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 ]