host: store module: install links, allow-list, signed multi-app catalog, staging and installs
Moved from wayBetter for X (distribution.rs + desktop_store.rs) so every wayBetter app gets the Store; the app keeps its Tauri command wrappers and a `StoreConfig`. - store::catalog (pure): `StoreConfig` (app id in the shared catalog, product, user agent, link scheme, store URL, public keys, allowed hosts), `AllowedHost` (raw-files-only rule for a code host; DEFAULT_HOSTS = waybetter.fyi, cdn1.crt.fyi, git.crt.fyi raw), install-link parsing, download allow-list, theme validation, catalog format 2: every item names its `apps`; the whole catalog is verified (Ed25519 over the exact bytes with any listed key, so keys rotate; strict schema; ids/files unique per app) and each app gets only its own items (another app's item is shape-checked but not held to this app's hosts); per-item sha256/size, install records, versions. - store::install: `Store` (pending links, staged installs with TTL/bound, MIN_REVIEW before a commit, commit writes exactly the staged bytes after re-validation via PluginStore::install, plugin remove, records file, verified catalog + offline copy verified again when read back, StoreView). Plugin start/stop scripts run through a function the app passes. - store::net (`net` feature): no-redirect, no-cookie reqwest fetch with caps; stage_link, stage_item, catalog(refresh). - Tests: links, per-app allow-lists and schemes, themes, signatures incl. tampering and key rotation, multi-app filtering, bad items, staging/commit/remove/records/offline copy on disk, fetch refuses anything off the allow-list. test:rust and check:tauri build with `net`.
This commit is contained in:
parent
c14dce3862
commit
1629c6acc9
8 changed files with 1950 additions and 7 deletions
344
crates/waybetter-host/Cargo.lock
generated
344
crates/waybetter-host/Cargo.lock
generated
|
|
@ -410,6 +410,12 @@ dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
|
|
@ -532,6 +538,33 @@ version = "1.0.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
|
checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek"
|
||||||
|
version = "4.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"curve25519-dalek-derive",
|
||||||
|
"digest",
|
||||||
|
"fiat-crypto",
|
||||||
|
"rustc_version",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek-derive"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.24.1"
|
version = "0.24.1"
|
||||||
|
|
@ -608,6 +641,16 @@ dependencies = [
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der"
|
||||||
|
version = "0.7.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
|
dependencies = [
|
||||||
|
"const-oid",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
|
|
@ -767,6 +810,30 @@ version = "1.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519"
|
||||||
|
version = "2.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||||
|
dependencies = [
|
||||||
|
"pkcs8",
|
||||||
|
"signature",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519-dalek"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||||
|
dependencies = [
|
||||||
|
"curve25519-dalek",
|
||||||
|
"ed25519",
|
||||||
|
"serde",
|
||||||
|
"sha2",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
version = "3.0.11"
|
version = "3.0.11"
|
||||||
|
|
@ -829,6 +896,12 @@ dependencies = [
|
||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fiat-crypto"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "field-offset"
|
name = "field-offset"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
|
|
@ -1365,6 +1438,21 @@ dependencies = [
|
||||||
"want",
|
"want",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.21"
|
version = "0.1.21"
|
||||||
|
|
@ -1679,6 +1767,36 @@ dependencies = [
|
||||||
"windows-sys 0.45.0",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"combine",
|
||||||
|
"jni-macros",
|
||||||
|
"jni-sys 0.4.1",
|
||||||
|
"log",
|
||||||
|
"simd_cesu8",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
"walkdir",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-macros"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc_version",
|
||||||
|
"simd_cesu8",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni-sys"
|
name = "jni-sys"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|
@ -2206,6 +2324,12 @@ version = "0.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -2336,6 +2460,16 @@ version = "0.2.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkcs8"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||||
|
dependencies = [
|
||||||
|
"der",
|
||||||
|
"spki",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
|
|
@ -2525,6 +2659,9 @@ name = "rand_core"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "raw-window-handle"
|
name = "raw-window-handle"
|
||||||
|
|
@ -2614,15 +2751,20 @@ dependencies = [
|
||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
|
|
@ -2634,6 +2776,20 @@ dependencies = [
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
|
|
@ -2662,6 +2818,79 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-native-certs"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||||
|
dependencies = [
|
||||||
|
"openssl-probe",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-platform-verifier"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"jni 0.22.4",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"rustls-platform-verifier-android",
|
||||||
|
"rustls-webpki",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"webpki-root-certs",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-platform-verifier-android"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|
@ -2677,6 +2906,15 @@ dependencies = [
|
||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schannel"
|
||||||
|
version = "0.1.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
|
|
@ -2734,6 +2972,29 @@ version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "3.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"core-foundation",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
|
|
@ -2939,12 +3200,37 @@ version = "2.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signature"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.10"
|
version = "0.3.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd_cesu8"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||||
|
dependencies = [
|
||||||
|
"rustc_version",
|
||||||
|
"simdutf8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simdutf8"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
@ -3021,6 +3307,16 @@ dependencies = [
|
||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spki"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"der",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
|
|
@ -3166,7 +3462,7 @@ dependencies = [
|
||||||
"gdkwayland-sys",
|
"gdkwayland-sys",
|
||||||
"gdkx11-sys",
|
"gdkx11-sys",
|
||||||
"gtk",
|
"gtk",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk",
|
||||||
|
|
@ -3222,7 +3518,7 @@ dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
|
|
@ -3328,7 +3624,7 @@ dependencies = [
|
||||||
"dpi",
|
"dpi",
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
|
|
@ -3351,7 +3647,7 @@ checksum = "fa829eb69860cf1fb0bedb21d2d5f1effce3527d054a68ca1cd7355c654400a3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"log",
|
"log",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
|
|
@ -3540,6 +3836,16 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.19"
|
version = "0.7.19"
|
||||||
|
|
@ -3775,6 +4081,12 @@ dependencies = [
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
|
|
@ -3961,16 +4273,20 @@ dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"ed25519-dalek",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"gtk",
|
"gtk",
|
||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
|
"reqwest",
|
||||||
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sys-locale",
|
"sys-locale",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows",
|
||||||
|
|
@ -4052,6 +4368,15 @@ dependencies = [
|
||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-root-certs"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.39.1"
|
version = "0.39.1"
|
||||||
|
|
@ -4243,6 +4568,15 @@ dependencies = [
|
||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.59.0"
|
version = "0.59.0"
|
||||||
|
|
@ -4458,7 +4792,7 @@ dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"javascriptcore-rs",
|
"javascriptcore-rs",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"ndk",
|
"ndk",
|
||||||
"objc2",
|
"objc2",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ path = "src/lib.rs"
|
||||||
default = []
|
default = []
|
||||||
# Tauri glue (webview helpers, window system menu). Off for headless tests.
|
# Tauri glue (webview helpers, window system menu). Off for headless tests.
|
||||||
tauri = ["dep:tauri", "dep:windows", "dep:webview2-com", "dep:gtk", "dep:objc2-app-kit"]
|
tauri = ["dep:tauri", "dep:windows", "dep:webview2-com", "dep:gtk", "dep:objc2-app-kit"]
|
||||||
|
# Store downloads (`store::net`): allow-listed https, no redirects or cookies, size caps.
|
||||||
|
net = ["dep:reqwest", "dep:rustls"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|
@ -24,6 +26,8 @@ sha2 = "0.10"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
minisign-verify = "0.2"
|
minisign-verify = "0.2"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
# Store catalog signatures (Ed25519 over the exact catalog bytes).
|
||||||
|
ed25519-dalek = "2"
|
||||||
# The OS language preference (first run, "Follow system"); no dependencies of its own on desktop.
|
# The OS language preference (first run, "Follow system"); no dependencies of its own on desktop.
|
||||||
sys-locale = "0.3"
|
sys-locale = "0.3"
|
||||||
# Encrypted settings sync (a folder the owner picks): Argon2id key from the passphrase,
|
# Encrypted settings sync (a folder the owner picks): Argon2id key from the passphrase,
|
||||||
|
|
@ -32,6 +36,8 @@ argon2 = { version = "0.5", default-features = false, features = ["alloc"] }
|
||||||
chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] }
|
chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] }
|
||||||
getrandom = "0.2"
|
getrandom = "0.2"
|
||||||
tauri = { version = "2", features = ["unstable"], optional = true }
|
tauri = { version = "2", features = ["unstable"], optional = true }
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider"], optional = true }
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring"], optional = true }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"], optional = true }
|
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"], optional = true }
|
||||||
|
|
@ -48,3 +54,4 @@ objc2-app-kit = { version = "0.3", optional = true, default-features = false, fe
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
tokio = { version = "1", features = ["rt"] }
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@
|
||||||
//! - [`sync`]: encrypted settings sync through a folder the owner syncs (Argon2id + XChaCha20-Poly1305).
|
//! - [`sync`]: encrypted settings sync through a folder the owner syncs (Argon2id + XChaCha20-Poly1305).
|
||||||
//! - [`workspaces`]: saved tab/pane/deck layouts, bounded and well-formed (never evaluated).
|
//! - [`workspaces`]: saved tab/pane/deck layouts, bounded and well-formed (never evaluated).
|
||||||
//! - [`devmode`]: developer mode's debounced watcher of plugins, custom CSS and a theme file.
|
//! - [`devmode`]: developer mode's debounced watcher of plugins, custom CSS and a theme file.
|
||||||
|
//! - [`store`]: installing themes and plugins from `waybetter://install` links, dropped files and the
|
||||||
|
//! signed multi-app wayBetter Store (allow-list, caps, catalog signatures, staging, install records);
|
||||||
|
//! downloads with the `net` feature.
|
||||||
//!
|
//!
|
||||||
//! With the `tauri` feature, [`host`] adds the Tauri glue that does not depend on app state.
|
//! With the `tauri` feature, [`host`] adds the Tauri glue that does not depend on app state.
|
||||||
|
|
||||||
|
|
@ -32,6 +35,7 @@ pub mod plugins;
|
||||||
pub mod policy;
|
pub mod policy;
|
||||||
pub mod profiles;
|
pub mod profiles;
|
||||||
pub mod site;
|
pub mod site;
|
||||||
|
pub mod store;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
pub mod tabs;
|
pub mod tabs;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
|
|
||||||
788
crates/waybetter-host/src/store/catalog.rs
Normal file
788
crates/waybetter-host/src/store/catalog.rs
Normal file
|
|
@ -0,0 +1,788 @@
|
||||||
|
//! The pure half of distribution: install links and the download allow-list, theme files, the
|
||||||
|
//! signed multi-app store catalog, install records and version order. Headless and tested here.
|
||||||
|
|
||||||
|
use crate::plugins::{self, valid_file_name, ValidatedPlugin, MAX_PLUGIN_BYTES};
|
||||||
|
use base64::Engine;
|
||||||
|
use ed25519_dalek::{Signature, VerifyingKey};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::{BTreeMap, HashSet};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// Where the shared wayBetter Store lives. The catalog is `catalog.json` + `catalog.json.sig` under it.
|
||||||
|
pub const DEFAULT_STORE_BASE: &str = "https://waybetter.fyi/store/";
|
||||||
|
/// The framework's own download hosts: the site, its CDN, and raw files on its code host.
|
||||||
|
pub const DEFAULT_HOSTS: &[AllowedHost] = &[
|
||||||
|
AllowedHost { host: "waybetter.fyi", raw_files_only: false },
|
||||||
|
AllowedHost { host: "cdn1.crt.fyi", raw_files_only: false },
|
||||||
|
AllowedHost { host: "git.crt.fyi", raw_files_only: true },
|
||||||
|
];
|
||||||
|
/// The catalog format this code reads. Format 2: every item names the apps it is for (`apps`).
|
||||||
|
pub const CATALOG_FORMAT: u32 = 2;
|
||||||
|
pub const MAX_THEME_BYTES: usize = 2 * 1024 * 1024;
|
||||||
|
pub const MAX_CATALOG_BYTES: usize = 1024 * 1024;
|
||||||
|
pub const MAX_SIGNATURE_BYTES: usize = 512;
|
||||||
|
pub const MAX_LINK_CHARS: usize = 2048;
|
||||||
|
const MAX_ITEMS: usize = 500;
|
||||||
|
const MAX_ITEM_APPS: usize = 16;
|
||||||
|
|
||||||
|
/// A host install links and store items may point at.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct AllowedHost {
|
||||||
|
pub host: &'static str,
|
||||||
|
/// A code host (Gitea/Forgejo): only raw file paths, `/<owner>/<repo>/raw/...`.
|
||||||
|
pub raw_files_only: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an app tells the framework about its store. Everything else is the framework's.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct StoreConfig {
|
||||||
|
/// The app's id in the shared catalog (`apps: ["x"]`): lowercase letters, digits and `-`.
|
||||||
|
pub app_id: &'static str,
|
||||||
|
/// Log prefix, e.g. `wayBetter for X`.
|
||||||
|
pub product: &'static str,
|
||||||
|
/// HTTP user agent for store downloads, e.g. `wayBetter-for-X/1.1.0`.
|
||||||
|
pub user_agent: &'static str,
|
||||||
|
/// The URL scheme of install links (`waybetter` → `waybetter://install?...`).
|
||||||
|
pub scheme: &'static str,
|
||||||
|
/// The store folder URL, ending in `/` (usually [`DEFAULT_STORE_BASE`]).
|
||||||
|
pub store_base: &'static str,
|
||||||
|
/// Store public keys (base64 of the raw 32-byte Ed25519 key), compiled into the app. Any of
|
||||||
|
/// them may sign the catalog, so a new key can ship before the catalog is re-signed with it.
|
||||||
|
pub public_keys: &'static [&'static str],
|
||||||
|
/// Hosts links and store items may point at (usually [`DEFAULT_HOSTS`]).
|
||||||
|
pub allowed_hosts: &'static [AllowedHost],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Kind {
|
||||||
|
Theme,
|
||||||
|
Plugin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kind {
|
||||||
|
pub fn parse(value: &str) -> Result<Self, String> {
|
||||||
|
match value {
|
||||||
|
"theme" => Ok(Kind::Theme),
|
||||||
|
"plugin" => Ok(Kind::Plugin),
|
||||||
|
_ => Err("VALIDATION: kind must be \"theme\" or \"plugin\"".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Kind::Theme => "theme",
|
||||||
|
Kind::Plugin => "plugin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn max_bytes(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Kind::Theme => MAX_THEME_BYTES,
|
||||||
|
Kind::Plugin => MAX_PLUGIN_BYTES as usize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||||
|
Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A valid app id: 1–32 lowercase ASCII letters, digits or `-`.
|
||||||
|
pub fn valid_app_id(id: &str) -> bool {
|
||||||
|
!id.is_empty() && id.len() <= 32 && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An https URL on an allowed host that native code may download from: default port, no
|
||||||
|
/// credentials, no fragment; on a raw-files-only host only `/<owner>/<repo>/raw/...` paths.
|
||||||
|
pub fn allowed_download_url(hosts: &[AllowedHost], url: &Url) -> bool {
|
||||||
|
if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() || url.port().is_some() || url.fragment().is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if url.as_str().len() > MAX_LINK_CHARS {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(host) = url.host_str() else { return false };
|
||||||
|
let Some(rule) = hosts.iter().find(|allowed| allowed.host == host) else { return false };
|
||||||
|
if rule.raw_files_only {
|
||||||
|
let segments: Vec<&str> = url.path_segments().map(|s| s.collect()).unwrap_or_default();
|
||||||
|
return segments.len() >= 4 && segments[2] == "raw" && segments.iter().all(|s| !s.is_empty() && *s != "..");
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hosts_text(hosts: &[AllowedHost]) -> String {
|
||||||
|
let names: Vec<&str> = hosts.iter().map(|h| h.host).collect();
|
||||||
|
let raw: Vec<&str> = hosts.iter().filter(|h| h.raw_files_only).map(|h| h.host).collect();
|
||||||
|
if raw.is_empty() {
|
||||||
|
names.join(", ")
|
||||||
|
} else {
|
||||||
|
format!("{} ({}: raw files)", names.join(", "), raw.join(", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a `waybetter://install` link asks for.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InstallLink {
|
||||||
|
pub kind: Kind,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses `<scheme>://install?kind=theme|plugin&url=<https URL>`. Anything else (another host or
|
||||||
|
/// path, unknown or repeated parameters, a URL that is not allowed) is refused.
|
||||||
|
pub fn parse_install_link(config: &StoreConfig, link: &str) -> Result<InstallLink, String> {
|
||||||
|
if link.len() > MAX_LINK_CHARS {
|
||||||
|
return Err("LINK: too long".into());
|
||||||
|
}
|
||||||
|
let parsed = Url::parse(link.trim()).map_err(|_| "LINK: not a URL")?;
|
||||||
|
if parsed.scheme() != config.scheme {
|
||||||
|
return Err(format!("LINK: not a {}:// link", config.scheme));
|
||||||
|
}
|
||||||
|
// `waybetter://install?...` (host form) and `waybetter:install?...` (path form) both occur.
|
||||||
|
let action = parsed.host_str().map(str::to_owned).unwrap_or_else(|| parsed.path().to_owned());
|
||||||
|
let action = action.trim_matches('/');
|
||||||
|
if action != "install" || !matches!(parsed.path(), "" | "/" | "install") {
|
||||||
|
return Err(format!("LINK: only {}://install links are supported", config.scheme));
|
||||||
|
}
|
||||||
|
let mut kind = None;
|
||||||
|
let mut url = None;
|
||||||
|
for (key, value) in parsed.query_pairs() {
|
||||||
|
let slot = match key.as_ref() {
|
||||||
|
"kind" => &mut kind,
|
||||||
|
"url" => &mut url,
|
||||||
|
_ => return Err("LINK: unknown parameter".into()),
|
||||||
|
};
|
||||||
|
if slot.replace(value.into_owned()).is_some() {
|
||||||
|
return Err("LINK: repeated parameter".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let kind = Kind::parse(kind.as_deref().ok_or("LINK: missing kind")?).map_err(|_| "LINK: kind must be theme or plugin")?;
|
||||||
|
let target = Url::parse(url.as_deref().ok_or("LINK: missing url")?).map_err(|_| "LINK: url is not a URL")?;
|
||||||
|
if !allowed_download_url(config.allowed_hosts, &target) {
|
||||||
|
return Err(format!("LINK: only https links on {} can be installed", hosts_text(config.allowed_hosts)));
|
||||||
|
}
|
||||||
|
if kind == Kind::Plugin {
|
||||||
|
plugin_file_from_url(&target)?;
|
||||||
|
}
|
||||||
|
Ok(InstallLink { kind, url: target.to_string() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The install link for `kind` and `url` (for sites and tests; parsing it applies every rule).
|
||||||
|
pub fn install_link(config: &StoreConfig, kind: Kind, url: &str) -> String {
|
||||||
|
format!("{}://install?kind={}&url={}", config.scheme, kind.as_str(), url::form_urlencoded::byte_serialize(url.as_bytes()).collect::<String>())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file name a plugin downloaded from `url` is saved under: its last path segment.
|
||||||
|
pub fn plugin_file_from_url(url: &Url) -> Result<String, String> {
|
||||||
|
let segment = url.path_segments().and_then(|mut s| s.next_back()).unwrap_or_default();
|
||||||
|
let decoded = percent_decode(segment).ok_or("LINK: invalid file name")?;
|
||||||
|
if !valid_file_name(&decoded) {
|
||||||
|
return Err("LINK: a plugin link must end in a file named like name.plugin.js".into());
|
||||||
|
}
|
||||||
|
Ok(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_decode(value: &str) -> Option<String> {
|
||||||
|
let bytes = value.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%' {
|
||||||
|
let hex = std::str::from_utf8(bytes.get(i + 1..i + 3)?).ok()?;
|
||||||
|
out.push(u8::from_str_radix(hex, 16).ok()?);
|
||||||
|
i += 3;
|
||||||
|
} else {
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8(out).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the confirmation screen shows about a theme file (the shell parses the rest).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ThemeInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub author: String,
|
||||||
|
pub description: String,
|
||||||
|
/// Characters of theme CSS (the one part with custom-CSS trust).
|
||||||
|
pub css_chars: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn short(value: Option<&serde_json::Value>, max: usize) -> String {
|
||||||
|
value.and_then(serde_json::Value::as_str).unwrap_or_default().chars().filter(|ch| !ch.is_control()).take(max).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A theme file: at most [`MAX_THEME_BYTES`], UTF-8 JSON, an object with a non-empty `name`.
|
||||||
|
pub fn validate_theme(bytes: &[u8]) -> Result<ThemeInfo, String> {
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Err("VALIDATION: the theme file is empty".into());
|
||||||
|
}
|
||||||
|
if bytes.len() > MAX_THEME_BYTES {
|
||||||
|
return Err("VALIDATION: themes are limited to 2 MiB".into());
|
||||||
|
}
|
||||||
|
let text = std::str::from_utf8(bytes).map_err(|_| "VALIDATION: a theme must be UTF-8 JSON")?;
|
||||||
|
let value: serde_json::Value = serde_json::from_str(text.trim_start_matches('\u{feff}')).map_err(|_| "VALIDATION: this file is not valid JSON")?;
|
||||||
|
let object = value.as_object().ok_or("VALIDATION: a theme is a JSON object")?;
|
||||||
|
let name = short(object.get("name"), 60);
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err("VALIDATION: a theme needs a \"name\"".into());
|
||||||
|
}
|
||||||
|
Ok(ThemeInfo {
|
||||||
|
name,
|
||||||
|
author: short(object.get("author"), 60),
|
||||||
|
description: short(object.get("description"), 200),
|
||||||
|
css_chars: object.get("css").and_then(serde_json::Value::as_str).map(|css| css.chars().count()).unwrap_or(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a plugin for installation (see `plugins::validate_plugin`).
|
||||||
|
pub fn validate_plugin(file: &str, bytes: &[u8]) -> Result<ValidatedPlugin, String> {
|
||||||
|
plugins::validate_plugin(file, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Store catalog
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct Swatch {
|
||||||
|
pub bg: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub surface: Option<String>,
|
||||||
|
pub text: String,
|
||||||
|
pub accent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct CatalogItem {
|
||||||
|
/// The wayBetter apps this item is for (app ids, e.g. `["x"]`); never empty.
|
||||||
|
pub apps: Vec<String>,
|
||||||
|
pub kind: Kind,
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub author: String,
|
||||||
|
pub version: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub category: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub url: String,
|
||||||
|
/// Plugins: the file name it is installed under.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub file: Option<String>,
|
||||||
|
pub sha256: String,
|
||||||
|
pub size: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub preview: Option<String>,
|
||||||
|
/// Plugins: declared permissions (`None` = legacy, no `@permissions` line).
|
||||||
|
#[serde(default)]
|
||||||
|
pub permissions: Option<Vec<String>>,
|
||||||
|
/// Themes: a few colors for swatches.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub colors: Option<Swatch>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct Catalog {
|
||||||
|
pub format: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub generated_at: String,
|
||||||
|
pub items: Vec<CatalogItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Catalog {
|
||||||
|
/// The items for `app` only (what that app shows, stages and installs).
|
||||||
|
pub fn for_app(mut self, app: &str) -> Catalog {
|
||||||
|
self.items.retain(|item| item.is_for(app));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_id(id: &str) -> bool {
|
||||||
|
!id.is_empty() && id.len() <= 40 && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_text(value: &str, max: usize) -> bool {
|
||||||
|
value.chars().count() <= max && !value.chars().any(char::is_control)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_color(value: &str) -> bool {
|
||||||
|
value.len() == 7 && value.starts_with('#') && value[1..].bytes().all(|b| b.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CatalogItem {
|
||||||
|
pub fn is_for(&self, app: &str) -> bool {
|
||||||
|
self.apps.iter().any(|id| id == app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shape rules every item must follow, whichever app it is for; the download hosts are
|
||||||
|
/// checked against `hosts` (the reading app's allow-list) for that app's own items.
|
||||||
|
fn check(&self, hosts: Option<&[AllowedHost]>) -> Result<(), String> {
|
||||||
|
let bad = |what: &str| Err(format!("STORE: item {}: invalid {what}", self.id.chars().take(40).collect::<String>()));
|
||||||
|
if !valid_id(&self.id) {
|
||||||
|
return bad("id");
|
||||||
|
}
|
||||||
|
let mut apps = HashSet::new();
|
||||||
|
if self.apps.is_empty() || self.apps.len() > MAX_ITEM_APPS || !self.apps.iter().all(|app| valid_app_id(app) && apps.insert(app.as_str())) {
|
||||||
|
return bad("apps");
|
||||||
|
}
|
||||||
|
if self.name.trim().is_empty() || !valid_text(&self.name, 60) || !valid_text(&self.author, 60) || !valid_text(&self.description, 600) {
|
||||||
|
return bad("name, author or description");
|
||||||
|
}
|
||||||
|
if self.version.is_empty() || !valid_text(&self.version, 32) || !valid_text(&self.category, 32) {
|
||||||
|
return bad("version or category");
|
||||||
|
}
|
||||||
|
if self.tags.len() > 12 || self.tags.iter().any(|tag| !valid_text(tag, 32)) {
|
||||||
|
return bad("tags");
|
||||||
|
}
|
||||||
|
let url = Url::parse(&self.url).map_err(|_| format!("STORE: item {}: invalid url", self.id))?;
|
||||||
|
if url.scheme() != "https" || url.as_str().len() > MAX_LINK_CHARS || hosts.is_some_and(|hosts| !allowed_download_url(hosts, &url)) {
|
||||||
|
return bad("url (host not allowed)");
|
||||||
|
}
|
||||||
|
if let Some(preview) = &self.preview {
|
||||||
|
let ok = Url::parse(preview).is_ok_and(|url| url.scheme() == "https" && hosts.is_none_or(|hosts| allowed_download_url(hosts, &url)));
|
||||||
|
if !ok {
|
||||||
|
return bad("preview");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.sha256.len() != 64 || !self.sha256.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) {
|
||||||
|
return bad("sha256");
|
||||||
|
}
|
||||||
|
if self.size == 0 || self.size > self.kind.max_bytes() as u64 {
|
||||||
|
return bad("size");
|
||||||
|
}
|
||||||
|
match self.kind {
|
||||||
|
Kind::Plugin => {
|
||||||
|
if !self.file.as_deref().is_some_and(valid_file_name) || self.colors.is_some() {
|
||||||
|
return bad("plugin file");
|
||||||
|
}
|
||||||
|
if let Some(list) = &self.permissions {
|
||||||
|
let joined = list.join(",");
|
||||||
|
if plugins::parse_permissions(&joined).map(|parsed| parsed.len() != list.len()).unwrap_or(true) {
|
||||||
|
return bad("permissions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Theme => {
|
||||||
|
if self.file.is_some() || self.permissions.is_some() {
|
||||||
|
return bad("theme fields");
|
||||||
|
}
|
||||||
|
if let Some(colors) = &self.colors {
|
||||||
|
let ok = [Some(&colors.bg), colors.surface.as_ref(), Some(&colors.text), Some(&colors.accent)].into_iter().flatten().all(|c| hex_color(c));
|
||||||
|
if !ok {
|
||||||
|
return bad("colors");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a store public key (base64 of the raw 32 bytes).
|
||||||
|
pub fn decode_key(text: &str) -> Result<VerifyingKey, String> {
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD.decode(text.trim()).map_err(|_| "STORE: invalid public key")?;
|
||||||
|
let bytes: [u8; 32] = bytes.try_into().map_err(|_| "STORE: invalid public key")?;
|
||||||
|
VerifyingKey::from_bytes(&bytes).map_err(|_| "STORE: invalid public key".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies the detached signature (base64 of 64 bytes) over the exact catalog bytes with any of
|
||||||
|
/// the app's public keys, then parses and checks the whole catalog, and returns the items for
|
||||||
|
/// `config.app_id` only ([`Catalog::for_app`]). Nothing in an unverified catalog is looked at; a
|
||||||
|
/// malformed item for another app still refuses the catalog (one signed file, one set of rules).
|
||||||
|
pub fn verify_catalog(config: &StoreConfig, body: &[u8], signature: &str) -> Result<Catalog, String> {
|
||||||
|
if body.len() > MAX_CATALOG_BYTES {
|
||||||
|
return Err("STORE: catalog too large".into());
|
||||||
|
}
|
||||||
|
if signature.len() > MAX_SIGNATURE_BYTES {
|
||||||
|
return Err("STORE: signature too large".into());
|
||||||
|
}
|
||||||
|
let raw = base64::engine::general_purpose::STANDARD.decode(signature.trim()).map_err(|_| "STORE: invalid signature")?;
|
||||||
|
let raw: [u8; 64] = raw.try_into().map_err(|_| "STORE: invalid signature")?;
|
||||||
|
let signature = Signature::from_bytes(&raw);
|
||||||
|
let verified = config
|
||||||
|
.public_keys
|
||||||
|
.iter()
|
||||||
|
.filter_map(|key| decode_key(key).ok())
|
||||||
|
.any(|key| key.verify_strict(body, &signature).is_ok());
|
||||||
|
if !verified {
|
||||||
|
return Err("STORE: the catalog signature does not verify".into());
|
||||||
|
}
|
||||||
|
let catalog: Catalog = serde_json::from_slice(body).map_err(|error| format!("STORE: catalog schema: {error}"))?;
|
||||||
|
if catalog.format != CATALOG_FORMAT {
|
||||||
|
return Err("STORE: unsupported catalog format (update the app)".into());
|
||||||
|
}
|
||||||
|
if catalog.items.len() > MAX_ITEMS {
|
||||||
|
return Err("STORE: too many items".into());
|
||||||
|
}
|
||||||
|
let mut ids = HashSet::new();
|
||||||
|
let mut files = HashSet::new();
|
||||||
|
for item in &catalog.items {
|
||||||
|
item.check(item.is_for(config.app_id).then_some(config.allowed_hosts))?;
|
||||||
|
for app in &item.apps {
|
||||||
|
if !ids.insert((app.clone(), item.kind, item.id.clone())) {
|
||||||
|
return Err(format!("STORE: duplicate item {}", item.id));
|
||||||
|
}
|
||||||
|
if let Some(file) = &item.file {
|
||||||
|
if !files.insert((app.clone(), file.to_ascii_lowercase())) {
|
||||||
|
return Err(format!("STORE: duplicate plugin file {file}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(catalog.for_app(config.app_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A downloaded item must be exactly what the signed catalog describes.
|
||||||
|
pub fn verify_item(item: &CatalogItem, bytes: &[u8]) -> Result<(), String> {
|
||||||
|
if bytes.len() as u64 != item.size {
|
||||||
|
return Err("STORE: the download's size does not match the signed catalog".into());
|
||||||
|
}
|
||||||
|
if sha256_hex(bytes) != item.sha256 {
|
||||||
|
return Err("STORE: the download's sha256 does not match the signed catalog".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Where installed things came from
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum SourceKind {
|
||||||
|
Store,
|
||||||
|
Link,
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One install the owner confirmed. Keyed `plugin:<file>` or `theme:<id>` in [`InstallRecords`].
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InstallRecord {
|
||||||
|
pub kind: Kind,
|
||||||
|
pub source: SourceKind,
|
||||||
|
/// The link or store item URL (none for a dropped file).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// Store item id, for update checks.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub store_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub version: String,
|
||||||
|
pub sha256: String,
|
||||||
|
pub installed_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InstallRecords {
|
||||||
|
#[serde(default)]
|
||||||
|
pub items: BTreeMap<String, InstallRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InstallRecords {
|
||||||
|
pub fn key(kind: Kind, name: &str) -> String {
|
||||||
|
format!("{}:{name}", kind.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded: keeps the newest 1000 records.
|
||||||
|
pub fn insert(&mut self, key: String, record: InstallRecord) {
|
||||||
|
self.items.insert(key, record);
|
||||||
|
while self.items.len() > 1000 {
|
||||||
|
let oldest = self.items.iter().min_by(|a, b| a.1.installed_at.cmp(&b.1.installed_at)).map(|(k, _)| k.clone());
|
||||||
|
match oldest {
|
||||||
|
Some(key) => self.items.remove(&key),
|
||||||
|
None => break,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `a` is a newer version than `b` (semver-ish: numeric dot parts compared as numbers).
|
||||||
|
pub fn newer_version(a: &str, b: &str) -> bool {
|
||||||
|
let parts = |v: &str| v.split(['.', '-', '+']).map(|p| p.parse::<u64>().unwrap_or(0)).collect::<Vec<_>>();
|
||||||
|
let (a, b) = (parts(a), parts(b));
|
||||||
|
for i in 0..a.len().max(b.len()) {
|
||||||
|
let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0));
|
||||||
|
if x != y {
|
||||||
|
return x > y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(crate) fn config(app: &'static str, keys: &[String]) -> &'static StoreConfig {
|
||||||
|
let keys: Vec<&'static str> = keys.iter().map(|key| &*Box::leak(key.clone().into_boxed_str())).collect();
|
||||||
|
Box::leak(Box::new(StoreConfig {
|
||||||
|
app_id: app,
|
||||||
|
product: "wayBetter test",
|
||||||
|
user_agent: "wayBetter-test/0",
|
||||||
|
scheme: "waybetter",
|
||||||
|
store_base: DEFAULT_STORE_BASE,
|
||||||
|
public_keys: Box::leak(keys.into_boxed_slice()),
|
||||||
|
allowed_hosts: DEFAULT_HOSTS,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_config() -> &'static StoreConfig {
|
||||||
|
config("x", &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_links_accept_only_allow_listed_https_urls() {
|
||||||
|
let c = link_config();
|
||||||
|
let ok = parse_install_link(c, "waybetter://install?kind=theme&url=https%3A%2F%2Fwaybetter.fyi%2Fstore%2Fthemes%2Fnord.json").unwrap();
|
||||||
|
assert_eq!(ok, InstallLink { kind: Kind::Theme, url: "https://waybetter.fyi/store/themes/nord.json".into() });
|
||||||
|
let plugin = parse_install_link(c, "waybetter://install?kind=plugin&url=https://cdn1.crt.fyi/p/focus.plugin.js").unwrap();
|
||||||
|
assert_eq!(plugin.kind, Kind::Plugin);
|
||||||
|
assert!(parse_install_link(c, "waybetter://install?kind=plugin&url=https://git.crt.fyi/CRT/plugins/raw/branch/main/a.plugin.js").is_ok());
|
||||||
|
assert_eq!(parse_install_link(c, &install_link(c, Kind::Theme, "https://waybetter.fyi/store/themes/nord.json")).unwrap(), ok);
|
||||||
|
for bad in [
|
||||||
|
"https://waybetter.fyi/install?kind=theme&url=https://waybetter.fyi/a.json",
|
||||||
|
"waybetter://uninstall?kind=theme&url=https://waybetter.fyi/a.json",
|
||||||
|
"waybetter://install/extra?kind=theme&url=https://waybetter.fyi/a.json",
|
||||||
|
"waybetter://install?kind=script&url=https://waybetter.fyi/a.json",
|
||||||
|
"waybetter://install?kind=theme",
|
||||||
|
"waybetter://install?kind=theme&url=http://waybetter.fyi/a.json",
|
||||||
|
"waybetter://install?kind=theme&url=https://evil.example/a.json",
|
||||||
|
"waybetter://install?kind=theme&url=https://waybetter.fyi.evil.example/a.json",
|
||||||
|
"waybetter://install?kind=theme&url=https://user:pw@waybetter.fyi/a.json",
|
||||||
|
"waybetter://install?kind=theme&url=https://waybetter.fyi:8443/a.json",
|
||||||
|
"waybetter://install?kind=theme&url=https://waybetter.fyi/a.json%23x#frag",
|
||||||
|
"waybetter://install?kind=theme&url=file:///C:/a.json",
|
||||||
|
"waybetter://install?kind=theme&url=https://git.crt.fyi/CRT/repo/src/branch/main/a.json",
|
||||||
|
"waybetter://install?kind=theme&url=https://git.crt.fyi/CRT/repo/raw/../../a.json",
|
||||||
|
"waybetter://install?kind=theme&url=https://waybetter.fyi/a.json&url=https://waybetter.fyi/b.json",
|
||||||
|
"waybetter://install?kind=theme&url=https://waybetter.fyi/a.json&then=run",
|
||||||
|
"waybetter://install?kind=plugin&url=https://waybetter.fyi/store/plugins/tool.js",
|
||||||
|
"waybetter://install?kind=plugin&url=https://waybetter.fyi/store/plugins/..%252Fx.plugin.js",
|
||||||
|
"otherapp://install?kind=theme&url=https://waybetter.fyi/a.json",
|
||||||
|
] {
|
||||||
|
assert!(parse_install_link(c, bad).is_err(), "accepted {bad}");
|
||||||
|
}
|
||||||
|
assert!(parse_install_link(c, &format!("waybetter://install?kind=theme&url=https://waybetter.fyi/{}", "a".repeat(MAX_LINK_CHARS))).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allow_lists_are_per_app() {
|
||||||
|
const ONLY_CDN: &[AllowedHost] = &[AllowedHost { host: "cdn.example", raw_files_only: false }];
|
||||||
|
let narrow = StoreConfig { allowed_hosts: ONLY_CDN, scheme: "wbkick", ..*link_config() };
|
||||||
|
assert!(parse_install_link(&narrow, "wbkick://install?kind=theme&url=https://cdn.example/a.json").is_ok());
|
||||||
|
assert!(parse_install_link(&narrow, "wbkick://install?kind=theme&url=https://waybetter.fyi/a.json").unwrap_err().contains("cdn.example"));
|
||||||
|
assert!(parse_install_link(&narrow, "waybetter://install?kind=theme&url=https://cdn.example/a.json").unwrap_err().contains("wbkick://"));
|
||||||
|
let url = Url::parse("https://git.crt.fyi/CRT/repo/raw/branch/main/a.json").unwrap();
|
||||||
|
assert!(allowed_download_url(DEFAULT_HOSTS, &url) && !allowed_download_url(ONLY_CDN, &url));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_file_names_come_from_the_last_path_segment() {
|
||||||
|
let url = Url::parse("https://waybetter.fyi/store/plugins/My%20Tool.plugin.js").unwrap();
|
||||||
|
assert_eq!(plugin_file_from_url(&url).unwrap(), "My Tool.plugin.js");
|
||||||
|
let url = Url::parse("https://waybetter.fyi/store/plugins/%2E%2E%5Cx.plugin.js").unwrap();
|
||||||
|
assert!(plugin_file_from_url(&url).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn themes_must_be_named_json_objects_under_the_cap() {
|
||||||
|
let info = validate_theme(br#"{"name":"Nord","author":"me","css":"a{}"}"#).unwrap();
|
||||||
|
assert_eq!((info.name.as_str(), info.author.as_str(), info.css_chars), ("Nord", "me", 3));
|
||||||
|
assert!(validate_theme(b"").is_err());
|
||||||
|
assert!(validate_theme(b"[1,2]").unwrap_err().contains("object"));
|
||||||
|
assert!(validate_theme(br#"{"colors":{}}"#).unwrap_err().contains("name"));
|
||||||
|
assert!(validate_theme(b"{not json").unwrap_err().contains("JSON"));
|
||||||
|
assert!(validate_theme(&[0xff, 0xfe]).unwrap_err().contains("UTF-8"));
|
||||||
|
let big = format!(r#"{{"name":"Big","css":"{}"}}"#, "a".repeat(MAX_THEME_BYTES));
|
||||||
|
assert!(validate_theme(big.as_bytes()).unwrap_err().contains("2 MiB"));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn test_key(seed: u8) -> (ed25519_dalek::SigningKey, String) {
|
||||||
|
let key = ed25519_dalek::SigningKey::from_bytes(&[seed; 32]);
|
||||||
|
let public = base64::engine::general_purpose::STANDARD.encode(key.verifying_key().to_bytes());
|
||||||
|
(key, public)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sign(key: &ed25519_dalek::SigningKey, body: &[u8]) -> String {
|
||||||
|
use ed25519_dalek::Signer;
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(key.sign(body).to_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn catalog_json(items: serde_json::Value) -> Vec<u8> {
|
||||||
|
serde_json::to_vec(&serde_json::json!({ "format": CATALOG_FORMAT, "name": "Test", "generatedAt": "2026-09-28T00:00:00Z", "items": items })).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn plugin_item(body: &[u8]) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"apps": ["x"], "kind": "plugin", "id": "tool", "name": "Tool", "author": "me", "version": "1.0.0", "description": "d",
|
||||||
|
"category": "tools", "tags": ["x"], "url": "https://waybetter.fyi/store/plugins/tool.plugin.js", "file": "tool.plugin.js",
|
||||||
|
"sha256": sha256_hex(body), "size": body.len(), "preview": "https://waybetter.fyi/store/previews/tool.svg", "permissions": ["ui"]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn theme_item(body: &[u8]) -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"apps": ["x"], "kind": "theme", "id": "nord", "name": "Nord", "version": "1.0.0", "category": "dark",
|
||||||
|
"url": "https://waybetter.fyi/store/themes/nord.json", "sha256": sha256_hex(body), "size": body.len(),
|
||||||
|
"colors": { "bg": "#2e3440", "text": "#eceff4", "accent": "#88c0d0" }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) const PLUGIN: &[u8] = b"/**\n * @name Tool\n * @version 1.0.0\n * @permissions ui\n */\nmodule.exports = class {};";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_signature_and_items_are_verified() {
|
||||||
|
let (key, public) = test_key(7);
|
||||||
|
let body = catalog_json(serde_json::json!([plugin_item(PLUGIN)]));
|
||||||
|
let signature = sign(&key, &body);
|
||||||
|
let c = config("x", std::slice::from_ref(&public));
|
||||||
|
let catalog = verify_catalog(c, &body, &signature).unwrap();
|
||||||
|
assert_eq!(catalog.items[0].file.as_deref(), Some("tool.plugin.js"));
|
||||||
|
verify_item(&catalog.items[0], PLUGIN).unwrap();
|
||||||
|
assert!(verify_item(&catalog.items[0], b"tampered").is_err());
|
||||||
|
let mut same_size = PLUGIN.to_vec();
|
||||||
|
same_size[20] ^= 1;
|
||||||
|
assert!(verify_item(&catalog.items[0], &same_size).unwrap_err().contains("sha256"));
|
||||||
|
|
||||||
|
// One changed byte, another key, a garbage or oversized signature: refused.
|
||||||
|
let mut tampered = body.clone();
|
||||||
|
let at = tampered.iter().position(|b| *b == b'T').unwrap();
|
||||||
|
tampered[at] = b'X';
|
||||||
|
assert!(verify_catalog(c, &tampered, &signature).unwrap_err().contains("does not verify"));
|
||||||
|
let (_, other) = test_key(9);
|
||||||
|
assert!(verify_catalog(config("x", std::slice::from_ref(&other)), &body, &signature).is_err());
|
||||||
|
assert!(verify_catalog(config("x", &[]), &body, &signature).is_err(), "no keys, nothing verifies");
|
||||||
|
assert!(verify_catalog(config("x", &["garbage".into()]), &body, &signature).is_err());
|
||||||
|
assert!(verify_catalog(c, &body, "not base64!").is_err());
|
||||||
|
assert!(verify_catalog(c, &body, &"A".repeat(MAX_SIGNATURE_BYTES + 1)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_rotate_any_listed_key_may_sign() {
|
||||||
|
let (old, old_public) = test_key(7);
|
||||||
|
let (new, new_public) = test_key(11);
|
||||||
|
let body = catalog_json(serde_json::json!([plugin_item(PLUGIN)]));
|
||||||
|
let both = config("x", &[old_public.clone(), new_public.clone()]);
|
||||||
|
// An app that ships both keys accepts the catalog signed with either one...
|
||||||
|
assert!(verify_catalog(both, &body, &sign(&old, &body)).is_ok());
|
||||||
|
assert!(verify_catalog(both, &body, &sign(&new, &body)).is_ok());
|
||||||
|
// ...and once the old key is dropped, only the new signature verifies.
|
||||||
|
assert!(verify_catalog(config("x", &[new_public]), &body, &sign(&old, &body)).is_err());
|
||||||
|
assert!(verify_catalog(config("x", &[old_public]), &body, &sign(&new, &body)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_catalog_serves_every_app_each_sees_only_its_own_items() {
|
||||||
|
let (key, public) = test_key(7);
|
||||||
|
let mut kick = plugin_item(PLUGIN);
|
||||||
|
kick["apps"] = serde_json::json!(["kick"]);
|
||||||
|
let mut both = theme_item(b"{}");
|
||||||
|
both["apps"] = serde_json::json!(["x", "kick"]);
|
||||||
|
let body = catalog_json(serde_json::json!([plugin_item(PLUGIN), kick, both]));
|
||||||
|
let signature = sign(&key, &body);
|
||||||
|
let x = verify_catalog(config("x", std::slice::from_ref(&public)), &body, &signature).unwrap();
|
||||||
|
assert_eq!(x.items.iter().map(|i| i.id.as_str()).collect::<Vec<_>>(), ["tool", "nord"]);
|
||||||
|
let kick = verify_catalog(config("kick", std::slice::from_ref(&public)), &body, &signature).unwrap();
|
||||||
|
assert_eq!(kick.items.iter().map(|i| (i.id.as_str(), i.apps.len())).collect::<Vec<_>>(), [("tool", 1), ("nord", 2)]);
|
||||||
|
assert!(verify_catalog(config("whatsapp", &[public]), &body, &signature).unwrap().items.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn another_apps_item_is_checked_for_shape_but_not_against_this_apps_hosts() {
|
||||||
|
let (key, public) = test_key(7);
|
||||||
|
let mut other = plugin_item(PLUGIN);
|
||||||
|
other["apps"] = serde_json::json!(["kick"]);
|
||||||
|
other["url"] = serde_json::json!("https://kick-cdn.example/tool.plugin.js");
|
||||||
|
other["preview"] = serde_json::json!("https://kick-cdn.example/tool.svg");
|
||||||
|
let body = catalog_json(serde_json::json!([other.clone()]));
|
||||||
|
assert!(verify_catalog(config("x", std::slice::from_ref(&public)), &body, &sign(&key, &body)).is_ok());
|
||||||
|
// The same item for this app: its host is not on this app's list.
|
||||||
|
other["apps"] = serde_json::json!(["x"]);
|
||||||
|
let body = catalog_json(serde_json::json!([other.clone()]));
|
||||||
|
assert!(verify_catalog(config("x", std::slice::from_ref(&public)), &body, &sign(&key, &body)).unwrap_err().contains("host"));
|
||||||
|
// Not https, or malformed, is refused for every app.
|
||||||
|
other["apps"] = serde_json::json!(["kick"]);
|
||||||
|
other["url"] = serde_json::json!("http://kick-cdn.example/tool.plugin.js");
|
||||||
|
let body = catalog_json(serde_json::json!([other]));
|
||||||
|
assert!(verify_catalog(config("x", &[public]), &body, &sign(&key, &body)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signed_catalogs_with_bad_items_are_still_refused() {
|
||||||
|
let (key, public) = test_key(7);
|
||||||
|
let c = config("x", &[public]);
|
||||||
|
let good = plugin_item(b"x");
|
||||||
|
let cases: Vec<(&str, serde_json::Value)> = vec![
|
||||||
|
("url", serde_json::json!({ "url": "https://evil.example/tool.plugin.js" })),
|
||||||
|
("preview", serde_json::json!({ "preview": "http://waybetter.fyi/p.svg" })),
|
||||||
|
("sha256", serde_json::json!({ "sha256": "ABC" })),
|
||||||
|
("size", serde_json::json!({ "size": MAX_PLUGIN_BYTES + 1 })),
|
||||||
|
("file", serde_json::json!({ "file": "../tool.plugin.js" })),
|
||||||
|
("permissions", serde_json::json!({ "permissions": ["root"] })),
|
||||||
|
("id", serde_json::json!({ "id": "Tool!" })),
|
||||||
|
("unknown field", serde_json::json!({ "run": "code" })),
|
||||||
|
("no apps", serde_json::json!({ "apps": [] })),
|
||||||
|
("bad app id", serde_json::json!({ "apps": ["X"] })),
|
||||||
|
("repeated app", serde_json::json!({ "apps": ["x", "x"] })),
|
||||||
|
("apps not a list", serde_json::json!({ "apps": "x" })),
|
||||||
|
];
|
||||||
|
for (what, patch) in cases {
|
||||||
|
let mut item = good.clone();
|
||||||
|
for (k, v) in patch.as_object().unwrap() {
|
||||||
|
item[k] = v.clone();
|
||||||
|
}
|
||||||
|
let body = catalog_json(serde_json::json!([item]));
|
||||||
|
assert!(verify_catalog(c, &body, &sign(&key, &body)).is_err(), "accepted bad {what}");
|
||||||
|
}
|
||||||
|
let mut missing = good.clone();
|
||||||
|
missing.as_object_mut().unwrap().remove("apps");
|
||||||
|
let body = catalog_json(serde_json::json!([missing]));
|
||||||
|
assert!(verify_catalog(c, &body, &sign(&key, &body)).is_err(), "accepted an item without apps");
|
||||||
|
let body = catalog_json(serde_json::json!([good.clone(), good.clone()]));
|
||||||
|
assert!(verify_catalog(c, &body, &sign(&key, &body)).unwrap_err().contains("duplicate"));
|
||||||
|
let mut same_file = good.clone();
|
||||||
|
same_file["id"] = serde_json::json!("tool-2");
|
||||||
|
same_file["file"] = serde_json::json!("TOOL.plugin.js");
|
||||||
|
let body = catalog_json(serde_json::json!([good.clone(), same_file.clone()]));
|
||||||
|
assert!(verify_catalog(c, &body, &sign(&key, &body)).unwrap_err().contains("duplicate plugin file"));
|
||||||
|
// The same id or file for two different apps is fine.
|
||||||
|
same_file["apps"] = serde_json::json!(["kick"]);
|
||||||
|
same_file["id"] = serde_json::json!("tool");
|
||||||
|
let body = catalog_json(serde_json::json!([good, same_file]));
|
||||||
|
assert!(verify_catalog(c, &body, &sign(&key, &body)).is_ok());
|
||||||
|
// Format 1 (no apps) is refused with an update hint.
|
||||||
|
let body = serde_json::to_vec(&serde_json::json!({ "format": 1, "name": "Old", "generatedAt": "2026-09-28T00:00:00Z", "items": [] })).unwrap();
|
||||||
|
assert!(verify_catalog(c, &body, &sign(&key, &body)).unwrap_err().contains("update the app"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn versions_compare_numerically() {
|
||||||
|
assert!(newer_version("1.10.0", "1.9.3"));
|
||||||
|
assert!(newer_version("2.0", "1.9.9"));
|
||||||
|
assert!(!newer_version("1.0.0", "1.0.0"));
|
||||||
|
assert!(!newer_version("1.0.0", "1.0.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_records_are_bounded() {
|
||||||
|
let mut records = InstallRecords::default();
|
||||||
|
for i in 0..1005 {
|
||||||
|
records.insert(format!("plugin:{i}.plugin.js"), InstallRecord {
|
||||||
|
kind: Kind::Plugin, source: SourceKind::File, url: None, store_id: None, version: "1".into(), sha256: "0".repeat(64),
|
||||||
|
installed_at: format!("2026-09-28T00:{:02}:{:02}Z", i / 60 % 60, i % 60),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert_eq!(records.items.len(), 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
662
crates/waybetter-host/src/store/install.rs
Normal file
662
crates/waybetter-host/src/store/install.rs
Normal file
|
|
@ -0,0 +1,662 @@
|
||||||
|
//! The install screen's state and file work, shared by every wayBetter app: links waiting for the
|
||||||
|
//! shell, installs staged for the confirmation screen, the verified catalog (and its offline copy),
|
||||||
|
//! and where installed things came from. The app wraps these methods in shell-only Tauri commands
|
||||||
|
//! and passes a function that runs a plugin start/stop script in its site tabs.
|
||||||
|
|
||||||
|
use super::catalog::{
|
||||||
|
self as dist, Catalog, CatalogItem, InstallLink, InstallRecord, InstallRecords, Kind, SourceKind, StoreConfig, ThemeInfo,
|
||||||
|
};
|
||||||
|
use crate::plugins::{self, PluginHost, PluginStore, ScriptPlugin, PLUGIN_SUFFIX};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Mutex, MutexGuard};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// Install records in the app-data folder.
|
||||||
|
pub const RECORDS_FILE: &str = "store-installed.json";
|
||||||
|
/// The last verified catalog and its signature (verified again when read back).
|
||||||
|
pub const CATALOG_CACHE: &str = "store-catalog.json";
|
||||||
|
pub const CATALOG_SIG_CACHE: &str = "store-catalog.json.sig";
|
||||||
|
/// Shell event: a validated install link (`{ link: { kind, url } }`) or `{ error }`.
|
||||||
|
pub const EVENT_LINK: &str = "install-link";
|
||||||
|
/// Links received before the shell asked for them (startup, or while the shell reloads).
|
||||||
|
const MAX_PENDING: usize = 8;
|
||||||
|
const MAX_STAGED: usize = 8;
|
||||||
|
const STAGED_TTL: Duration = Duration::from_secs(15 * 60);
|
||||||
|
/// The confirmation screen enables Install after a moment; a commit faster than this was not read.
|
||||||
|
pub const MIN_REVIEW: Duration = Duration::from_millis(600);
|
||||||
|
/// How long a fetched catalog is used before the next store open fetches it again.
|
||||||
|
pub const CATALOG_TTL: Duration = Duration::from_secs(10 * 60);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LinkEvent {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub link: Option<InstallLink>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SourceView {
|
||||||
|
pub kind: SourceKind,
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// "wayBetter Store (signed catalog, sha256 verified)", "Link: waybetter.fyi", "A file you dropped"
|
||||||
|
pub label: String,
|
||||||
|
pub signed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Existing {
|
||||||
|
pub version: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
/// The installed file is byte-identical to this one.
|
||||||
|
pub same: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the confirmation screen shows. `text` is the exact content that will be written.
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InstallPreview {
|
||||||
|
pub token: String,
|
||||||
|
pub kind: Kind,
|
||||||
|
pub source: SourceView,
|
||||||
|
pub sha256: String,
|
||||||
|
pub size: usize,
|
||||||
|
pub file: Option<String>,
|
||||||
|
pub text: String,
|
||||||
|
pub version: String,
|
||||||
|
pub plugin: Option<ScriptPlugin>,
|
||||||
|
pub theme: Option<ThemeInfo>,
|
||||||
|
pub findings: Vec<&'static str>,
|
||||||
|
pub existing: Option<Existing>,
|
||||||
|
pub store: Option<CatalogItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InstallOutcome {
|
||||||
|
pub kind: Kind,
|
||||||
|
/// Plugins: the list after installing.
|
||||||
|
pub plugins: Option<Vec<ScriptPlugin>>,
|
||||||
|
/// Themes: the exact JSON the owner confirmed; the shell adds it (sanitized) to its themes.
|
||||||
|
pub theme_text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct InstalledPlugin {
|
||||||
|
pub version: String,
|
||||||
|
pub sha256: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the Store view shows: this app's items from the verified catalog, with what is installed.
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StoreView {
|
||||||
|
pub catalog: Option<Catalog>,
|
||||||
|
pub fetched_at: Option<String>,
|
||||||
|
/// Why the live catalog could not be loaded (a cached copy may still be shown).
|
||||||
|
pub error: Option<String>,
|
||||||
|
/// Installed plugin files the catalog lists, by file name.
|
||||||
|
pub plugins: BTreeMap<String, InstalledPlugin>,
|
||||||
|
pub records: InstallRecords,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A store item the shell asks for (`store_stage`).
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StoreItemRef {
|
||||||
|
pub kind: String,
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Staged {
|
||||||
|
kind: Kind,
|
||||||
|
file: Option<String>,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
sha256: String,
|
||||||
|
source: SourceKind,
|
||||||
|
url: Option<String>,
|
||||||
|
store: Option<CatalogItem>,
|
||||||
|
version: String,
|
||||||
|
created: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CatalogCache {
|
||||||
|
catalog: Catalog,
|
||||||
|
fetched: Instant,
|
||||||
|
fetched_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Inner {
|
||||||
|
pending: VecDeque<LinkEvent>,
|
||||||
|
staged: HashMap<String, Staged>,
|
||||||
|
catalog: Option<CatalogCache>,
|
||||||
|
records: InstallRecords,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One app's store and install state. Create it once (`Store::new`) and keep it in app state.
|
||||||
|
pub struct Store {
|
||||||
|
config: &'static StoreConfig,
|
||||||
|
plugins: &'static PluginHost,
|
||||||
|
data_dir: PathBuf,
|
||||||
|
inner: Mutex<Inner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_token() -> String {
|
||||||
|
let mut bytes = [0u8; 16];
|
||||||
|
let _ = getrandom::getrandom(&mut bytes);
|
||||||
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_rfc3339() -> String {
|
||||||
|
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_theme_id(id: &str) -> bool {
|
||||||
|
!id.is_empty() && id.len() <= 40 && id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
/// `data_dir` is the app-data folder: install records and the catalog copy live in it, and
|
||||||
|
/// plugins in its `plugins` folder (the same folder the app's `PluginStore` uses).
|
||||||
|
pub fn new(config: &'static StoreConfig, plugins: &'static PluginHost, data_dir: PathBuf) -> Self {
|
||||||
|
let records = std::fs::read_to_string(data_dir.join(RECORDS_FILE))
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| serde_json::from_str::<InstallRecords>(&raw).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
plugins,
|
||||||
|
data_dir,
|
||||||
|
inner: Mutex::new(Inner { pending: VecDeque::new(), staged: HashMap::new(), catalog: None, records }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config(&self) -> &'static StoreConfig {
|
||||||
|
self.config
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock(&self) -> MutexGuard<'_, Inner> {
|
||||||
|
self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plugin_store(&self) -> PluginStore {
|
||||||
|
PluginStore::new(self.data_dir.join("plugins"), self.plugins)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plugins_dir(&self) -> PathBuf {
|
||||||
|
self.data_dir.join("plugins")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_records(&self, records: &InstallRecords) {
|
||||||
|
let result = serde_json::to_string_pretty(records)
|
||||||
|
.map_err(|_| "STORAGE: serialization failed".to_owned())
|
||||||
|
.and_then(|json| std::fs::write(self.data_dir.join(RECORDS_FILE), json).map_err(|error| format!("STORAGE: {error}")));
|
||||||
|
if let Err(error) = result {
|
||||||
|
eprintln!("{}: {error}", self.config.product);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------
|
||||||
|
// Links
|
||||||
|
|
||||||
|
/// A link reached the app (OS deep link, second launch): validate it and queue it for the
|
||||||
|
/// shell. Returns the event to emit ([`EVENT_LINK`]). Nothing is downloaded or installed here.
|
||||||
|
pub fn receive_link(&self, raw: &str) -> LinkEvent {
|
||||||
|
let event = match dist::parse_install_link(self.config, raw) {
|
||||||
|
Ok(link) => LinkEvent { link: Some(link), error: None },
|
||||||
|
Err(error) => LinkEvent { link: None, error: Some(error) },
|
||||||
|
};
|
||||||
|
let mut inner = self.lock();
|
||||||
|
if inner.pending.len() >= MAX_PENDING {
|
||||||
|
inner.pending.pop_front();
|
||||||
|
}
|
||||||
|
inner.pending.push_back(event.clone());
|
||||||
|
event
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Links that arrived before the shell listened (drained).
|
||||||
|
pub fn take_pending(&self) -> Vec<LinkEvent> {
|
||||||
|
self.lock().pending.drain(..).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same rules as a link the OS handed over, for a `kind` + `url` the shell asks for: the
|
||||||
|
/// shell cannot widen them. Returns the link and, for plugins, the file name.
|
||||||
|
pub fn check_link(&self, kind: &str, url: &str) -> Result<(InstallLink, Option<String>), String> {
|
||||||
|
let kind = Kind::parse(kind)?;
|
||||||
|
let link = dist::parse_install_link(self.config, &dist::install_link(self.config, kind, url))?;
|
||||||
|
let file = match kind {
|
||||||
|
Kind::Plugin => Some(dist::plugin_file_from_url(&Url::parse(&link.url).map_err(|_| "VALIDATION: invalid URL")?)?),
|
||||||
|
Kind::Theme => None,
|
||||||
|
};
|
||||||
|
Ok((link, file))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------
|
||||||
|
// Staging
|
||||||
|
|
||||||
|
/// Validates bytes for `kind` and stages them under a fresh token.
|
||||||
|
fn stage(&self, kind: Kind, file: Option<String>, bytes: Vec<u8>, source: SourceKind, url: Option<String>, store: Option<CatalogItem>) -> Result<InstallPreview, String> {
|
||||||
|
let mut inner = self.lock();
|
||||||
|
inner.staged.retain(|_, staged| staged.created.elapsed() < STAGED_TTL);
|
||||||
|
while inner.staged.len() >= MAX_STAGED {
|
||||||
|
let oldest = inner.staged.iter().min_by_key(|(_, s)| s.created).map(|(k, _)| k.clone());
|
||||||
|
match oldest {
|
||||||
|
Some(key) => inner.staged.remove(&key),
|
||||||
|
None => break,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let text = String::from_utf8(bytes.clone()).map_err(|_| "VALIDATION: not UTF-8 text")?;
|
||||||
|
let (plugin, theme, findings, version, existing) = match kind {
|
||||||
|
Kind::Plugin => {
|
||||||
|
let file = file.as_deref().ok_or("VALIDATION: missing plugin file name")?;
|
||||||
|
let validated = dist::validate_plugin(file, &bytes)?;
|
||||||
|
let local = self.plugin_store();
|
||||||
|
let existing = local.file_hash(file).map(|hash| Existing {
|
||||||
|
version: plugins::parse_meta(file, &std::fs::read_to_string(self.plugins_dir().join(file)).unwrap_or_default()).version,
|
||||||
|
enabled: local.is_enabled(file),
|
||||||
|
same: hash == validated.sha256,
|
||||||
|
});
|
||||||
|
let version = validated.meta.version.clone();
|
||||||
|
(Some(validated.meta), None, validated.findings, version, existing)
|
||||||
|
}
|
||||||
|
Kind::Theme => {
|
||||||
|
let info = dist::validate_theme(&bytes)?;
|
||||||
|
let version = store.as_ref().map(|item| item.version.clone()).unwrap_or_default();
|
||||||
|
(None, Some(info), Vec::new(), version, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let label = match source {
|
||||||
|
SourceKind::Store => "wayBetter Store (signed catalog, sha256 verified)".to_owned(),
|
||||||
|
SourceKind::Link => format!("Link: {}", url.as_deref().and_then(|u| Url::parse(u).ok()).and_then(|u| u.host_str().map(str::to_owned)).unwrap_or_default()),
|
||||||
|
SourceKind::File => "A file you dropped".to_owned(),
|
||||||
|
};
|
||||||
|
let sha256 = dist::sha256_hex(&bytes);
|
||||||
|
let token = new_token();
|
||||||
|
let preview = InstallPreview {
|
||||||
|
token: token.clone(),
|
||||||
|
kind,
|
||||||
|
source: SourceView { kind: source.clone(), url: url.clone(), label, signed: source == SourceKind::Store },
|
||||||
|
sha256: sha256.clone(),
|
||||||
|
size: bytes.len(),
|
||||||
|
file: file.clone(),
|
||||||
|
text,
|
||||||
|
version: version.clone(),
|
||||||
|
plugin,
|
||||||
|
theme,
|
||||||
|
findings,
|
||||||
|
existing,
|
||||||
|
store: store.clone(),
|
||||||
|
};
|
||||||
|
inner.staged.insert(token, Staged { kind, file, bytes, sha256, source, url, store, version, created: Instant::now() });
|
||||||
|
Ok(preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stages what a link (checked with [`Self::check_link`]) downloaded. A link to a file the
|
||||||
|
/// signed store lists is still a link (no store badge), but the preview carries the store entry
|
||||||
|
/// when the hash matches so the screen can say so.
|
||||||
|
pub fn stage_link_download(&self, link: &InstallLink, file: Option<String>, bytes: Vec<u8>) -> Result<InstallPreview, String> {
|
||||||
|
if bytes.len() > link.kind.max_bytes() {
|
||||||
|
return Err("VALIDATION: the file is larger than allowed".into());
|
||||||
|
}
|
||||||
|
let hash = dist::sha256_hex(&bytes);
|
||||||
|
let store = self.lock().catalog.as_ref().and_then(|cache| cache.catalog.items.iter().find(|item| item.url == link.url && item.sha256 == hash).cloned());
|
||||||
|
self.stage(link.kind, file, bytes, SourceKind::Link, Some(link.url.clone()), store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file dropped on the window (the shell read it with the File API; validated here).
|
||||||
|
pub fn stage_file(&self, name: &str, text: String) -> Result<InstallPreview, String> {
|
||||||
|
let lower = name.to_ascii_lowercase();
|
||||||
|
let (kind, file) = if lower.ends_with(PLUGIN_SUFFIX) {
|
||||||
|
(Kind::Plugin, Some(name.to_owned()))
|
||||||
|
} else if lower.ends_with(".json") {
|
||||||
|
(Kind::Theme, None)
|
||||||
|
} else {
|
||||||
|
return Err("VALIDATION: drop a theme (.json) or a plugin (.plugin.js)".into());
|
||||||
|
};
|
||||||
|
if text.len() > kind.max_bytes() {
|
||||||
|
return Err("VALIDATION: the file is larger than allowed".into());
|
||||||
|
}
|
||||||
|
self.stage(kind, file, text.into_bytes(), SourceKind::File, None, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The catalog entry the shell asks for (this app's items only).
|
||||||
|
pub fn catalog_item(&self, item: &StoreItemRef) -> Result<CatalogItem, String> {
|
||||||
|
let kind = Kind::parse(&item.kind)?;
|
||||||
|
self.lock()
|
||||||
|
.catalog
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|cache| cache.catalog.items.iter().find(|i| i.kind == kind && i.id == item.id).cloned())
|
||||||
|
.ok_or_else(|| "STORE: not in the catalog; refresh the store".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stages a downloaded store item after checking it against the signed catalog (size + sha256).
|
||||||
|
pub fn stage_store_download(&self, entry: CatalogItem, bytes: Vec<u8>) -> Result<InstallPreview, String> {
|
||||||
|
dist::verify_item(&entry, &bytes)?;
|
||||||
|
if !entry.is_for(self.config.app_id) {
|
||||||
|
return Err("STORE: this item is for another app".into());
|
||||||
|
}
|
||||||
|
self.stage(entry.kind, entry.file.clone(), bytes, SourceKind::Store, Some(entry.url.clone()), Some(entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancel(&self, token: &str) {
|
||||||
|
self.lock().staged.remove(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------
|
||||||
|
// Commit and remove
|
||||||
|
|
||||||
|
/// The owner confirmed the screen for `token`. Plugins are written (validated again) and, when
|
||||||
|
/// `enable` (or they were enabled before an update), started: `run` evaluates each start/stop
|
||||||
|
/// script in the app's site tabs. Themes are handed back for the shell to add under `theme_id`.
|
||||||
|
pub fn commit(&self, token: &str, enable: bool, theme_id: Option<String>, run: impl Fn(&str)) -> Result<InstallOutcome, String> {
|
||||||
|
let mut inner = self.lock();
|
||||||
|
let Some(staged) = inner.staged.get(token) else {
|
||||||
|
return Err("INSTALL: this install screen expired; open it again".into());
|
||||||
|
};
|
||||||
|
if staged.created.elapsed() < MIN_REVIEW {
|
||||||
|
return Err("INSTALL: confirm again after reviewing".into());
|
||||||
|
}
|
||||||
|
let staged = inner.staged.remove(token).expect("present");
|
||||||
|
let record = |version: String| InstallRecord {
|
||||||
|
kind: staged.kind,
|
||||||
|
source: staged.source.clone(),
|
||||||
|
url: staged.url.clone(),
|
||||||
|
store_id: staged.store.as_ref().map(|item| item.id.clone()),
|
||||||
|
version,
|
||||||
|
sha256: staged.sha256.clone(),
|
||||||
|
installed_at: now_rfc3339(),
|
||||||
|
};
|
||||||
|
match staged.kind {
|
||||||
|
Kind::Plugin => {
|
||||||
|
let file = staged.file.clone().ok_or("INSTALL: missing file")?;
|
||||||
|
let store = self.plugin_store();
|
||||||
|
let was_enabled = store.is_enabled(&file);
|
||||||
|
let validated = store.install(&file, &staged.bytes)?;
|
||||||
|
inner.records.insert(InstallRecords::key(Kind::Plugin, &file), record(validated.meta.version.clone()));
|
||||||
|
let records = inner.records.clone();
|
||||||
|
drop(inner);
|
||||||
|
self.save_records(&records);
|
||||||
|
if was_enabled {
|
||||||
|
// An update of a running plugin: stop the old code, start the new.
|
||||||
|
run(&plugins::stop_script(self.plugins, &file)?);
|
||||||
|
run(&store.start_script(&file)?);
|
||||||
|
} else if enable {
|
||||||
|
let script = store.start_script(&file)?;
|
||||||
|
store.set_enabled(&file, true)?;
|
||||||
|
run(&script);
|
||||||
|
}
|
||||||
|
Ok(InstallOutcome { kind: Kind::Plugin, plugins: Some(store.list()?), theme_text: None })
|
||||||
|
}
|
||||||
|
Kind::Theme => {
|
||||||
|
let id = theme_id.filter(|id| valid_theme_id(id)).ok_or("VALIDATION: invalid theme id")?;
|
||||||
|
inner.records.insert(InstallRecords::key(Kind::Theme, &id), record(staged.version.clone()));
|
||||||
|
let records = inner.records.clone();
|
||||||
|
drop(inner);
|
||||||
|
self.save_records(&records);
|
||||||
|
let text = String::from_utf8(staged.bytes).map_err(|_| "VALIDATION: not UTF-8")?;
|
||||||
|
Ok(InstallOutcome { kind: Kind::Theme, plugins: None, theme_text: Some(text) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a plugin; `run` stops it in every site tab first.
|
||||||
|
pub fn remove_plugin(&self, file: &str, run: impl Fn(&str)) -> Result<Vec<ScriptPlugin>, String> {
|
||||||
|
let mut inner = self.lock();
|
||||||
|
let store = self.plugin_store();
|
||||||
|
let script = plugins::stop_script(self.plugins, file)?;
|
||||||
|
store.remove(file)?;
|
||||||
|
inner.records.items.remove(&InstallRecords::key(Kind::Plugin, file));
|
||||||
|
let records = inner.records.clone();
|
||||||
|
drop(inner);
|
||||||
|
self.save_records(&records);
|
||||||
|
run(&script);
|
||||||
|
store.list()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where installed plugins and themes came from (for badges and update checks).
|
||||||
|
pub fn records(&self) -> InstallRecords {
|
||||||
|
self.lock().records.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A theme (or plugin) was removed elsewhere: forget where it came from.
|
||||||
|
pub fn forget(&self, kind: &str, name: &str) -> Result<(), String> {
|
||||||
|
let kind = Kind::parse(kind)?;
|
||||||
|
let mut inner = self.lock();
|
||||||
|
if inner.records.items.remove(&InstallRecords::key(kind, name)).is_some() {
|
||||||
|
let records = inner.records.clone();
|
||||||
|
drop(inner);
|
||||||
|
self.save_records(&records);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------------------
|
||||||
|
// Catalog
|
||||||
|
|
||||||
|
/// Whether the catalog in memory is recent enough to show without fetching.
|
||||||
|
pub fn catalog_is_fresh(&self) -> bool {
|
||||||
|
self.lock().catalog.as_ref().is_some_and(|cache| cache.fetched.elapsed() < CATALOG_TTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies fetched catalog bytes, keeps this app's part and saves the verified bytes for
|
||||||
|
/// offline use (they are verified again when read back).
|
||||||
|
pub fn accept_catalog(&self, body: &[u8], signature: &str) -> Result<(), String> {
|
||||||
|
let catalog = dist::verify_catalog(self.config, body, signature)?;
|
||||||
|
let _ = std::fs::write(self.data_dir.join(CATALOG_CACHE), body);
|
||||||
|
let _ = std::fs::write(self.data_dir.join(CATALOG_SIG_CACHE), signature.as_bytes());
|
||||||
|
self.lock().catalog = Some(CatalogCache { catalog, fetched: Instant::now(), fetched_at: now_rfc3339() });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live catalog could not be loaded: fall back to the saved copy (if it still verifies)
|
||||||
|
/// when nothing is in memory. It is shown, but fetched again on the next open.
|
||||||
|
pub fn use_cached_catalog(&self) {
|
||||||
|
if self.lock().catalog.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some((catalog, fetched_at)) = cached_catalog(self.config, &self.data_dir) else { return };
|
||||||
|
let fetched = Instant::now().checked_sub(CATALOG_TTL).unwrap_or_else(Instant::now);
|
||||||
|
let mut inner = self.lock();
|
||||||
|
if inner.catalog.is_none() {
|
||||||
|
inner.catalog = Some(CatalogCache { catalog, fetched, fetched_at });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the Store view shows, with `error` when the live catalog could not be loaded.
|
||||||
|
pub fn view(&self, error: Option<String>) -> StoreView {
|
||||||
|
let inner = self.lock();
|
||||||
|
let local = self.plugin_store();
|
||||||
|
let mut installed = BTreeMap::new();
|
||||||
|
if let Some(cache) = &inner.catalog {
|
||||||
|
for item in cache.catalog.items.iter().filter(|item| item.kind == Kind::Plugin) {
|
||||||
|
let Some(file) = item.file.as_deref() else { continue };
|
||||||
|
let Some(sha256) = local.file_hash(file) else { continue };
|
||||||
|
let source = std::fs::read_to_string(self.plugins_dir().join(file)).unwrap_or_default();
|
||||||
|
installed.insert(file.to_owned(), InstalledPlugin { version: plugins::parse_meta(file, &source).version, sha256, enabled: local.is_enabled(file) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StoreView {
|
||||||
|
catalog: inner.catalog.as_ref().map(|cache| cache.catalog.clone()),
|
||||||
|
fetched_at: inner.catalog.as_ref().map(|cache| cache.fetched_at.clone()),
|
||||||
|
error,
|
||||||
|
plugins: installed,
|
||||||
|
records: inner.records.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cached_catalog(config: &StoreConfig, dir: &Path) -> Option<(Catalog, String)> {
|
||||||
|
let body = std::fs::read(dir.join(CATALOG_CACHE)).ok()?;
|
||||||
|
let signature = std::fs::read_to_string(dir.join(CATALOG_SIG_CACHE)).ok()?;
|
||||||
|
let modified = std::fs::metadata(dir.join(CATALOG_CACHE)).and_then(|m| m.modified()).ok()?;
|
||||||
|
let catalog = dist::verify_catalog(config, &body, &signature).ok()?;
|
||||||
|
let at = chrono::DateTime::<chrono::Utc>::from(modified).to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||||
|
Some((catalog, at))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::super::catalog::tests::{catalog_json, config, plugin_item, sign, test_key, theme_item, PLUGIN};
|
||||||
|
use super::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
static HOST: PluginHost = PluginHost {
|
||||||
|
runtime_global: "WayBetterTest",
|
||||||
|
origin: "https://example.com",
|
||||||
|
product: "wayBetter test",
|
||||||
|
css_element_id: "wb-test-css",
|
||||||
|
example: None,
|
||||||
|
bundled: &[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const THEME: &[u8] = br#"{"name":"Nord","author":"me","css":"a{}"}"#;
|
||||||
|
|
||||||
|
fn store_with_catalog() -> (tempfile::TempDir, Store, Vec<u8>, String) {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (key, public) = test_key(7);
|
||||||
|
let mut kick = plugin_item(PLUGIN);
|
||||||
|
kick["apps"] = serde_json::json!(["kick"]);
|
||||||
|
kick["id"] = serde_json::json!("kick-tool");
|
||||||
|
kick["file"] = serde_json::json!("kick-tool.plugin.js");
|
||||||
|
let body = catalog_json(serde_json::json!([plugin_item(PLUGIN), theme_item(THEME), kick]));
|
||||||
|
let signature = sign(&key, &body);
|
||||||
|
let store = Store::new(config("x", &[public]), &HOST, dir.path().to_path_buf());
|
||||||
|
(dir, store, body, signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reviewed() {
|
||||||
|
std::thread::sleep(MIN_REVIEW + Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn links_are_validated_and_queued_for_the_shell() {
|
||||||
|
let (_dir, store, _, _) = store_with_catalog();
|
||||||
|
let ok = store.receive_link("waybetter://install?kind=theme&url=https://waybetter.fyi/store/themes/nord.json");
|
||||||
|
assert!(ok.link.is_some() && ok.error.is_none());
|
||||||
|
let bad = store.receive_link("waybetter://install?kind=theme&url=https://evil.example/a.json");
|
||||||
|
assert!(bad.link.is_none() && bad.error.unwrap().starts_with("LINK:"));
|
||||||
|
for _ in 0..20 {
|
||||||
|
store.receive_link("waybetter://install?kind=theme&url=https://waybetter.fyi/a.json");
|
||||||
|
}
|
||||||
|
assert_eq!(store.take_pending().len(), MAX_PENDING, "bounded");
|
||||||
|
assert!(store.take_pending().is_empty(), "drained");
|
||||||
|
// The shell's kind + url go through the same parser.
|
||||||
|
assert_eq!(store.check_link("plugin", "https://waybetter.fyi/store/plugins/tool.plugin.js").unwrap().1.as_deref(), Some("tool.plugin.js"));
|
||||||
|
assert!(store.check_link("plugin", "https://waybetter.fyi/store/plugins/tool.js").is_err());
|
||||||
|
assert!(store.check_link("theme", "https://evil.example/a.json").is_err());
|
||||||
|
assert!(store.check_link("script", "https://waybetter.fyi/a.json").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_catalog_is_verified_filtered_to_the_app_and_kept_for_offline_use() {
|
||||||
|
let (dir, store, body, signature) = store_with_catalog();
|
||||||
|
assert!(store.view(None).catalog.is_none());
|
||||||
|
assert!(store.accept_catalog(&body, "AAAA").is_err());
|
||||||
|
assert!(store.view(None).catalog.is_none(), "a bad signature changes nothing");
|
||||||
|
store.accept_catalog(&body, &signature).unwrap();
|
||||||
|
assert!(store.catalog_is_fresh());
|
||||||
|
let view = store.view(None);
|
||||||
|
let ids: Vec<_> = view.catalog.unwrap().items.iter().map(|i| i.id.clone()).collect();
|
||||||
|
assert_eq!(ids, ["tool", "nord"], "the other app's item is not shown");
|
||||||
|
assert!(store.catalog_item(&StoreItemRef { kind: "plugin".into(), id: "kick-tool".into() }).is_err(), "nor staged");
|
||||||
|
// A fresh start offline: the saved copy verifies and is shown, marked stale.
|
||||||
|
let again = Store::new(store.config(), &HOST, dir.path().to_path_buf());
|
||||||
|
again.use_cached_catalog();
|
||||||
|
assert_eq!(again.view(Some("NETWORK: offline".into())).catalog.unwrap().items.len(), 2);
|
||||||
|
assert!(!again.catalog_is_fresh());
|
||||||
|
// A tampered copy on disk is ignored.
|
||||||
|
std::fs::write(dir.path().join(CATALOG_CACHE), [&body[..], b" "].concat()).unwrap();
|
||||||
|
let tampered = Store::new(store.config(), &HOST, dir.path().to_path_buf());
|
||||||
|
tampered.use_cached_catalog();
|
||||||
|
assert!(tampered.view(None).catalog.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_items_must_match_the_signed_catalog_and_install_the_staged_bytes() {
|
||||||
|
let (dir, store, body, signature) = store_with_catalog();
|
||||||
|
store.accept_catalog(&body, &signature).unwrap();
|
||||||
|
let entry = store.catalog_item(&StoreItemRef { kind: "plugin".into(), id: "tool".into() }).unwrap();
|
||||||
|
assert!(store.stage_store_download(entry.clone(), b"tampered".to_vec()).is_err());
|
||||||
|
let preview = store.stage_store_download(entry, PLUGIN.to_vec()).unwrap();
|
||||||
|
assert!(preview.source.signed && preview.existing.is_none());
|
||||||
|
assert_eq!(preview.plugin.as_ref().unwrap().permissions.as_deref(), Some(&["ui".to_owned()][..]));
|
||||||
|
// Too fast: the screen was not read.
|
||||||
|
assert!(store.commit(&preview.token, true, None, |_| {}).unwrap_err().contains("reviewing"));
|
||||||
|
reviewed();
|
||||||
|
let ran = RefCell::new(Vec::new());
|
||||||
|
let outcome = store.commit(&preview.token, true, None, |script| ran.borrow_mut().push(script.to_owned())).unwrap();
|
||||||
|
assert_eq!(outcome.plugins.unwrap().iter().filter(|p| p.file == "tool.plugin.js" && p.enabled).count(), 1);
|
||||||
|
assert_eq!(ran.borrow().len(), 1, "started once");
|
||||||
|
assert_eq!(std::fs::read(dir.path().join("plugins/tool.plugin.js")).unwrap(), PLUGIN);
|
||||||
|
let record = &store.records().items["plugin:tool.plugin.js"];
|
||||||
|
assert_eq!((record.source.clone(), record.store_id.as_deref(), record.version.as_str()), (SourceKind::Store, Some("tool"), "1.0.0"));
|
||||||
|
// The token is single use; records survive a restart.
|
||||||
|
assert!(store.commit(&preview.token, true, None, |_| {}).unwrap_err().contains("expired"));
|
||||||
|
assert_eq!(Store::new(store.config(), &HOST, dir.path().to_path_buf()).records(), store.records());
|
||||||
|
// Installed and identical: the view says so.
|
||||||
|
assert_eq!(store.view(None).plugins["tool.plugin.js"].sha256, dist::sha256_hex(PLUGIN));
|
||||||
|
// Staging it again shows what is installed; committing an update of a running plugin stops then starts it.
|
||||||
|
let entry = store.catalog_item(&StoreItemRef { kind: "plugin".into(), id: "tool".into() }).unwrap();
|
||||||
|
let again = store.stage_store_download(entry, PLUGIN.to_vec()).unwrap();
|
||||||
|
let existing = again.existing.unwrap();
|
||||||
|
assert!(existing.same && existing.enabled);
|
||||||
|
reviewed();
|
||||||
|
let ran = RefCell::new(Vec::new());
|
||||||
|
store.commit(&again.token, false, None, |script| ran.borrow_mut().push(script.to_owned())).unwrap();
|
||||||
|
assert_eq!(ran.borrow().len(), 2, "stop + start");
|
||||||
|
// Remove: stopped, deleted, forgotten.
|
||||||
|
let ran = RefCell::new(0);
|
||||||
|
let list = store.remove_plugin("tool.plugin.js", |_| *ran.borrow_mut() += 1).unwrap();
|
||||||
|
assert!(list.iter().all(|p| p.file != "tool.plugin.js") && *ran.borrow() == 1);
|
||||||
|
assert!(!store.records().items.contains_key("plugin:tool.plugin.js"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dropped_files_and_links_are_validated_and_themes_come_back_as_text() {
|
||||||
|
let (_dir, store, body, signature) = store_with_catalog();
|
||||||
|
assert!(store.stage_file("photo.png", "x".into()).is_err());
|
||||||
|
assert!(store.stage_file("bad.plugin.js", "module.exports = 1".into()).unwrap_err().contains("header"));
|
||||||
|
assert!(store.stage_file("big.json", "a".repeat(dist::MAX_THEME_BYTES + 1)).unwrap_err().contains("larger"));
|
||||||
|
let dropped = store.stage_file("nord.json", String::from_utf8(THEME.to_vec()).unwrap()).unwrap();
|
||||||
|
assert!(!dropped.source.signed && dropped.theme.as_ref().unwrap().name == "Nord");
|
||||||
|
reviewed();
|
||||||
|
assert!(store.commit(&dropped.token, true, Some("../x".into()), |_| {}).unwrap_err().contains("theme id"));
|
||||||
|
let dropped = store.stage_file("nord.json", String::from_utf8(THEME.to_vec()).unwrap()).unwrap();
|
||||||
|
reviewed();
|
||||||
|
let outcome = store.commit(&dropped.token, true, Some("nord-2".into()), |_| panic!("themes run nothing")).unwrap();
|
||||||
|
assert_eq!(outcome.theme_text.as_deref().map(str::as_bytes), Some(THEME));
|
||||||
|
assert_eq!(store.records().items["theme:nord-2"].source, SourceKind::File);
|
||||||
|
store.forget("theme", "nord-2").unwrap();
|
||||||
|
assert!(store.records().items.is_empty());
|
||||||
|
// A link to a file the signed store lists carries the store entry, but is still a link.
|
||||||
|
store.accept_catalog(&body, &signature).unwrap();
|
||||||
|
let (link, file) = store.check_link("theme", "https://waybetter.fyi/store/themes/nord.json").unwrap();
|
||||||
|
let preview = store.stage_link_download(&link, file, THEME.to_vec()).unwrap();
|
||||||
|
assert!(!preview.source.signed && preview.store.is_some() && preview.source.label == "Link: waybetter.fyi");
|
||||||
|
let other = store.stage_link_download(&link, None, br#"{"name":"Other"}"#.to_vec()).unwrap();
|
||||||
|
assert!(other.store.is_none(), "different bytes: no store entry");
|
||||||
|
store.cancel(&other.token);
|
||||||
|
assert!(store.commit(&other.token, true, Some("other".into()), |_| {}).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn staging_is_bounded() {
|
||||||
|
let (_dir, store, _, _) = store_with_catalog();
|
||||||
|
let tokens: Vec<String> = (0..MAX_STAGED + 4)
|
||||||
|
.map(|_| {
|
||||||
|
std::thread::sleep(Duration::from_millis(2));
|
||||||
|
store.stage_file("a.json", r#"{"name":"A"}"#.into()).unwrap().token
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(store.lock().staged.len(), MAX_STAGED);
|
||||||
|
assert!(!store.lock().staged.contains_key(&tokens[0]), "the oldest goes first");
|
||||||
|
}
|
||||||
|
}
|
||||||
34
crates/waybetter-host/src/store/mod.rs
Normal file
34
crates/waybetter-host/src/store/mod.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
//! Installing themes and plugins from outside the plugins folder: a `waybetter://install` link, a
|
||||||
|
//! dropped file, or the signed wayBetter Store. Every wayBetter app gets the same rules; the app
|
||||||
|
//! supplies only a [`StoreConfig`] (its id in the shared catalog, the store URL, the store public
|
||||||
|
//! keys, the hosts it may download from) and its Tauri command wrappers.
|
||||||
|
//!
|
||||||
|
//! Trust model (the same for every app):
|
||||||
|
//! - Nothing installs without the shell's confirmation screen (name, author, version, source,
|
||||||
|
//! sha256, permissions, code/CSS preview). Only the app's local shell webview may call the install
|
||||||
|
//! commands; site tabs and web pages cannot. A web page can at most open a `waybetter://` link,
|
||||||
|
//! which only *proposes* an install ([`Store::receive_link`]).
|
||||||
|
//! - Links may only point at https URLs on the app's allow-list ([`AllowedHost`]; raw files only on
|
||||||
|
//! a code host), default port, no credentials, no fragment; native code downloads them without
|
||||||
|
//! redirects or cookies and with size caps ([`MAX_THEME_BYTES`], `plugins::MAX_PLUGIN_BYTES`).
|
||||||
|
//! - The store catalog carries a detached Ed25519 signature made offline with the store key
|
||||||
|
//! (`waybetter store sign`); the app verifies it with the public keys compiled into it (any listed
|
||||||
|
//! key may sign, so keys can rotate) and then checks every download against the catalog's sha256
|
||||||
|
//! and size. One catalog serves every app: each item names the apps it is for (`apps`) and each
|
||||||
|
//! app only ever sees, stages or installs its own items ([`Catalog::for_app`]).
|
||||||
|
//! - Plugins are validated like any install (`plugins::validate_plugin`) and written by
|
||||||
|
//! `PluginStore::install` (atomic, never enabled on its own); themes are parsed here (a JSON object
|
||||||
|
//! with a name, size cap) and sanitized field by field by the app's shell and native theme code.
|
||||||
|
//! - [`Store::commit`] writes exactly the bytes staged (and hashed) for the screen, after
|
||||||
|
//! validating them again, and only once the screen has been open for [`MIN_REVIEW`].
|
||||||
|
//!
|
||||||
|
//! [`catalog`] is pure; [`install`] keeps the state (pending links, staged installs, the verified
|
||||||
|
//! catalog, install records) and does the file work; with the `net` feature, [`net`] downloads.
|
||||||
|
|
||||||
|
pub mod catalog;
|
||||||
|
pub mod install;
|
||||||
|
#[cfg(feature = "net")]
|
||||||
|
pub mod net;
|
||||||
|
|
||||||
|
pub use catalog::*;
|
||||||
|
pub use install::*;
|
||||||
114
crates/waybetter-host/src/store/net.rs
Normal file
114
crates/waybetter-host/src/store/net.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
//! Store downloads (`net` feature): allow-listed https only, no redirects, no cookies, size caps.
|
||||||
|
|
||||||
|
use super::catalog::{self as dist, StoreConfig, MAX_CATALOG_BYTES, MAX_SIGNATURE_BYTES};
|
||||||
|
use super::install::{InstallPreview, Store, StoreItemRef, StoreView};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
fn client(config: &StoreConfig) -> Result<&'static reqwest::Client, String> {
|
||||||
|
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||||
|
if let Some(client) = CLIENT.get() {
|
||||||
|
return Ok(client);
|
||||||
|
}
|
||||||
|
if rustls::crypto::CryptoProvider::get_default().is_none() {
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
}
|
||||||
|
// No cookie store is configured: requests carry no cookies and keep none.
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.user_agent(config.user_agent)
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("NETWORK: {e}"))?;
|
||||||
|
Ok(CLIENT.get_or_init(|| client))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads an allow-listed URL (re-checked here), no redirects or cookies, at most `max` bytes.
|
||||||
|
pub async fn fetch(config: &StoreConfig, url: &str, max: usize) -> Result<Vec<u8>, String> {
|
||||||
|
let url = Url::parse(url).map_err(|_| "VALIDATION: invalid URL")?;
|
||||||
|
if !dist::allowed_download_url(config.allowed_hosts, &url) {
|
||||||
|
return Err("VALIDATION: this address is not allowed".into());
|
||||||
|
}
|
||||||
|
let mut response = client(config)?.get(url).send().await.map_err(|e| format!("NETWORK: {e}"))?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
if status != 200 {
|
||||||
|
return Err(format!("NETWORK: the server answered {status}"));
|
||||||
|
}
|
||||||
|
if response.content_length().is_some_and(|len| len as usize > max) {
|
||||||
|
return Err("VALIDATION: the file is larger than allowed".into());
|
||||||
|
}
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
while let Some(chunk) = response.chunk().await.map_err(|e| format!("NETWORK: {e}"))? {
|
||||||
|
if bytes.len() + chunk.len() > max {
|
||||||
|
return Err("VALIDATION: the file is larger than allowed".into());
|
||||||
|
}
|
||||||
|
bytes.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
/// Downloads what an install link points at and stages it for the confirmation screen.
|
||||||
|
pub async fn stage_link(&self, kind: &str, url: &str) -> Result<InstallPreview, String> {
|
||||||
|
let (link, file) = self.check_link(kind, url)?;
|
||||||
|
let bytes = fetch(self.config(), &link.url, link.kind.max_bytes()).await?;
|
||||||
|
self.stage_link_download(&link, file, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads a store item, checks it against the signed catalog (size + sha256) and stages it.
|
||||||
|
pub async fn stage_item(&self, item: &StoreItemRef) -> Result<InstallPreview, String> {
|
||||||
|
let entry = self.catalog_item(item)?;
|
||||||
|
let bytes = fetch(self.config(), &entry.url, entry.kind.max_bytes()).await?;
|
||||||
|
self.stage_store_download(entry, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The signed catalog (fetched at most every [`super::CATALOG_TTL`] unless `refresh`), with
|
||||||
|
/// what is installed. Offline, the last verified copy is shown with the error.
|
||||||
|
pub async fn catalog(&self, refresh: bool) -> StoreView {
|
||||||
|
if !refresh && self.catalog_is_fresh() {
|
||||||
|
return self.view(None);
|
||||||
|
}
|
||||||
|
let error = match self.fetch_catalog().await {
|
||||||
|
Ok(()) => None,
|
||||||
|
Err(error) => {
|
||||||
|
self.use_cached_catalog();
|
||||||
|
Some(error)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.view(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_catalog(&self) -> Result<(), String> {
|
||||||
|
let base = self.config().store_base;
|
||||||
|
let body = fetch(self.config(), &format!("{base}catalog.json"), MAX_CATALOG_BYTES).await?;
|
||||||
|
let signature = fetch(self.config(), &format!("{base}catalog.json.sig"), MAX_SIGNATURE_BYTES).await?;
|
||||||
|
let signature = String::from_utf8(signature).map_err(|_| "STORE: invalid signature")?;
|
||||||
|
self.accept_catalog(&body, &signature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::store::catalog::Kind;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_off_the_allow_list_is_requested() {
|
||||||
|
let config = StoreConfig {
|
||||||
|
app_id: "x",
|
||||||
|
product: "t",
|
||||||
|
user_agent: "t/0",
|
||||||
|
scheme: "waybetter",
|
||||||
|
store_base: dist::DEFAULT_STORE_BASE,
|
||||||
|
public_keys: &[],
|
||||||
|
allowed_hosts: dist::DEFAULT_HOSTS,
|
||||||
|
};
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
|
||||||
|
for url in ["http://waybetter.fyi/a.json", "https://evil.example/a.json", "https://waybetter.fyi:444/a.json", "not a url"] {
|
||||||
|
let error = runtime.block_on(fetch(&config, url, 10)).unwrap_err();
|
||||||
|
assert!(error.starts_with("VALIDATION:"), "{url}: {error}");
|
||||||
|
}
|
||||||
|
assert_eq!(Kind::Theme.max_bytes(), dist::MAX_THEME_BYTES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,8 @@
|
||||||
"test": "bun run test:ts && bun run test:rust && bun run test:create",
|
"test": "bun run test:ts && bun run test:rust && bun run test:create",
|
||||||
"test:ts": "node --test packages/runtime/tests/*.test.ts packages/shell/tests/*.test.ts",
|
"test:ts": "node --test packages/runtime/tests/*.test.ts packages/shell/tests/*.test.ts",
|
||||||
"test:create": "node --test packages/create-waybetter/tests/*.test.mjs",
|
"test:create": "node --test packages/create-waybetter/tests/*.test.mjs",
|
||||||
"test:rust": "cargo test --manifest-path crates/waybetter-host/Cargo.toml --locked",
|
"test:rust": "cargo test --manifest-path crates/waybetter-host/Cargo.toml --locked --features net",
|
||||||
"check:tauri": "cargo check --manifest-path crates/waybetter-host/Cargo.toml --features tauri",
|
"check:tauri": "cargo check --manifest-path crates/waybetter-host/Cargo.toml --features tauri,net",
|
||||||
"build": "bun run typecheck && cargo build --manifest-path crates/waybetter-host/Cargo.toml --locked",
|
"build": "bun run typecheck && cargo build --manifest-path crates/waybetter-host/Cargo.toml --locked",
|
||||||
"build:packages": "cd packages/waybetter && npm run build",
|
"build:packages": "cd packages/waybetter && npm run build",
|
||||||
"pack:check": "bun run build:packages && npm pack ./packages/waybetter --dry-run && npm pack ./packages/create-waybetter --dry-run",
|
"pack:check": "bun run build:packages && npm pack ./packages/waybetter --dry-run && npm pack ./packages/create-waybetter --dry-run",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue