From ae403f56e03c81386f10476f9289ed7ea8a01f1a Mon Sep 17 00:00:00 2001 From: Saksham <168998965+cloudycotton@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:39:02 +0530 Subject: [PATCH] feat: keep preview on npm's latest by itself Any command starts a detached update check when the last one is more than 6 hours old; it never blocks the command or writes to stdout. The check reads this platform's package from the registry, downloads the latest tarball, verifies its sha512 against the registry's integrity field, checks the new binary prints its version, and installs it under ~/.runable/preview/versions/. At start a binary hands off to the newest installed version, so it works for read-only installs too. Deprecating a version and moving latest back rolls machines back. PREVIEW_UPDATE=0 turns it off, PREVIEW_VERSION pins a version, PREVIEW_REGISTRY points at a mirror, and `preview upgrade` checks now. Smoke scenarios cover update, rollback, a corrupted tarball, and the pin and opt-out controls against a static fake registry. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 6 +- Cargo.lock | 786 ++++++++++++++++++++++++++++++++++ Cargo.toml | 6 + README.md | 34 +- RELEASING.md | 8 +- src/main.rs | 29 +- src/run.rs | 3 +- src/update.rs | 605 ++++++++++++++++++++++++++ tests/registry.py | 67 +++ tests/smoke.sh | 73 ++++ 10 files changed, 1603 insertions(+), 14 deletions(-) create mode 100644 src/update.rs create mode 100755 tests/registry.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 056dad3..065edb3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,7 +60,7 @@ jobs: run: | bin=target/${{ matrix.target }}/release/preview file "$bin" - case "${{ matrix.target }}" in "$(uname -m)"-*) "$bin" --version | grep -qx "preview ${TAG#v}" ;; esac + case "${{ matrix.target }}" in "$(uname -m)"-*) PREVIEW_UPDATE=0 "$bin" --version | grep -E "^preview ${TAG#v}( |$)" ;; esac - name: Package run: | mkdir dist @@ -153,14 +153,14 @@ jobs: # npm can succeed while an optional platform package is still unavailable. for attempt in $(seq 60); do if npm install -g "@runablehq/preview@${TAG#v}" --prefer-online && - preview --version 2>/dev/null | grep -qx "preview ${TAG#v}"; then + PREVIEW_UPDATE=0 preview --version 2>/dev/null | grep -qE "^preview ${TAG#v}( |$)"; then break fi [ "$attempt" = 60 ] && exit 1 sleep 15 done preview --version - preview --version | grep -qx "preview ${TAG#v}" + PREVIEW_UPDATE=0 preview --version | grep -E "^preview ${TAG#v}( |$)" - run: tests/smoke.sh env: SMOKE_VITE: "1" diff --git a/Cargo.lock b/Cargo.lock index da95276..7652752 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,801 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[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", + "log", + "simd_cesu8", + "thiserror", + "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]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "libc" version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "preview" version = "0.1.0" dependencies = [ + "base64", + "flate2", + "libc", + "serde_json", + "sha2", + "tar", + "ureq", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", ] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +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", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[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]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "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]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +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]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index a84fccf..7ee51fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,13 @@ repository = "https://github.com/runablehq/preview" publish = false [dependencies] +base64 = "0.23.1" +flate2 = "1.1.10" libc = "0.2" +serde_json = "1.0.154" +sha2 = "0.11.1" +tar = { version = "0.4.46", default-features = false } +ureq = { version = "3.4.2", default-features = false, features = ["rustls", "platform-verifier"] } [profile.release] opt-level = "s" diff --git a/README.md b/README.md index 435c47f..2df8aff 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ preview stop preview stop --port preview list preview logs [--lines ] +preview upgrade preview --version ``` @@ -137,8 +138,35 @@ cargo build --release && PREVIEW=target/release/preview tests/smoke.sh # SMOKE and macOS for every PR; the release runs it again against the package installed from npm. Releasing is described in [RELEASING.md](RELEASING.md). +## Updates + +`preview` keeps itself on npm's `latest` with no setup, however it was installed (npm, curl, +a binary copied into an image, a read-only `/usr/local/bin`): + +- After any command, when the last check is more than 6 hours old, it starts a detached + background check and exits without waiting. The check downloads the new version of this + platform's package, checks its sha512 against the registry, checks that it runs, and + installs it under `~/.runable/preview/versions/`. +- At start, a binary hands off to the newest installed version, so the next command runs it. +- A rollback works the same way: deprecate the bad version on npm and move `latest` back. + Machines on the deprecated version switch to `latest` at their next check. + +Updates never write to stdout, never slow a command down, and never touch running previews. + +| Control | Effect | +| --- | --- | +| `PREVIEW_UPDATE=0` | no checks, no hand-off: the installed binary runs as is | +| `PREVIEW_VERSION=` | run exactly ``, installing it if needed | +| `PREVIEW_REGISTRY=` | fetch from a registry mirror instead of registry.npmjs.org | +| `preview upgrade` | check now, in the foreground, and say what happened | +| `preview --version` | `preview 0.4.1 (installed 0.3.0, checked 2 h ago)` | + +Downloads honor `HTTPS_PROXY`, `ALL_PROXY` and `NO_PROXY`. They trust the system's +certificates first, then Mozilla's bundled roots. + ## Not yet -v0.1 has no auto-update, signatures, `preview upgrade`, adoption of servers that detach from -the tree (pm2, `docker compose up -d`), the host-check probe (`hostCheck`), or HTTP-based port -choice. Those are milestones v0.2 and v0.3 of the design. Windows is not supported. +Updates are not signed yet. A version is trusted if npm serves it with a matching sha512, and +releases come only from this repository's release workflow through npm trusted publishing. +Also missing: adoption of servers that detach from the tree (pm2, `docker compose up -d`), the +host-check probe (`hostCheck`), and HTTP-based port choice. Windows is not supported. diff --git a/RELEASING.md b/RELEASING.md index 87813e2..5717c30 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -40,11 +40,15 @@ expected version before proceeding. ## Rolling back +Deprecate the bad version and point `latest` at the good one, for all five packages: + ```sh for p in preview preview-linux-x64 preview-linux-arm64 preview-darwin-x64 preview-darwin-arm64; do + npm deprecate "@runablehq/$p@" "" npm dist-tag add "@runablehq/$p@" latest done ``` -New installs pick up the good version from then on. Auto-update, which moves machines that -already have it installed, is v0.2. +New installs get the good version right away. Machines that already run the bad one switch at +their next update check, within about 6 hours (`preview upgrade` does it at once). Updaters +read the platform packages, so the deprecation must be on those, not only on the main one. diff --git a/src/main.rs b/src/main.rs index 61bfa78..8c105f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod run; mod signals; mod state; mod stop; +mod update; #[cfg(target_os = "linux")] #[path = "tree_linux.rs"] @@ -30,25 +31,38 @@ Usage: preview stop --port preview list preview logs [--lines ] + preview upgrade preview --version preview run starts from the current folder with PORT set to a free port, waits until it listens, and prints one JSON line: {\"name\",\"port\",\"ports\",\"pid\",\"url\",\"log\"}. Run the same command again to restart it. A single argument runs as a bash script; with several, -$PORT and $HOST inside them are filled in."; +$PORT and $HOST inside them are filled in. + +preview keeps itself on the latest release: a newer installed version takes over at start, +and a background check every 6 hours installs new ones. PREVIEW_UPDATE=0 turns that off, +PREVIEW_VERSION= pins a version, and preview upgrade checks now."; fn main() -> ExitCode { + update::hand_off(); let args: Vec = std::env::args_os() .skip(1) .map(|a| a.to_string_lossy().into_owned()) .collect(); - match dispatch(&args) { - Ok(code) => ExitCode::from(code), + let code = match dispatch(&args) { + Ok(code) => code, Err(msg) => { eprintln!("{msg}"); - ExitCode::from(1) + 1 } + }; + if !matches!( + args.first().map(String::as_str), + Some("__update" | "upgrade") + ) { + update::after_command(); } + ExitCode::from(code) } fn dispatch(args: &[String]) -> Result { @@ -65,9 +79,14 @@ fn dispatch(args: &[String]) -> Result { } "logs" | "log" => logs(rest), "--version" | "-V" | "version" => { - json::print(&format!("preview {}", env!("CARGO_PKG_VERSION"))); + json::print(&update::version_line()); Ok(0) } + "upgrade" => { + no_more(rest)?; + Ok(update::upgrade()) + } + "__update" => Ok(update::background()), "--help" | "-h" | "help" => { json::print(USAGE); Ok(0) diff --git a/src/run.rs b/src/run.rs index 0420eb7..4b22b72 100644 --- a/src/run.rs +++ b/src/run.rs @@ -121,7 +121,8 @@ pub fn run(opts: Options) -> u8 { .env("HOST", &host) .env("BROWSER", "none") .env("PYTHONUNBUFFERED", "1") - .env("NG_CLI_ANALYTICS", "false"); + .env("NG_CLI_ANALYTICS", "false") + .env_remove("PREVIEW_HANDOFF"); let fd_limit = signals::fd_limit(); // SAFETY: child_setup only makes async-signal-safe calls. unsafe { diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..55be921 --- /dev/null +++ b/src/update.rs @@ -0,0 +1,605 @@ +//! Auto-update (design section 7, without signatures for now). +//! +//! Every machine follows npm's `latest` of this platform's package, `@runablehq/preview-`: +//! +//! - At start, before anything else, a binary hands off (`exec`) to the newest installed +//! version named in `~/.runable/preview/current`, when that is newer than itself. +//! - After a command's own work, when the last check is older than 6 hours, it starts +//! `preview __update` detached and exits without waiting. That process fetches the registry +//! metadata, downloads the new tarball, checks its sha512 against the registry's integrity +//! field and that the new binary prints its version, then installs it under +//! `versions//` and rewrites `current`. +//! +//! None of this writes to stdout or blocks a command. Failures are recorded in `update.json` +//! and retried at the next check. + +use crate::signals; +use crate::state; +use base64::Engine; +use serde_json::{Value, json}; +use sha2::{Digest, Sha512}; +use std::cmp::Ordering; +use std::fmt; +use std::fs::{self, File, OpenOptions, TryLockError}; +use std::io::Read; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread::sleep; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +const CHECK_EVERY: u64 = 6 * 3600; +/// How soon to retry when `PREVIEW_VERSION` names a version that is not installed yet. +const PINNED_RETRY: u64 = 10 * 60; +const DEFAULT_REGISTRY: &str = "https://registry.npmjs.org"; +const METADATA_LIMIT: u64 = 16 * 1024 * 1024; +const TARBALL_LIMIT: u64 = 64 * 1024 * 1024; + +const PLATFORM: &str = if cfg!(all(target_os = "linux", target_arch = "x86_64")) { + "linux-x64" +} else if cfg!(all(target_os = "linux", target_arch = "aarch64")) { + "linux-arm64" +} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) { + "darwin-x64" +} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + "darwin-arm64" +} else { + "unsupported" +}; + +/// A plain `MAJOR.MINOR.PATCH`. Prereleases (`0.0.0-stage`) do not parse and are ignored. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Version(u64, u64, u64); + +impl Version { + pub fn parse(text: &str) -> Option { + let mut parts = text.trim().trim_start_matches('v').split('.'); + let mut next = || -> Option { + let part = parts.next()?; + if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + part.parse().ok() + }; + let v = Version(next()?, next()?, next()?); + parts.next().is_none().then_some(v) + } +} + +impl fmt::Display for Version { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}.{}", self.0, self.1, self.2) + } +} + +fn own() -> Version { + Version::parse(VERSION).unwrap_or(Version(0, 0, 0)) +} + +fn enabled() -> bool { + std::env::var("PREVIEW_UPDATE").map_or(true, |v| v != "0") +} + +fn pinned() -> Option { + std::env::var("PREVIEW_VERSION") + .ok() + .and_then(|v| Version::parse(&v)) +} + +fn dir() -> PathBuf { + state::home().join(".runable").join("preview") +} + +fn binary(v: Version) -> PathBuf { + dir().join("versions").join(v.to_string()).join("preview") +} + +fn installed(v: Version) -> bool { + fs::metadata(binary(v)).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) +} + +fn current() -> Option { + Version::parse(&fs::read_to_string(dir().join("current")).ok()?) +} + +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} + +/// The version that handed off to this process, if one did. +fn handed_off_from() -> Option { + std::env::var("PREVIEW_HANDOFF") + .ok() + .filter(|v| !v.is_empty()) +} + +/// Runs first in `main`. Replaces this process with the newer installed version, or returns. +pub fn hand_off() { + if !enabled() || handed_off_from().is_some() { + return; + } + let target = match pinned() { + Some(p) => (p != own()).then_some(p), + None => current().filter(|c| *c > own()), + }; + let Some(v) = target.filter(|v| installed(*v)) else { + return; + }; + // exec only returns on failure; then this binary carries on as itself. + let _ = Command::new(binary(v)) + .args(std::env::args_os().skip(1)) + .env("PREVIEW_HANDOFF", VERSION) + .exec(); +} + +/// What `update.json` remembers between checks. +#[derive(Default)] +struct Record { + checked_at: u64, + latest: Option, + last_error: Option, +} + +impl Record { + fn load() -> Record { + let Some(v) = fs::read_to_string(dir().join("update.json")) + .ok() + .and_then(|t| serde_json::from_str::(&t).ok()) + else { + return Record::default(); + }; + Record { + checked_at: v["checkedAt"].as_u64().unwrap_or(0), + latest: v["latest"].as_str().map(String::from), + last_error: v["lastError"].as_str().map(String::from), + } + } + + fn save(&self) { + let mut v = json!({ "checkedAt": self.checked_at }); + if let Some(latest) = &self.latest { + v["latest"] = json!(latest); + } + if let Some(err) = &self.last_error { + v["lastError"] = json!(err); + } + let _ = write_atomic(&dir().join("update.json"), &format!("{v}\n")); + } +} + +fn write_atomic(path: &Path, text: &str) -> std::io::Result<()> { + let partial = path.with_extension("partial"); + fs::write(&partial, text)?; + fs::rename(&partial, path) +} + +/// Runs last in `main`: starts a background check when one is due. +pub fn after_command() { + if !enabled() || fs::create_dir_all(dir()).is_err() { + return; + } + let every = match pinned() { + Some(p) if p != own() && !installed(p) => PINNED_RETRY, + _ => CHECK_EVERY, + }; + let checked = Record::load().checked_at; + let now = now(); + if checked <= now && now - checked < every { + return; + } + let Ok(exe) = std::env::current_exe() else { + return; + }; + let mut cmd = Command::new(exe); + cmd.arg("__update") + // Already the newest binary here; the child needs no hand-off of its own. + .env( + "PREVIEW_HANDOFF", + handed_off_from().unwrap_or_else(|| VERSION.to_string()), + ) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let fd_limit = signals::fd_limit(); + // SAFETY: child_setup only makes async-signal-safe calls. + unsafe { + cmd.pre_exec(move || signals::child_setup(fd_limit)); + } + // Not waited for: it outlives this process in its own session. + let _ = cmd.spawn(); +} + +fn lock(wait: bool) -> Option { + fs::create_dir_all(dir()).ok()?; + let file = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(dir().join("update.lock")) + .ok()?; + match file.try_lock() { + Ok(()) => Some(file), + Err(TryLockError::WouldBlock) if wait => { + eprintln!("Waiting for another preview update to finish…"); + file.lock().ok()?; + Some(file) + } + Err(_) => None, + } +} + +/// `preview __update`: the detached background check. +pub fn background() -> u8 { + if let Some(_lock) = lock(false) { + let _ = check(); + } + 0 +} + +/// `preview upgrade`: the same check in the foreground, saying what it did. +pub fn upgrade() -> u8 { + let Some(_lock) = lock(true) else { + eprintln!("preview: cannot write {}.", dir().display()); + return 1; + }; + match check() { + Ok(Done::UpToDate(v)) => { + println!("preview {v} is the latest version."); + 0 + } + Ok(Done::Switched(v)) => { + println!("Switched to preview {v}; the next command runs it."); + 0 + } + Ok(Done::Installed(v)) => { + println!("Installed preview {v}; the next command runs it."); + 0 + } + Err(e) => { + eprintln!("preview: update failed: {e}"); + 1 + } + } +} + +enum Done { + UpToDate(Version), + /// `current` now names a version that was already on disk. + Switched(Version), + Installed(Version), +} + +fn check() -> Result { + let mut record = Record::load(); + let result = decide_and_install(&mut record); + record.checked_at = now(); + record.last_error = result.as_ref().err().cloned(); + record.save(); + result +} + +fn decide_and_install(record: &mut Record) -> Result { + let meta = metadata()?; + let latest = meta["dist-tags"]["latest"] + .as_str() + .and_then(Version::parse); + record.latest = latest.map(|v| v.to_string()); + + // A pinned version is installed but never becomes `current`: shells without + // PREVIEW_VERSION keep following latest. + if let Some(p) = pinned() { + if p == own() || installed(p) { + return Ok(Done::UpToDate(p)); + } + install(&meta, p)?; + return Ok(Done::Installed(p)); + } + + let latest = latest.ok_or("the registry names no latest version")?; + let active = current().unwrap_or(own()); + let deprecated = |v: Version| { + meta["versions"][v.to_string()]["deprecated"] + .as_str() + .is_some_and(|reason| !reason.is_empty()) + }; + let want = match latest.cmp(&active) { + Ordering::Greater => latest, + // Rollback: latest moved back because the active version was deprecated. + Ordering::Less if deprecated(active) => latest, + _ => return Ok(Done::UpToDate(active)), + }; + + let done = if want == own() || installed(want) { + Done::Switched(want) + } else { + install(&meta, want)?; + Done::Installed(want) + }; + write_atomic(&dir().join("current"), &format!("{want}\n")) + .map_err(|e| format!("cannot write {}: {e}", dir().join("current").display()))?; + prune(&[want, active]); + Ok(done) +} + +fn registry() -> String { + std::env::var("PREVIEW_REGISTRY") + .ok() + .filter(|r| !r.is_empty()) + .unwrap_or_else(|| DEFAULT_REGISTRY.to_string()) + .trim_end_matches('/') + .to_string() +} + +fn metadata() -> Result { + let url = format!("{}/@runablehq%2fpreview-{PLATFORM}", registry()); + let body = get( + &url, + "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8", + Duration::from_secs(10), + METADATA_LIMIT, + )?; + serde_json::from_slice(&body).map_err(|e| format!("{url} is not JSON: {e}")) +} + +/// One GET, trusting the system's certificates first (so TLS-intercepting proxies work) and +/// Mozilla's roots second (for minimal images without a CA bundle). Proxies come from +/// HTTPS_PROXY / ALL_PROXY / NO_PROXY. +fn get(url: &str, accept: &str, timeout: Duration, limit: u64) -> Result, String> { + let fetch = |roots: ureq::tls::RootCerts| -> Result, ureq::Error> { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(timeout)) + .tls_config(ureq::tls::TlsConfig::builder().root_certs(roots).build()) + .user_agent(format!("preview/{VERSION}")) + .build() + .into(); + agent + .get(url) + .header("Accept", accept) + .call()? + .body_mut() + .with_config() + .limit(limit) + .read_to_vec() + }; + match fetch(ureq::tls::RootCerts::PlatformVerifier) { + Ok(body) => Ok(body), + Err(e @ ureq::Error::StatusCode(_)) => Err(format!("GET {url}: {e}")), + Err(_) => fetch(ureq::tls::RootCerts::WebPki).map_err(|e| format!("GET {url}: {e}")), + } +} + +fn install(meta: &Value, v: Version) -> Result<(), String> { + let dist = &meta["versions"][v.to_string()]["dist"]; + let tarball = dist["tarball"] + .as_str() + .ok_or_else(|| format!("the registry has no tarball for {v}"))?; + let integrity = dist["integrity"] + .as_str() + .ok_or_else(|| format!("the registry has no integrity for {v}"))?; + let bytes = get( + tarball, + "application/octet-stream", + Duration::from_secs(120), + TARBALL_LIMIT, + )?; + verify_integrity(&bytes, integrity).map_err(|e| format!("{tarball}: {e}"))?; + let exe = extract_binary(&bytes).map_err(|e| format!("{tarball}: {e}"))?; + + let versions = dir().join("versions"); + let partial = versions.join(format!("{v}.partial")); + let _ = fs::remove_dir_all(&partial); + fs::create_dir_all(&partial) + .map_err(|e| format!("cannot create {}: {e}", partial.display()))?; + let new = partial.join("preview"); + fs::write(&new, exe).map_err(|e| format!("cannot write {}: {e}", new.display()))?; + fs::set_permissions(&new, fs::Permissions::from_mode(0o755)) + .map_err(|e| format!("cannot chmod {}: {e}", new.display()))?; + + let printed = run_version(&new)?; + if !reports(&printed, v) { + let _ = fs::remove_dir_all(&partial); + return Err(format!("the {v} binary printed {printed:?} for --version")); + } + + let done = versions.join(v.to_string()); + let _ = fs::remove_dir_all(&done); + fs::rename(&partial, &done).map_err(|e| format!("cannot install {}: {e}", done.display())) +} + +/// Accepts `sha512-` among the space-separated hashes of an SRI string. +fn verify_integrity(bytes: &[u8], integrity: &str) -> Result<(), String> { + let got = base64::engine::general_purpose::STANDARD.encode(Sha512::digest(bytes)); + let wanted: Vec<&str> = integrity + .split_whitespace() + .filter_map(|h| h.strip_prefix("sha512-")) + .map(|h| h.split('?').next().unwrap_or(h)) + .collect(); + if wanted.is_empty() { + return Err(format!("no sha512 in integrity {integrity:?}")); + } + if wanted.contains(&got.as_str()) { + Ok(()) + } else { + Err(format!( + "sha512 mismatch (registry {integrity}, download sha512-{got})" + )) + } +} + +/// `package/bin/preview` out of an npm tarball. +fn extract_binary(tgz: &[u8]) -> Result, String> { + let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(tgz)); + let entries = archive + .entries() + .map_err(|e| format!("not a tarball: {e}"))?; + for entry in entries { + let mut entry = entry.map_err(|e| format!("bad tarball: {e}"))?; + let is_binary = entry + .path() + .is_ok_and(|p| p.as_os_str() == "package/bin/preview"); + if is_binary { + let mut out = Vec::new(); + entry + .read_to_end(&mut out) + .map_err(|e| format!("bad tarball: {e}"))?; + return Ok(out); + } + } + Err(String::from("no package/bin/preview in the tarball")) +} + +/// Runs ` --version` with updates off, for at most 5 seconds. +fn run_version(bin: &Path) -> Result { + let mut child = Command::new(bin) + .arg("--version") + .env("PREVIEW_UPDATE", "0") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("cannot run the new binary: {e}"))?; + let until = Instant::now() + Duration::from_secs(5); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < until => sleep(Duration::from_millis(20)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + return Err(String::from( + "the new binary did not answer --version in 5 s", + )); + } + } + } + let mut out = String::new(); + if let Some(mut stdout) = child.stdout.take() { + let _ = stdout.read_to_string(&mut out); + } + Ok(out.trim().to_string()) +} + +/// `preview 0.4.1` or `preview 0.4.1 (…)`. +fn reports(printed: &str, v: Version) -> bool { + let first = printed.lines().next().unwrap_or(""); + first + .strip_prefix("preview ") + .and_then(|rest| rest.split_whitespace().next()) + .and_then(Version::parse) + == Some(v) +} + +/// Keeps the given versions (and a pinned one) and deletes every other version folder. +fn prune(keep: &[Version]) { + let Ok(entries) = fs::read_dir(dir().join("versions")) else { + return; + }; + let mut keep: Vec = keep.iter().map(|v| v.to_string()).collect(); + keep.extend(pinned().map(|v| v.to_string())); + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if !keep.contains(&name) { + let _ = fs::remove_dir_all(entry.path()); + } + } +} + +/// The text of `preview --version`: `preview 0.4.1 (installed 0.3.0, checked 2 h ago)`. +pub fn version_line() -> String { + let mut notes: Vec = Vec::new(); + if let Some(from) = handed_off_from().filter(|f| f != VERSION) { + notes.push(format!("installed {from}")); + } + if !enabled() { + notes.push(String::from("updates off")); + } else { + if let Some(p) = pinned() { + notes.push(format!("pinned to {p}")); + } + let record = Record::load(); + notes.push(match record.checked_at { + 0 => String::from("not checked yet"), + at => format!("checked {}", ago(now().saturating_sub(at))), + }); + if record.last_error.is_some() { + notes.push(String::from("last check failed, see preview upgrade")); + } + } + format!("preview {VERSION} ({})", notes.join(", ")) +} + +fn ago(secs: u64) -> String { + match secs { + 0..60 => String::from("just now"), + 60..3600 => format!("{} min ago", secs / 60), + 3600..172800 => format!("{} h ago", secs / 3600), + _ => format!("{} days ago", secs / 86400), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_versions() { + assert_eq!(Version::parse("0.4.1"), Some(Version(0, 4, 1))); + assert_eq!(Version::parse("v1.2.3\n"), Some(Version(1, 2, 3))); + assert_eq!(Version::parse("0.0.0-stage"), None); + assert_eq!(Version::parse("1.2"), None); + assert_eq!(Version::parse("1.2.3.4"), None); + assert!(Version::parse("0.10.0") > Version::parse("0.9.9")); + } + + #[test] + fn checks_integrity() { + let data = b"hello"; + let sri = format!( + "sha512-{}", + base64::engine::general_purpose::STANDARD.encode(Sha512::digest(data)) + ); + assert!(verify_integrity(data, &sri).is_ok()); + assert!(verify_integrity(data, &format!("sha1-abc {sri}")).is_ok()); + assert!(verify_integrity(b"other", &sri).is_err()); + assert!(verify_integrity(data, "sha1-abc").is_err()); + } + + #[test] + fn reads_version_lines() { + let v = Version(0, 4, 1); + assert!(reports("preview 0.4.1", v)); + assert!(reports( + "preview 0.4.1 (installed 0.3.0, checked 2 h ago)\n", + v + )); + assert!(!reports("preview 0.4.10", v)); + assert!(!reports("something else", v)); + } + + #[test] + fn extracts_the_binary() { + let mut tgz = Vec::new(); + { + let gz = flate2::write::GzEncoder::new(&mut tgz, flate2::Compression::fast()); + let mut tar = tar::Builder::new(gz); + for (path, body) in [ + ("package/package.json", &b"{}"[..]), + ("package/bin/preview", &b"#!/bin/sh\n"[..]), + ] { + let mut header = tar::Header::new_gnu(); + header.set_size(body.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + tar.append_data(&mut header, path, body).unwrap(); + } + tar.into_inner().unwrap().finish().unwrap(); + } + assert_eq!(extract_binary(&tgz).unwrap(), b"#!/bin/sh\n"); + assert!(extract_binary(b"junk").is_err()); + } +} diff --git a/tests/registry.py b/tests/registry.py new file mode 100755 index 0000000..22ac96c --- /dev/null +++ b/tests/registry.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""A static stand-in for the npm registry, for the update scenarios in smoke.sh. + + registry.py publish adds a fixture version and makes it latest + registry.py latest moves dist-tag latest + registry.py deprecate marks a version deprecated + registry.py corrupt breaks a version's integrity + +Serve with `python3 -m http.server`; preview reads /@runablehq%2fpreview-. +A fixture "binary" is a script that answers --version like preview and prints +`fixture ` for anything else, so a test can see which version ran. +""" + +import base64 +import hashlib +import io +import json +import sys +import tarfile +from pathlib import Path + + +def main(): + root, action, platform, version = Path(sys.argv[1]), *sys.argv[2:5] + name = f"@runablehq/preview-{platform}" + meta_path = root / "@runablehq" / f"preview-{platform}" + meta_path.parent.mkdir(parents=True, exist_ok=True) + meta = json.loads(meta_path.read_text()) if meta_path.exists() else { + "name": name, "dist-tags": {}, "versions": {}} + + if action == "publish": + script = (f'#!/bin/sh\nif [ "$1" = --version ]; then echo "preview {version}"; exit 0; fi\n' + f'echo "fixture {version}"\n').encode() + tgz = tarball({"package/package.json": json.dumps({"name": name, "version": version}).encode(), + "package/bin/preview": script}) + file = root / "tarballs" / f"preview-{platform}-{version}.tgz" + file.parent.mkdir(parents=True, exist_ok=True) + file.write_bytes(tgz) + integrity = "sha512-" + base64.b64encode(hashlib.sha512(tgz).digest()).decode() + url = f"{sys.argv[5].rstrip('/')}/tarballs/{file.name}" if len(sys.argv) > 5 else "" + meta["versions"][version] = {"name": name, "version": version, + "dist": {"tarball": url, "integrity": integrity}} + meta["dist-tags"]["latest"] = version + elif action == "latest": + meta["dist-tags"]["latest"] = version + elif action == "deprecate": + meta["versions"][version]["deprecated"] = "broken, use the previous version" + elif action == "corrupt": + meta["versions"][version]["dist"]["integrity"] = "sha512-" + base64.b64encode(b"x" * 64).decode() + else: + sys.exit(f"unknown action {action}") + meta_path.write_text(json.dumps(meta)) + + +def tarball(files): + out = io.BytesIO() + with tarfile.open(fileobj=out, mode="w:gz") as tar: + for path, body in files.items(): + info = tarfile.TarInfo(path) + info.size = len(body) + info.mode = 0o755 + tar.addfile(info, io.BytesIO(body)) + return out.getvalue() + + +if __name__ == "__main__": + main() diff --git a/tests/smoke.sh b/tests/smoke.sh index 1658fcd..e164113 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -18,6 +18,10 @@ SMOKE_HTTP_SERVER="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/http-server.py" export SMOKE_HTTP_SERVER WORK=$(mktemp -d "${TMPDIR:-/tmp}/preview-smoke.XXXXXX") export PREVIEW_HOME="$WORK/home" +# The binary under test, never a newer one it would update to. The update scenarios turn +# this back on against a fake registry. +export PREVIEW_UPDATE=0 +REGISTRY_PID="" FAILED=0 PASSED=0 @@ -25,6 +29,7 @@ cleanup() { for name in $("$PREVIEW" list | python3 -c 'import json,sys; print(" ".join(p["name"] for p in json.load(sys.stdin) if p["running"]))'); do "$PREVIEW" stop "$name" >/dev/null done + [ -n "$REGISTRY_PID" ] && kill "$REGISTRY_PID" 2>/dev/null rm -rf "$WORK" } trap cleanup EXIT @@ -226,5 +231,73 @@ if [ "${SMOKE_VITE:-0}" = 1 ]; then fi fi +# Auto-update, against a fake registry on port 8769 and a home of its own. +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) PLATFORM=linux-x64 ;; + Linux-aarch64 | Linux-arm64) PLATFORM=linux-arm64 ;; + Darwin-x86_64) PLATFORM=darwin-x64 ;; + Darwin-arm64) PLATFORM=darwin-arm64 ;; +esac +REG="$WORK/registry" +REG_URL=http://127.0.0.1:8769 +FAKE="$(cd "$(dirname "$0")" && pwd)/registry.py" +mkdir -p "$REG" +(cd "$REG" && exec python3 "$SMOKE_HTTP_SERVER" 8769 --bind 127.0.0.1 >/dev/null 2>&1) & +REGISTRY_PID=$! +for _ in $(seq 50); do listening 8769 && break; sleep 0.1; done +UPHOME="$WORK/uphome" +mkdir -p "$UPHOME" +up() { HOME="$UPHOME" PREVIEW_UPDATE=1 PREVIEW_REGISTRY=$REG_URL "$PREVIEW" "$@"; } +# preview upgrade by the binary under test itself, not the fixture it would hand off to. +upgrade_here() { PREVIEW_HANDOFF=smoke up upgrade 2>&1; } +python3 "$FAKE" "$REG" publish "$PLATFORM" 9.9.8 "$REG_URL" +python3 "$FAKE" "$REG" publish "$PLATFORM" 9.9.9 "$REG_URL" + +# Update: any command starts a background check, its own output untouched; the next run is the new version. +OUT=$(up list) +if python3 -c 'import json,sys; json.loads(sys.argv[1])' "$OUT"; then + for _ in $(seq 100); do [ "$(cat "$UPHOME/.runable/preview/current" 2>/dev/null)" = 9.9.9 ] && break; sleep 0.2; done + NEXT=$(up list) + if [ "$NEXT" = "fixture 9.9.9" ]; then + ok "update: installs latest in the background, next run hands off" + else + bad "update" "next run printed: $NEXT" "update.json: $(cat "$UPHOME/.runable/preview/update.json" 2>/dev/null)" + fi +else + bad "update" "first run's stdout changed: $OUT" +fi + +# Rollback: latest moves back and the active version is deprecated. +python3 "$FAKE" "$REG" latest "$PLATFORM" 9.9.8 +python3 "$FAKE" "$REG" deprecate "$PLATFORM" 9.9.9 +OUT=$(upgrade_here) +NEXT=$(up list) +if echo "$OUT" | grep -q "Installed preview 9.9.8" && [ "$NEXT" = "fixture 9.9.8" ]; then + ok "rollback to the previous version" +else + bad "rollback" "upgrade: $OUT" "next run: $NEXT" +fi + +# A corrupted download is refused and changes nothing. +python3 "$FAKE" "$REG" publish "$PLATFORM" 9.9.10 "$REG_URL" +python3 "$FAKE" "$REG" corrupt "$PLATFORM" 9.9.10 +OUT=$(upgrade_here) +CODE=$? +if [ "$CODE" = 1 ] && echo "$OUT" | grep -q "sha512 mismatch" && [ "$(up list)" = "fixture 9.9.8" ] && + grep -q lastError "$UPHOME/.runable/preview/update.json"; then + ok "update refuses a tarball that fails its integrity check" +else + bad "integrity" "exit $CODE: $OUT" +fi + +# Pin and opt-out. +PINNED=$(PREVIEW_VERSION=9.9.9 up list) +OFF=$(HOME="$UPHOME" PREVIEW_UPDATE=0 "$PREVIEW" --version) +if [ "$PINNED" = "fixture 9.9.9" ] && echo "$OFF" | grep -q "updates off"; then + ok "PREVIEW_VERSION pins, PREVIEW_UPDATE=0 stays put" +else + bad "pin and opt-out" "pinned: $PINNED" "off: $OFF" +fi + echo "# $PASSED passed, $FAILED failed" [ "$FAILED" = 0 ]