diff --git a/docs/CLIENT_INSTALLATION.md b/docs/CLIENT_INSTALLATION.md index 9a65b111..a5f7cde0 100644 --- a/docs/CLIENT_INSTALLATION.md +++ b/docs/CLIENT_INSTALLATION.md @@ -37,3 +37,42 @@ Compatibility exports refer to the same functions/constants. Shared protocol hel `tests/test_client_boundary.py` checks implementation ownership, compatibility exports, proof context, atomic-write failure protection and missing-dependency errors. `scripts/check_client_install.py` starts a fresh interpreter with server imports blocked, loads client features, saves/reloads a private key, and sends the same signed request through four real client adapters. Its HTTP endpoints use MockTransport rather than production. The client-boundary workflow builds wheel/sdist, installs the wheel in a new environment outside the source tree, checks dependencies, both help commands and all four transport probes. `--minimal-install` rejects server Python dependencies. Full PostgreSQL/Valkey, CLI/MCP, credential journal/recovery and conformance gates remain required separately. These checks do not establish physical-device, external-network, performance or production acceptance. + +## One-command, user-level installation + +```bash +curl -fsSL https://msg.lmm.best/install | bash +msg lightjunction@msg.lmm.best "" +``` + +The `/install` endpoint serves a packaged Bash script. It installs the client +without `sudo` on Linux glibc x86_64/ARM64 and macOS Intel/Apple Silicon. +It needs Bash, curl, tar, and a SHA-256 utility. No existing Python is required: +the script verifies a pinned uv 0.12.20 native archive and obtains managed +CPython 3.15.0rc2. CPython is currently a release candidate because this project +requires Python 3.15. + +The client source is the published GitHub commit +`5554f93819478f4559aa226d1d94cfc6202a1cee`, verified against its embedded SHA-256; +there is no dependency on an unpublished MSG release asset. Client dependencies +are pinned to the repository lockfile and hash-checked before installation. +Only `msg` is exposed in `$HOME/.local/bin`; server dependencies and daemon +services are not installed. Install data stays under +`$XDG_DATA_HOME/msg/client-{releases,python,cache}` (default `~/.local/share`). +Identity, certificates, and service connection configuration are untouched. + +If that bin directory is absent from PATH, the installer prints the line to add +to your shell configuration. It does not edit shell files. Repeating the +installation reuses the verified installed revision. An existing `msg` launcher +from a different installer is retained unless you explicitly replace it: + +```bash +curl -fsSL https://msg.lmm.best/install | bash -s -- --force +``` + +To publish a newer client, update the source revision and archive SHA-256 in +`src/msg/data/install.sh`, regenerate its embedded client requirements with +`uv export --no-dev --no-emit-project --format requirements-txt`, and exercise a +fresh installation before deploying the `/install` endpoint. The installer UX +was inspired by [Termium](https://github.com/codr1/termium); its browser and SSH +implementation are not dependencies of MSG. diff --git a/src/msg/data/install.sh b/src/msg/data/install.sh new file mode 100644 index 00000000..adb8dccc --- /dev/null +++ b/src/msg/data/install.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +# Parse the complete function before a piped download can execute installation. +# UX inspired by https://github.com/codr1/termium; implementation is MSG-specific. +main() { + set -euo pipefail + fail() { printf 'msg: %s\n' "$*" >&2; exit 1; } + force=0 + case "${1:-}" in + '') ;; + --force) force=1; shift ;; + --help) printf '%s\n' 'Install the MSG client: curl -fsSL https://msg.lmm.best/install | bash' 'Use bash -s -- --force to replace an existing unmanaged msg command.'; return ;; + *) fail 'Unknown option; use --help.' ;; + esac + [ "$#" -eq 0 ] || fail 'Unexpected arguments.' + case "$(uname -s)-$(uname -m)" in + Linux-x86_64) platform=x86_64-unknown-linux-gnu; uv_hash=6590717592ace991ff83a63fef799e3ad9d33ecc8f96c5d6bdd732496e79337f ;; + Linux-aarch64|Linux-arm64) platform=aarch64-unknown-linux-gnu; uv_hash=8a7aad7bc76a2fae5151566ff3e43eacce0b2a113d5e4de3e4afe3e58fa2441e ;; + Darwin-x86_64) platform=x86_64-apple-darwin; uv_hash=ac54283d211fd77cdc152b67606dbaf6406ff4ab03f3af4ae99468fa8e887141 ;; + Darwin-arm64) platform=aarch64-apple-darwin; uv_hash=848fdeb602ff1a1baacd4f6c8b7bdc6cf1ad026a6d9cf59475fda17c179743ca ;; + *) fail 'Supported: Linux glibc x86_64/ARM64 and macOS Intel/Apple Silicon.' ;; + esac + if [ "$(uname -s)" = Linux ]; then + getconf GNU_LIBC_VERSION >/dev/null 2>&1 || fail 'Linux requires glibc; musl is not supported by this installer.' + fi + for tool in curl tar mktemp; do command -v "$tool" >/dev/null || fail "$tool is required."; done + if command -v sha256sum >/dev/null; then + sha256() { sha256sum "$1" | awk '{print $1}'; } + elif command -v shasum >/dev/null; then + sha256() { shasum -a 256 "$1" | awk '{print $1}'; } + else fail 'SHA-256 verification is required.'; fi + revision=5554f93819478f4559aa226d1d94cfc6202a1cee + source_hash=7214ffbd73aaa247a91732f4f7229ee75f6d0d90cd86e73dbc4bbcbd96dbdc09 + data_home=${XDG_DATA_HOME:-"$HOME/.local/share"} + case "$data_home" in /*) ;; *) fail 'XDG_DATA_HOME must be absolute.' ;; esac + bin_dir="$HOME/.local/bin" + release_root="$data_home/msg/client-releases" + release="$release_root/$revision" + if [ -e "$bin_dir/msg" ] || [ -L "$bin_dir/msg" ]; then + current=$(readlink "$bin_dir/msg" 2>/dev/null || true) + case "$current" in "$release_root"/*/bin/msg) ;; *) [ "$force" -eq 1 ] || fail 'An existing msg command is not managed by this installer. Re-run with --force to replace its launcher.' ;; esac + fi + work=$(mktemp -d "${TMPDIR:-/tmp}/msg-install.XXXXXXXX") + created=0 + locked=0 + launcher='' + cleanup() { + rm -rf -- "$work" + [ -z "$launcher" ] || rm -f -- "$launcher" + if [ "$created" -eq 1 ]; then rm -rf -- "$release"; fi + if [ "$locked" -eq 1 ]; then rmdir "$release_root/.install-lock"; fi + } + trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + download() { curl --fail --location --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 --connect-timeout 15 "$1" -o "$2"; } + mkdir -p "$release_root" + mkdir "$release_root/.install-lock" 2>/dev/null || fail 'Another installation is running (or a stale .install-lock remains).' + locked=1 + [ ! -d "$bin_dir/msg" ] || fail 'The launcher path is a directory; refusing to replace it.' + if [ ! -x "$release/bin/msg" ]; then + [ ! -e "$release" ] || fail 'An incomplete release directory exists; remove it before retrying.' + printf 'Installing MSG client %s (no sudo)\n' "${revision:0:7}" >&2 + download "https://github.com/astral-sh/uv/releases/download/0.12.20/uv-$platform.tar.gz" "$work/uv.tar.gz" + [ "$(sha256 "$work/uv.tar.gz")" = "$uv_hash" ] || fail 'uv checksum mismatch.' + tar -xzf "$work/uv.tar.gz" -C "$work" + uv="$work/uv-$platform/uv" + [ -x "$uv" ] || fail 'uv archive does not contain the expected executable.' + download "https://codeload.github.com/TokenNotIncluded/msg/zip/$revision" "$work/msg.zip" + [ "$(sha256 "$work/msg.zip")" = "$source_hash" ] || fail 'MSG source checksum mismatch.' + cat > "$work/requirements.txt" <<'MSG_CLIENT_REQUIREMENTS' +# This file was autogenerated by uv via the following command: +# uv export --no-dev --no-emit-project --format requirements-txt --output-file /tmp/msg-client-requirements.txt +anyio==4.15.1 \ + --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \ + --hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94 + # via httpx +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via + # jsonschema + # referencing +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # httpcore + # httpx +cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 + # via cryptography +cryptography==46.0.7 \ + --hash=sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832 \ + --hash=sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067 \ + --hash=sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de \ + --hash=sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0 \ + --hash=sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b \ + --hash=sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef \ + --hash=sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b \ + --hash=sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4 \ + --hash=sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3 \ + --hash=sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308 \ + --hash=sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e \ + --hash=sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163 \ + --hash=sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77 \ + --hash=sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85 \ + --hash=sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7 \ + --hash=sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83 \ + --hash=sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85 \ + --hash=sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006 \ + --hash=sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb \ + --hash=sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e \ + --hash=sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba \ + --hash=sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325 \ + --hash=sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1 \ + --hash=sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2 \ + --hash=sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0 \ + --hash=sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457 \ + --hash=sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5 \ + --hash=sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4 \ + --hash=sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246 + # via msgctl +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via httpcore +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via msgctl +idna==3.20 \ + --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ + --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c + # via + # anyio + # httpx +jsonschema==4.26.0 \ + --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + # via msgctl +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via jsonschema +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # via + # jsonschema + # jsonschema-specifications + # msgctl +rpds-py==2026.6.3 \ + --hash=sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf \ + --hash=sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4 \ + --hash=sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a \ + --hash=sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187 \ + --hash=sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05 \ + --hash=sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba \ + --hash=sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146 \ + --hash=sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e \ + --hash=sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12 \ + --hash=sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5 \ + --hash=sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342 \ + --hash=sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb \ + --hash=sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885 \ + --hash=sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617 \ + --hash=sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577 \ + --hash=sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140 \ + --hash=sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba \ + --hash=sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a \ + --hash=sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324 \ + --hash=sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b \ + --hash=sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690 \ + --hash=sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107 \ + --hash=sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6 \ + --hash=sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369 \ + --hash=sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13 \ + --hash=sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868 \ + --hash=sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9 \ + --hash=sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00 \ + --hash=sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442 \ + --hash=sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef + # via + # jsonschema + # referencing +MSG_CLIENT_REQUIREMENTS + mkdir -p "$release_root" + created=1 + # Keep the runtime/cache in MSG's installation area, away from identities. + export UV_PYTHON_INSTALL_DIR="$data_home/msg/client-python" + export UV_CACHE_DIR="$data_home/msg/client-cache" + "$uv" venv --managed-python --python 3.15.0rc2 "$release" + "$uv" pip install --python "$release/bin/python" --require-hashes --only-binary :all: -r "$work/requirements.txt" + "$uv" pip install --python "$release/bin/python" --no-deps "$work/msg.zip" + "$release/bin/msg" --help >/dev/null + fi + mkdir -p "$bin_dir" + # mv on the same filesystem atomically replaces only the launcher, after validation. + launcher=$(mktemp "$bin_dir/.msg-install.XXXXXXXX") + rm -f -- "$launcher" + ln -s "$release/bin/msg" "$launcher" + mv -f -- "$launcher" "$bin_dir/msg" + created=0 + printf '\nMSG installed: %s/msg\nTry: msg lightjunction@msg.lmm.best ""\n' "$bin_dir" + # shellcheck disable=SC2016 # Print literal shell variables for the user. + case ":$PATH:" in *":$bin_dir:"*) ;; *) printf '\nAdd to your shell configuration:\n export PATH="$HOME/.local/bin:$PATH"\n' ;; esac +} +main "$@" diff --git a/src/msg/transports/http_routes.py b/src/msg/transports/http_routes.py index 21c2707b..30907963 100644 --- a/src/msg/transports/http_routes.py +++ b/src/msg/transports/http_routes.py @@ -1768,6 +1768,18 @@ async def dispatch(request: Request): if path == '/-' or path.startswith('/-/'): raise Failure('not_found') require(request.method in {'GET', 'HEAD'}, 'method_not_allowed') + if path == '/install': + require(not request.url.query, 'unknown_query_parameter') + payload = files('msg.data').joinpath('install.sh').read_bytes() + return Response( + b'' if request.method == 'HEAD' else payload, + media_type='text/plain', + headers={ + **BASE_HEADERS, + 'Content-Length': str(len(payload)), + 'Cache-Control': 'no-cache', + }, + ) if path in {'/robots.txt', '/sitemap.xml'}: require(not request.url.query, 'unknown_query_parameter') origin = service.settings.service_url.rstrip('/') diff --git a/tests/test_client_installer.py b/tests/test_client_installer.py new file mode 100644 index 00000000..9bd4f35c --- /dev/null +++ b/tests/test_client_installer.py @@ -0,0 +1,68 @@ +"""The installer is a read-only, packaged bootstrap with GET/HEAD parity.""" + +import os +import subprocess +from importlib.resources import files + +import httpx +import pytest + +from msg.transports.http import create_app + + +@pytest.mark.asyncio +async def test_install_endpoint_is_packaged_read_only_and_head_matches(installed): + app, _ = installed + script = files('msg.data').joinpath('install.sh').read_bytes() + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + response = await http.get('/install') + assert response.status_code == 200 + assert response.content == script + assert response.headers['content-type'].startswith('text/plain') + assert response.headers['x-content-type-options'] == 'nosniff' + assert response.headers['cache-control'] == 'no-cache' + head = await http.head('/install') + assert head.status_code == 200 and not head.content + assert head.headers['content-length'] == str(len(script)) + assert (await http.post('/install', content='overwrite')).status_code == 405 + assert (await http.get('/install?source=elsewhere')).status_code == 400 + + +def test_checksum_failure_keeps_existing_launcher_and_identity(tmp_path): + home = tmp_path / 'home' + bin_dir = home / '.local/bin' + release_root = home / '.local/share/msg/client-releases' + old = release_root / 'previous/bin/msg' + old.parent.mkdir(parents=True) + old.write_text('#!/bin/sh\nexit 0\n') + old.chmod(0o755) + bin_dir.mkdir(parents=True) + launcher = bin_dir / 'msg' + launcher.symlink_to(old) + identity = home / '.local/share/msg/services/example.test/identity.key' + identity.parent.mkdir(parents=True) + identity.write_text('identity marker') + fake = tmp_path / 'fake-bin' + fake.mkdir() + curl = fake / 'curl' + curl.write_text('#!/bin/bash\nprintf "invalid download" > "${@: -1}"\n') + curl.chmod(0o755) + result = subprocess.run( + ['bash', str(files('msg.data').joinpath('install.sh'))], + env={ + **os.environ, + 'HOME': str(home), + 'XDG_DATA_HOME': str(home / '.local/share'), + 'PATH': str(fake) + os.pathsep + os.environ['PATH'], + }, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode != 0 and 'uv checksum mismatch' in result.stderr + assert launcher.readlink() == old + assert identity.read_text() == 'identity marker' + assert not (release_root / '.install-lock').exists() + assert not (release_root / '5554f93819478f4559aa226d1d94cfc6202a1cee').exists()