diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1912945..f25ac099 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,3 +54,6 @@ jobs: - name: Build release CLI run: nix develop -c cargo build -p flutterdec-cli --release + + - name: Release layout smoke + run: nix develop -c ./scripts/release-layout-smoke.sh target/release/flutterdec diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a59d2f6..ab65e855 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,6 +69,11 @@ jobs: fi "$bin" --version + # The archive carries a prefix, not a lone binary. Runtime discovery is + # executable-relative: the CLI reads its compatibility registry, runtime + # profiles and packaged producer from `/../share/flutterdec`, so + # an archive holding only `flutterdec` extracts into a CLI that cannot + # resolve any of them without a source checkout. - name: Package artifact shell: bash run: | @@ -78,9 +83,15 @@ jobs: out_dir="dist" mkdir -p "$out_dir" archive="$out_dir/flutterdec-${{ github.ref_name }}-${os}-${arch}.tar.gz" - tar -C "target/${{ matrix.target }}/release" -czf "$archive" flutterdec + ./scripts/stage-release-prefix.sh \ + "target/${{ matrix.target }}/release/flutterdec" stage + tar -C stage -czf "$archive" bin share echo "archive=$archive" >> "$GITHUB_ENV" + - name: Verify archive runs off a checkout + shell: bash + run: ./scripts/release-layout-smoke.sh "${{ env.archive }}" + - name: Upload artifact uses: actions/upload-artifact@v7 with: diff --git a/.gitignore b/.gitignore index dab86ebd..c178132d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,5 @@ /out /.venv *.log -/adapters/installed/* -!/adapters/installed/.gitkeep __pycache__/ *.pyc diff --git a/Cargo.lock b/Cargo.lock index c8a7180b..e84e0dad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -326,6 +326,7 @@ version = "0.1.0-alpha.4" dependencies = [ "anyhow", "flutterdec-loader", + "libc", "serde", "serde_json", "sha2", @@ -340,7 +341,11 @@ dependencies = [ "clap", "flutterdec-adapter", "flutterdec-core", + "flutterdec-loader", + "libc", "serde_json", + "sha2", + "tempfile", ] [[package]] @@ -401,6 +406,7 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2", "tempfile", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index 18ada707..add61a20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ regex = "1" goblin = "0.10" zip = { version = "8", default-features = false, features = ["deflate"] } tempfile = "3" +libc = "0.2" sha2 = "0.10" walkdir = "2" capstone = "0.14" diff --git a/README.md b/README.md index 41c9d4e9..8e35a8cf 100644 --- a/README.md +++ b/README.md @@ -81,12 +81,18 @@ nix profile upgrade flutterdec Current prerelease: [`v0.1.0-alpha.4`](https://github.com/caverav/flutterdec/releases/tag/v0.1.0-alpha.4) +The archive is a prefix, not a lone binary: it holds `bin/flutterdec` plus the compatibility +registry, the runtime profiles and the packaged producer under `share/flutterdec`. The CLI finds +that data relative to its own executable, so copy `bin` and `share` to the same prefix and keep +them together. A binary moved out on its own reports which directories it looked in and stops. + Linux x64: ```bash curl -fLO https://github.com/caverav/flutterdec/releases/download/v0.1.0-alpha.4/flutterdec-v0.1.0-alpha.4-Linux-X64.tar.gz -tar -xzf flutterdec-v0.1.0-alpha.4-Linux-X64.tar.gz -sudo install -m 0755 flutterdec /usr/local/bin/flutterdec +mkdir -p flutterdec-v0.1.0-alpha.4 +tar -xzf flutterdec-v0.1.0-alpha.4-Linux-X64.tar.gz -C flutterdec-v0.1.0-alpha.4 +sudo cp -R flutterdec-v0.1.0-alpha.4/bin flutterdec-v0.1.0-alpha.4/share /usr/local/ flutterdec --help ``` @@ -94,8 +100,9 @@ macOS arm64: ```bash curl -fLO https://github.com/caverav/flutterdec/releases/download/v0.1.0-alpha.4/flutterdec-v0.1.0-alpha.4-macOS-ARM64.tar.gz -tar -xzf flutterdec-v0.1.0-alpha.4-macOS-ARM64.tar.gz -sudo install -m 0755 flutterdec /usr/local/bin/flutterdec +mkdir -p flutterdec-v0.1.0-alpha.4 +tar -xzf flutterdec-v0.1.0-alpha.4-macOS-ARM64.tar.gz -C flutterdec-v0.1.0-alpha.4 +sudo cp -R flutterdec-v0.1.0-alpha.4/bin flutterdec-v0.1.0-alpha.4/share /usr/local/ flutterdec --help ``` @@ -137,13 +144,36 @@ If this is your first run, this is the shortest useful path. flutterdec info ./sample.apk --json ``` -`info` resolves the Dart SDK version straight from the snapshot hash, with no adapter -installed and no disassembly: +`info` reads the snapshot identity out of the header with no adapter installed and no +disassembly: `snapshot_hash`, `arch`, `snapshot_features`, `compressed_pointers`, and +`registry_record_present`. + +The Dart profile fields come from the host registry record that the identity matched, +and appear only once that record's adapter is installed, because the profile is +SHA-256 verified as part of authorizing the run: -- `dart_version` (for example `3.9.2`) +- `dart_aliases`: zero or more SDK labels, each with its own `ecosystem`, `version`, + and `provenance`. They are provenance only and never select a parser or a profile. +- `dart_version`: a display value, never an exact SDK claim. It is `unverified` when + the record carries aliases and `unavailable` when it carries none. - `dart_tag_style` (`CID_INT32`, `CID_SHIFT1`, or `OBJECT_HEADER`) -Both are `null` for snapshot hashes not in the bundled table (`data/dart-profiles.json`). +```json +"dart_aliases": [ + {"ecosystem": "dart", "version": "3.5.0", "provenance": "r2flutter-offsets"}, + {"ecosystem": "flutter", "version": "3.24.0", "provenance": "sample-build-metadata"} +], +"dart_version": "unverified", +"dart_tag_style": "OBJECT_HEADER" +``` + +All three are `null` when no registry record matches the snapshot identity, and when a +matching record's adapter is not installed. + +The registry record is the only authority for SDK labels, so a snapshot with no record +reports no alias at all — including hashes that an earlier version labelled from the +bundled profile table. That is the accepted cost of keeping one authority: there is no +second, weaker path that names a version from a hash. For APK inputs, `info` reports Android startup summary fields such as: @@ -161,6 +191,10 @@ If adapter metadata is available, `info` also reports package and compatibility - `identity_rejection` (why no adapter could be selected, when none could) - `model_capabilities` - `compatibility_warnings` +- `provider` (one block: requested and resolved backend, whether an adapter was + executed at all and why not, host and target architectures, the producer and + its artifact digest, the parser family, profile and artifact the registry + named, and the containment the child reported) 2. Install the adapter for the detected Dart hash: @@ -216,6 +250,14 @@ flutterdec adapter install --dart-hash flutterdec adapter list ``` +Adapters install into a writable store outside any checkout: `FLUTTERDEC_ADAPTER_STORE` when set, +otherwise `$XDG_DATA_HOME/flutterdec/adapters` or `$HOME/.local/share/flutterdec/adapters`. The +compatibility registry and the runtime profiles are read-only package data, found next to the binary at +`share/flutterdec` (or wherever `FLUTTERDEC_DATA_DIR` points). Neither location depends on your current +directory, so a release binary behaves the same everywhere. `adapter list` reports `verified`, +`missing`, `corrupt`, `incompatible`, or `unavailable` per record and exits 2 if the store holds an +install it cannot back. See [docs/user-guide.md](docs/user-guide.md) for the full rules. + Decompile with the default app-focused scope: ```bash @@ -358,16 +400,26 @@ flutterdec decompile ./sample.apk -o ./out --analysis-profile light Adapter backend selection: -- `--adapter-backend auto` (default): try r2flutter, then Blutter, then fall back to the internal adapter -- `--adapter-backend internal`: force the internal adapter only +- `--adapter-backend auto` (default): try r2flutter, then Blutter, then the producer's internal path +- `--adapter-backend internal`: recover in core; select nothing, read no registry, execute nothing - `--adapter-backend blutter`: require the Blutter backend and fail if unavailable - `--adapter-backend r2-flutter`: require the r2flutter backend and fail if unavailable +- `--adapter-timeout `: bound one adapter invocation - `--require-snapshot-hash-match`: fail unless the snapshot identity came from a real header +A snapshot nothing is authorized to parse does not end the run. Core recovers +ARM64 code candidates from the instruction bytes, marks every one of them +heuristic and unnamed, and leaves libraries, classes, function names, the +original entry function and the ObjectPool unavailable with a diagnostic each. +`core_fallback_reason` says which condition it was. A pinned external backend is +refused by name instead, and an adapter that was authorized, ran, and failed is +still a failure. See [Core recovery](docs/cli-reference.md#core-recovery). + What the backends actually recover: | Backend | Function names | Classes | ObjectPool | | --- | --- | --- | --- | +| core recovery | none at all; code ranges are unnamed | none | unavailable | | `internal` | none at all; code ranges are unnamed | none | carved strings, ordinal index space | | `blutter` | scraped from Blutter's rendered source, heuristic | yes | Blutter `pp.txt` entries, ordinal index space | | `r2flutter` | exact, from the AOT instruction table | yes, library attribution unavailable | real slots, resolvable from `x27` displacements | @@ -579,7 +631,7 @@ Recover readable behavior from Flutter AOT ARM64 binaries with enough semantic s ## Third-Party Credits -- `data/dart-profiles.json`: Dart snapshot hash-to-version and layout table imported from +- `data/dart-profiles.json`: Dart AOT snapshot layout profiles imported from [radareorg/r2flutter](https://github.com/radareorg/r2flutter) (MIT). Rationale in [docs/research-decisions.md](docs/research-decisions.md). - `--adapter-backend r2-flutter` drives the same project as an external tool; it is not diff --git a/adapters/installed/.gitkeep b/adapters/installed/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/adapters/manifest.json b/adapters/manifest.json deleted file mode 100644 index 0e2c39e5..00000000 --- a/adapters/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "entries": [ - { - "snapshot_hash": "ace654289f5abc240509fc941453ebc5", - "version": "unknown", - "adapter": "dart_adapter_ace654289f5abc240509fc941453ebc5" - }, - { - "snapshot_hash": "80a49c7111088100a233b2ae788e1f48", - "version": "unknown", - "adapter": "dart_adapter_80a49c7111088100a233b2ae788e1f48" - } - ] -} \ No newline at end of file diff --git a/adapters/python/adapter_template.py b/adapters/python/adapter_template.py index fb63b1ef..3bf846e3 100644 --- a/adapters/python/adapter_template.py +++ b/adapters/python/adapter_template.py @@ -55,10 +55,11 @@ DERIVED = "derived" HEURISTIC = "heuristic" -# Mirrors `validate::PLACEHOLDER_NAMES`. A carved string that is one of these is -# an admission of ignorance wearing a value's clothes, and the host rejects the -# whole model over one of them, so they are filtered at the source. -PLACEHOLDER_NAMES = frozenset( +# Mirrors `validate::CASE_INSENSITIVE_PLACEHOLDERS` and `EXACT_LOWERCASE_PLACEHOLDERS`. +# A carved string that is one of these is an admission of ignorance wearing a +# value's clothes, and the host rejects the whole model over one of them, so +# they are filtered at the source. +CASE_INSENSITIVE_PLACEHOLDERS = frozenset( [ "", "-", @@ -67,9 +68,6 @@ "???", "n/a", "na", - "none", - "null", - "nil", "todo", "tbd", "unknown", @@ -81,6 +79,8 @@ ] ) +EXACT_LOWERCASE_PLACEHOLDERS = frozenset(["none", "null", "nil"]) + class BackendUnavailable(Exception): """The backend's tooling is not installed. Distinct from it failing.""" @@ -91,8 +91,8 @@ class BackendFailed(Exception): def _is_placeholder(text: str) -> bool: - return text.strip().lower() in PLACEHOLDER_NAMES - + t = text.strip() + return t in EXACT_LOWERCASE_PLACEHOLDERS or t.lower() in CASE_INSENSITIVE_PLACEHOLDERS def _usable_value(text: str) -> bool: return bool(text) and not _is_placeholder(text) @@ -761,7 +761,12 @@ def _run_blutter_dump(input_path: Optional[str], libapp_path: Optional[str]) -> raise BackendFailed("custom blutter backend needs --input-path") cmd = runner + [input_path, str(out_dir)] - lock_dir = Path.home() / ".cache" / "flutterdec" + # The lock has to live beside the cache it protects. The adapter host gives + # every invocation a private HOME, so a lock under it would be private too + # and would serialize nothing; XDG_CACHE_HOME is the variable the blutter + # wrapper itself keys its source cache on. + cache_root = os.getenv("XDG_CACHE_HOME", "").strip() + lock_dir = Path(cache_root) / "flutterdec" if cache_root else Path.home() / ".cache" / "flutterdec" lock_dir.mkdir(parents=True, exist_ok=True) lock_file = lock_dir / "blutter-run.lock" with lock_file.open("w") as lock_fp: diff --git a/adapters/registry.json b/adapters/registry.json new file mode 100644 index 00000000..95787d16 --- /dev/null +++ b/adapters/registry.json @@ -0,0 +1,182 @@ +{ + "version": 1, + "records": [ + { + "snapshot_hash": "80a49c7111088100a233b2ae788e1f48", + "snapshot_kind": "full_aot", + "target_arch": "arm64", + "features": [ + "android", + "arm64", + "compressed-pointers", + "no-code_comments", + "product" + ], + "feature_fingerprint": "cd3b1581739bbd05f45b1be681daa14bd4b9ad241f750325347095b98b6b7709", + "known_features": [ + "android", + "arm64", + "compressed-pointers", + "no-code_comments", + "product", + "no-compressed-pointers", + "x64", + "arm", + "ia32", + "riscv32", + "riscv64" + ], + "forbidden_features": [ + "no-compressed-pointers" + ], + "sdk_aliases": [ + { + "ecosystem": "dart", + "version": "3.5.0", + "provenance": "r2flutter-offsets" + }, + { + "ecosystem": "flutter", + "version": "3.24.0", + "provenance": "sample-build-metadata" + } + ], + "parser_family": { + "id": "flutterdec-local-python", + "version": "1", + "sha256": null + }, + "profile": { + "id": "3.5.0", + "path": "data/dart-profiles.json", + "sha256": "a1ce86ebf936c3e780413090974b753520c1c8a9f1854d3a0367158d48f77e0f" + }, + "artifact": { + "id": "flutterdec-local-python", + "variants": [ + { + "host_os": "linux", + "host_arch": "x86_64", + "path": "artifacts/flutterdec-local-python", + "size": 44979, + "sha256": "efb78b09100ca1f378436168e32373e22102c389f62f219a3079a1a845350bd3", + "provenance": "checked-in reference producer, published by adapter install" + }, + { + "host_os": "macos", + "host_arch": "x86_64", + "path": "artifacts/flutterdec-local-python", + "size": 44979, + "sha256": "efb78b09100ca1f378436168e32373e22102c389f62f219a3079a1a845350bd3", + "provenance": "checked-in reference producer, published by adapter install" + }, + { + "host_os": "macos", + "host_arch": "aarch64", + "path": "artifacts/flutterdec-local-python", + "size": 44979, + "sha256": "efb78b09100ca1f378436168e32373e22102c389f62f219a3079a1a845350bd3", + "provenance": "checked-in reference producer, published by adapter install" + } + ] + }, + "evidence": { + "source": "data/dart-profiles.json", + "provenance": "checked-in-profile-and-fixture", + "references": [ + "LocalSend 1.17.0 sample", + "r2flutter offsets.json" + ] + }, + "trust_tier": "verified", + "protocol_major": 1, + "model_major": 4 + }, + { + "snapshot_hash": "ace654289f5abc240509fc941453ebc5", + "snapshot_kind": "full_aot", + "target_arch": "arm64", + "features": [ + "android", + "arm64", + "compressed-pointers", + "no-code_comments", + "product" + ], + "feature_fingerprint": "cd3b1581739bbd05f45b1be681daa14bd4b9ad241f750325347095b98b6b7709", + "known_features": [ + "android", + "arm64", + "compressed-pointers", + "no-code_comments", + "product", + "no-compressed-pointers", + "x64", + "arm", + "ia32", + "riscv32", + "riscv64" + ], + "forbidden_features": [ + "no-compressed-pointers" + ], + "sdk_aliases": [ + { + "ecosystem": "dart", + "version": "3.12.1", + "provenance": "r2flutter-offsets" + } + ], + "parser_family": { + "id": "flutterdec-local-python", + "version": "1", + "sha256": null + }, + "profile": { + "id": "3.9.0", + "path": "data/dart-profiles.json", + "sha256": "a1ce86ebf936c3e780413090974b753520c1c8a9f1854d3a0367158d48f77e0f" + }, + "artifact": { + "id": "flutterdec-local-python", + "variants": [ + { + "host_os": "linux", + "host_arch": "x86_64", + "path": "artifacts/flutterdec-local-python", + "size": 44979, + "sha256": "efb78b09100ca1f378436168e32373e22102c389f62f219a3079a1a845350bd3", + "provenance": "checked-in reference producer, published by adapter install" + }, + { + "host_os": "macos", + "host_arch": "x86_64", + "path": "artifacts/flutterdec-local-python", + "size": 44979, + "sha256": "efb78b09100ca1f378436168e32373e22102c389f62f219a3079a1a845350bd3", + "provenance": "checked-in reference producer, published by adapter install" + }, + { + "host_os": "macos", + "host_arch": "aarch64", + "path": "artifacts/flutterdec-local-python", + "size": 44979, + "sha256": "efb78b09100ca1f378436168e32373e22102c389f62f219a3079a1a845350bd3", + "provenance": "checked-in reference producer, published by adapter install" + } + ] + }, + "evidence": { + "source": "data/dart-profiles.json", + "provenance": "checked-in-profile-and-fixture", + "references": [ + "Immich 3.1.0 sample", + "r2flutter offsets.json" + ] + }, + "trust_tier": "verified", + "protocol_major": 1, + "model_major": 4 + } + ] +} diff --git a/context.md b/context.md index cfe6dbc1..831fdce2 100644 --- a/context.md +++ b/context.md @@ -212,9 +212,12 @@ Current scope: - artifact file names cap the sanitized function-name stem at 160 bytes; recovered Dart names reach 305 bytes on real binaries and previously aborted the whole run with `File name too long (os error 36)` as soon as an adapter returned real names -- `info` and `report.json` now name the Dart SDK version and object-header tag style - straight from the snapshot hash, with no adapter installed, using the vendored - `data/dart-profiles.json` (61 hashes, 19 layout profiles, imported from r2flutter, MIT) +- `info` and `report.json` report the object-header tag style and snapshot layout from + the vendored `data/dart-profiles.json` (19 layout profiles keyed by profile id, no + snapshot hashes, imported from r2flutter, MIT), which a host registry record selects + by profile id and pins by SHA-256; SDK labels come from that record's `sdk_aliases` + as provenance only, so `dart_version` is `unverified`/`unavailable` rather than an + exact version, and a snapshot with no record reports no alias - decompile can now ingest `map-symbols` target JSON directly to inject mapped call names into pseudocode - external symbol names are normalized (including C++ demangle and runtime/native prefixes) before pseudocode emission - pseudocode call sites now include semantic intent comments for recognized stdlib/runtime/native targets diff --git a/crates/flutterdec-adapter/Cargo.toml b/crates/flutterdec-adapter/Cargo.toml index 8632d738..2926427d 100644 --- a/crates/flutterdec-adapter/Cargo.toml +++ b/crates/flutterdec-adapter/Cargo.toml @@ -10,4 +10,5 @@ serde.workspace = true serde_json.workspace = true tempfile.workspace = true sha2.workspace = true +libc.workspace = true flutterdec-loader = { path = "../flutterdec-loader" } diff --git a/crates/flutterdec-adapter/src/host.rs b/crates/flutterdec-adapter/src/host.rs new file mode 100644 index 00000000..9e26b18e --- /dev/null +++ b/crates/flutterdec-adapter/src/host.rs @@ -0,0 +1,1283 @@ +//! Bounded one-shot adapter execution. +//! +//! One adapter run is one process, started once, with everything decided before +//! it exists. The order is the whole design: every integrity and compatibility +//! check runs *before* a child is created, so a mismatch is a refusal with no +//! side effects rather than a process that has to be caught afterwards. Once the +//! child does exist it is contained by construction, not by convention: a +//! private workspace it cannot see out of, an allowlisted environment, an +//! overall deadline, bounded output, and a process group the host can terminate +//! whole. +//! +//! The bytes that run are the bytes that were checked, and no pathname is +//! involved in making that true. The store artifact is read once, digested from +//! that buffer, and turned into an executable inode the host holds open; every +//! pathname to that inode is gone before anything else happens, and the child is +//! created from the descriptor with `execveat`. So there is nothing left to +//! race: not the owner-writable store path, which is never opened again, and not +//! a workspace copy, because none exists to find, `chmod`, rename, or overwrite. +//! +//! The registry record is the authority throughout. The host re-derives the +//! record digest, the profile digest, the artifact digest, the host variant, the +//! target and feature tuple, and the protocol and model majors from the record +//! and refuses if any of them disagrees with what the caller believes. An +//! adapter never contributes to a decision about whether it may run. +//! +//! Every refusal is a [`HostError`] variant, so a caller can act on which check +//! stopped the run. Messages carry bounded, escaped excerpts of child output and +//! never the host environment, because a diagnostic that pastes an unbounded +//! child's stderr into a log is an output channel the adapter controls. + +use crate::model::{ + CompatibilityBinding, InputRegion, InputRegionName, Producer, ProducerTrust, ProgramModel, + MODEL_VERSION, +}; +use crate::primitives::{RelativePath, Sha256Digest}; +use crate::protocol::{ + self, AdapterRequest, AdapterResult, AdapterStatus, BackendId, RequestedBackend, PROTOCOL_MAJOR, +}; +use crate::sandbox::{ContainmentReport, Limits}; +use crate::validate::{self, HostSelectedContext}; +use flutterdec_loader::identity::{IdentityRejection, SnapshotIdentity}; +use flutterdec_loader::registry::{ + canonical_feature_fingerprint, CompatibilityRecord, HostArtifactVariant, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::ffi::{CString, OsStr, OsString}; +use std::fmt; +use std::fs; +use std::io::Read; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +mod exec; +mod image; +use exec::Completion; +use image::{argument, ExecImage}; + +/// The scratch layout one invocation sees. Every one of these is relative to the +/// working directory, and the working directory is private to the invocation. +const INPUT_DIR: &str = "in"; +const OUTPUT_DIR: &str = "out"; +const HOME_DIR: &str = "home"; +const TEMP_DIR: &str = "tmp"; +const ARTIFACT_DIR: &str = "artifact"; +/// Used as `argv[0]` when the store path has no usable final component. +const EXEC_FALLBACK_NAME: &str = "adapter"; +/// What `Command` is told to run, which is never reached: the last pre-exec hook +/// executes the held image descriptor and does not return. It is a path that +/// cannot exist so that a hook that somehow did not run fails loudly instead of +/// starting something. +const UNREACHABLE_PROGRAM: &str = "/nonexistent/flutterdec-adapter-image-descriptor"; +const OUTPUT_MODEL_PATH: &str = "out/model.json"; +const REQUEST_PATH: &str = "request.json"; +const RESULT_PATH: &str = "result.json"; + +/// Test hook: rendezvous with another process after the verified bytes are held +/// as a nameless executable descriptor and before the child is created. +/// +/// Proving that replacing the store artifact cannot change which bytes execute +/// requires the replacement to land inside that window, and the window is a few +/// microseconds wide. Polling for it is a timing gamble; this makes it a +/// synchronization point. The host creates `/ready` and blocks until +/// `/go` appears or the wait times out. +/// +/// It can only delay a run. Nothing here reads, re-reads, or re-resolves the +/// artifact, so an operator who sets it cannot change what executes; they can +/// only stall their own process. +pub const PRESPAWN_RENDEZVOUS_VAR: &str = "FLUTTERDEC_ADAPTER_PRESPAWN_RENDEZVOUS"; + +/// How long the rendezvous waits before giving up as a pre-spawn failure. +const RENDEZVOUS_TIMEOUT: Duration = Duration::from_secs(60); + +/// The host variables an adapter may see. +/// +/// Everything else is dropped. These are here because the checked-in producer +/// reads them to find an external backend, and because a `PATH` is what makes an +/// interpreter shebang resolvable. `HOME`, `TMPDIR` and `PWD` are not on this +/// list: they are *set* to directories inside the private workspace, so an +/// adapter that writes to either of them writes somewhere that is cleaned up. +/// +/// `XDG_CACHE_HOME` is the one exception to "everything the adapter writes is +/// thrown away", and it is here on purpose. An external backend that compiles +/// itself on first use rebuilds on every invocation if its cache is private, +/// which is the difference between a bridge that works and one that nobody +/// uses. Passing the variable through means the operator decides: unset, the +/// backend caches inside the private workspace and the run leaves nothing +/// behind; set, it caches where the operator already keeps caches. +const ENVIRONMENT_ALLOWLIST: &[&str] = &[ + "PATH", + "LANG", + "LC_ALL", + "PYTHON", + "XDG_CACHE_HOME", + "FLUTTERDEC_BLUTTER_CMD", + "FLUTTERDEC_BLUTTER_PY", + "FLUTTERDEC_R2FLUTTER_CMD", + "FLUTTERDEC_R2FLUTTER_BIN", + "FLUTTERDEC_R2FLUTTER_TIMEOUT", +]; + +/// How much child output a diagnostic may quote. +const EXCERPT_BYTES: usize = 2000; + +/// Which output stream breached its cap. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OutputStream { + Stdout, + Stderr, +} + +impl fmt::Display for OutputStream { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Stdout => "stdout", + Self::Stderr => "stderr", + }) + } +} + +/// Why one adapter invocation was refused or did not produce a usable model. +/// +/// The split matters more than the count. Everything from +/// [`Self::IdentityRejected`] through [`Self::ImageNotSealed`] is a +/// pre-spawn refusal: no process was created and nothing outside the host +/// happened. Everything after it describes a child that ran. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostError { + /// The snapshot identity may not authorize any adapter at all. + IdentityRejected(IdentityRejection), + /// The compatibility record does not satisfy its own invariants. + RecordInvalid(String), + /// The record the caller acted on is not the record it says it is. + RecordDigestMismatch { + expected: String, + actual: String, + }, + /// The record was written for a different protocol or model major. + UnsupportedMajors { + record_protocol: u32, + record_model: u32, + }, + /// The record does not describe this snapshot. + IdentityRecordMismatch { + record: String, + identity: String, + }, + /// The record was written for a different target architecture. + TargetMismatch { + record: String, + identity: String, + }, + /// The snapshot's feature tuple is not the one the record was written for. + FeatureMismatch { + record_fingerprint: String, + identity_fingerprint: String, + }, + /// The selected artifact variant is not for this host. + HostVariantMismatch { + variant_os: String, + variant_arch: String, + host_os: String, + host_arch: String, + }, + /// The variant handed in is not one the record declares. + VariantNotInRecord { + artifact_id: String, + }, + /// The executable is not where the record says, or is outside the store. + ArtifactPathRejected(String), + /// The path resolves to something that is not a regular executable file. + ArtifactNotExecutable(String), + /// The bytes about to be executed are not the bytes the registry authorized. + ArtifactDigestMismatch { + expected: String, + actual: String, + expected_size: u64, + actual_size: u64, + }, + /// The store ledger holds no installation of this record for this host. + /// + /// Distinct from [`Self::ArtifactPathRejected`] and + /// [`Self::ArtifactDigestMismatch`], which are already satisfied when this + /// fires: the file is where the record says it is and hashes to what the + /// record declares, and it still was not installed for *this* record. Two + /// records can name one artifact path with one digest, so the file cannot + /// answer which record an install was for. The ledger can, and it is the + /// ledger `adapter list` reports from. + NotInstalled { + state: crate::store::EntryState, + detail: String, + }, + /// The runtime profile does not match the digest the record pinned. + ProfileRejected(String), + /// The producer record the caller built does not follow from the registry + /// record. + ProducerMismatch(String), + /// The compatibility binding the caller built does not follow from the + /// registry record. + BindingMismatch(String), + /// A snapshot region is empty, oversized, or not one of the four. + InputRejected(String), + /// The request the host itself would refuse to answer. + RequestRejected(String), + /// The output handle is not a usable place to write a model. + OutputHandleRejected(String), + /// The verified bytes could not be held as an executable image whose + /// contents are provably immutable, so nothing was executed. + /// + /// Reachable on every platform, each for its own reason. Where anonymous + /// files exist the host could not create one, write all of it, seal it, or + /// read the whole seal set back off the descriptor it was about to execute. + /// Where they do not, the host could not create and freeze the image's + /// pathname — or found, in the child and immediately before `execve`, that + /// the pathname no longer resolved to the descriptor it verified, or was no + /// longer frozen. The alternative to this refusal is executing bytes nobody + /// checked. + ImageNotSealed(String), + /// The private workspace could not be built or torn down. + Workspace(String), + /// The child could not be created. + Spawn(String), + /// The child was still running at the deadline and its tree was killed. + Timeout { + after: Duration, + }, + /// The child produced more than its cap on one stream and its tree was + /// killed. + OutputLimitExceeded { + stream: OutputStream, + limit: u64, + }, + /// The child died on a signal. + Crashed { + signal: i32, + stderr: String, + }, + /// The child exited nonzero without leaving a result document. + NoResult { + status: String, + stdout: String, + stderr: String, + }, + /// A document the child wrote is larger than its cap. + DocumentTooLarge { + document: String, + size: u64, + limit: u64, + }, + /// A document the child wrote is not protocol v1 or not model v4. + MalformedDocument { + document: String, + detail: String, + }, + /// The result does not answer the request that was asked. + ResultMismatch(String), + /// The adapter answered with a model at a path other than the one it was + /// given. Distinct from [`Self::OutputHandleRejected`], which is a refusal + /// before anything ran. + ModelPathMismatch { + wrote: String, + requested: String, + }, + /// The adapter answered, and the answer is a failure. + AdapterFailed { + status: AdapterStatus, + code: protocol::AdapterErrorCode, + message: String, + }, + /// The model contradicts a fact the host established before the run. + ModelRejected(String), + /// The child exists but never reported which containment controls it + /// established, so the host cannot describe the run it just performed. + ContainmentUnreported, + Io(String), +} + +impl HostError { + /// Whether this refusal happened before any child process existed. + /// + /// The distinction is the contract: a pre-spawn refusal guarantees zero side + /// effects outside the host, and callers and tests assert on exactly that. + pub fn is_pre_spawn(&self) -> bool { + matches!( + self, + Self::IdentityRejected(_) + | Self::RecordInvalid(_) + | Self::RecordDigestMismatch { .. } + | Self::UnsupportedMajors { .. } + | Self::IdentityRecordMismatch { .. } + | Self::TargetMismatch { .. } + | Self::FeatureMismatch { .. } + | Self::HostVariantMismatch { .. } + | Self::VariantNotInRecord { .. } + | Self::ArtifactPathRejected(_) + | Self::ArtifactNotExecutable(_) + | Self::ArtifactDigestMismatch { .. } + | Self::NotInstalled { .. } + | Self::ProfileRejected(_) + | Self::ProducerMismatch(_) + | Self::BindingMismatch(_) + | Self::InputRejected(_) + | Self::RequestRejected(_) + | Self::OutputHandleRejected(_) + | Self::ImageNotSealed(_) + ) + } +} + +impl fmt::Display for HostError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::IdentityRejected(rejection) => write!(f, "{rejection}"), + Self::RecordInvalid(detail) => { + write!(f, "compatibility record is not usable: {detail}") + } + Self::RecordDigestMismatch { expected, actual } => write!( + f, + "compatibility record digest mismatch: the run was authorized under {expected} and the record hashes to {actual}" + ), + Self::UnsupportedMajors { + record_protocol, + record_model, + } => write!( + f, + "compatibility record declares protocol/model majors {record_protocol}/{record_model}; this host implements {PROTOCOL_MAJOR}/{MODEL_VERSION}" + ), + Self::IdentityRecordMismatch { record, identity } => write!( + f, + "compatibility record is for snapshot {record} and this snapshot is {identity}" + ), + Self::TargetMismatch { record, identity } => write!( + f, + "compatibility record targets {record} and this snapshot targets {identity}" + ), + Self::FeatureMismatch { + record_fingerprint, + identity_fingerprint, + } => write!( + f, + "compatibility record was written for feature fingerprint {record_fingerprint} and this snapshot fingerprints to {identity_fingerprint}" + ), + Self::HostVariantMismatch { + variant_os, + variant_arch, + host_os, + host_arch, + } => write!( + f, + "artifact variant is for host {variant_os}/{variant_arch} and this host is {host_os}/{host_arch}" + ), + Self::VariantNotInRecord { artifact_id } => write!( + f, + "the selected host variant is not declared by artifact {artifact_id}" + ), + Self::ArtifactPathRejected(detail) => { + write!(f, "adapter executable path rejected: {detail}") + } + Self::ArtifactNotExecutable(detail) => { + write!(f, "adapter executable rejected: {detail}") + } + Self::ArtifactDigestMismatch { + expected, + actual, + expected_size, + actual_size, + } => write!( + f, + "adapter artifact changed after registry verification: expected {expected_size} bytes with {expected}, got {actual_size} bytes with {actual}" + ), + Self::NotInstalled { state, detail } => write!( + f, + "the adapter store reports {state} for this compatibility record: {detail}" + ), + Self::ProfileRejected(detail) => write!(f, "runtime profile rejected: {detail}"), + Self::ProducerMismatch(detail) => write!(f, "producer record rejected: {detail}"), + Self::BindingMismatch(detail) => { + write!(f, "compatibility binding rejected: {detail}") + } + Self::InputRejected(detail) => write!(f, "snapshot region rejected: {detail}"), + Self::RequestRejected(detail) => write!(f, "adapter request is invalid: {detail}"), + Self::OutputHandleRejected(detail) => { + write!(f, "adapter output handle rejected: {detail}") + } + Self::ImageNotSealed(detail) => write!( + f, + "the adapter image could not be sealed, so nothing was run: {detail}" + ), + Self::Workspace(detail) => write!(f, "adapter workspace failed: {detail}"), + Self::Spawn(detail) => write!(f, "adapter could not be started: {detail}"), + Self::Timeout { after } => write!( + f, + "adapter exceeded its {:?} deadline; its process tree was terminated", + after + ), + Self::OutputLimitExceeded { stream, limit } => write!( + f, + "adapter wrote more than {limit} bytes to {stream}; its process tree was terminated" + ), + Self::Crashed { signal, stderr } => write!( + f, + "adapter died on signal {signal}\nstderr (bounded):\n{stderr}" + ), + Self::NoResult { + status, + stdout, + stderr, + } => write!( + f, + "adapter failed with status {status} and wrote no result document\nstdout (bounded):\n{stdout}\nstderr (bounded):\n{stderr}" + ), + Self::DocumentTooLarge { + document, + size, + limit, + } => write!( + f, + "adapter {document} is {size} bytes and the limit is {limit}" + ), + Self::MalformedDocument { document, detail } => { + write!(f, "adapter {document} rejected: {detail}") + } + Self::ResultMismatch(detail) => { + write!(f, "adapter result does not answer the request: {detail}") + } + Self::ModelPathMismatch { wrote, requested } => write!( + f, + "adapter wrote its model to {wrote:?} instead of the requested {requested:?}" + ), + Self::AdapterFailed { + status, + code, + message, + } => write!( + f, + "adapter reported {status:?} ({code:?}): {message}" + ), + Self::ModelRejected(detail) => { + write!(f, "adapter model failed semantic validation: {detail}") + } + Self::ContainmentUnreported => f.write_str( + "the adapter child reported no containment record, so the host cannot state which controls were in force", + ), + Self::Io(detail) => write!(f, "adapter invocation I/O failed: {detail}"), + } + } +} + +impl std::error::Error for HostError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::IdentityRejected(rejection) => Some(rejection), + _ => None, + } + } +} + +/// One snapshot region, as the host read it. +#[derive(Debug, Clone, Copy)] +pub struct AdapterRegionInput<'a> { + pub region: InputRegionName, + pub bytes: &'a [u8], + /// Load address. Required for executable regions, forbidden for data ones; + /// [`AdapterRequest::validate`] rejects the other combinations. + pub virtual_address: Option, +} + +/// Where the shared object the adapter may want to re-read actually lives. +/// +/// An APK member is not a path. Passing `lib/arm64-v8a/libapp.so` to a backend +/// that opens files gives it a path relative to a directory it has never seen, +/// so the host writes the member into the private workspace and hands over a +/// real one. +#[derive(Debug, Clone, Copy)] +pub enum LibappSource<'a> { + /// A regular file on this host. + File(&'a Path), + /// A member of the container at `input_path`, by name and content. + Member { name: &'a str, bytes: &'a [u8] }, +} + +/// Everything the registry decided, as the host resolved it. +/// +/// This is what the pre-spawn gates check against. It is separate from +/// [`AdapterInput`] because it is the *authority*, and mixing authority with +/// operator choices in one struct is how an operator choice ends up being +/// treated as authority. +#[derive(Debug, Clone, Copy)] +pub struct HostAuthorization<'a> { + /// The record that authorized this run. + pub record: &'a CompatibilityRecord, + /// The host variant of `record.artifact` that was selected. + pub variant: &'a HostArtifactVariant, + /// The writable store root every adapter executable must stay inside. + pub store_root: &'a Path, + /// Where `record.profile.path` resolved to in the read-only package data. + pub profile_path: &'a Path, +} + +/// Everything the host hands one adapter invocation. +#[derive(Debug, Clone)] +pub struct AdapterInput<'a> { + /// Header-derived identity of the snapshot. Authoritative. + pub identity: &'a SnapshotIdentity, + /// What the registry authorized. Re-checked before anything is spawned. + pub authorization: HostAuthorization<'a>, + /// Who the host believes is about to run, including the digest of the + /// artifact it is about to execute. + pub producer: Producer, + /// The compatibility decision that authorized this run. + pub compatibility: CompatibilityBinding, + pub regions: Vec>, + /// The original artifact, for backends that re-read it themselves. + pub input_path: Option<&'a Path>, + pub libapp: Option>, + pub requested_backend: RequestedBackend, + /// What this invocation may consume. + pub limits: Limits, +} + +/// What one adapter invocation produced, with the facts about the run that the +/// core needs and must not re-derive from the model. +#[derive(Debug, Clone)] +pub struct AdapterRun { + pub model: ProgramModel, + /// The backend that actually ran, as the protocol reported it. + pub resolved_backend: BackendId, + pub fallback_reason: Option, + pub diagnostics: Vec, + /// Which containment controls were established for the child that ran. + pub containment: ContainmentReport, +} + +fn region_file_name(region: InputRegionName) -> &'static str { + match region { + InputRegionName::VmData => "in/vm_data.bin", + InputRegionName::IsolateData => "in/isolate_data.bin", + InputRegionName::VmInstructions => "in/vm_instructions.bin", + InputRegionName::IsolateInstructions => "in/isolate_instructions.bin", + } +} + +/// A bounded, printable rendering of child output. +/// +/// The tail rather than the head: an interpreter puts the thing that went wrong +/// last. Control characters are replaced so a child cannot rewrite a host log +/// line with an escape sequence. +pub(crate) fn excerpt(bytes: &[u8]) -> String { + let start = bytes.len().saturating_sub(EXCERPT_BYTES); + let mut out = String::new(); + if start > 0 { + out.push_str(&format!("[{start} earlier bytes omitted]\n")); + } + for character in String::from_utf8_lossy(&bytes[start..]).chars() { + match character { + '\n' | '\t' => out.push(character), + other if other.is_control() => out.push('\u{fffd}'), + other => out.push(other), + } + } + out +} + +fn digest_of(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +/// Read a file that must not be larger than `limit`. +/// +/// Metadata first so an enormous file is refused rather than read, and a capped +/// read after it so a file that grew between the two is still bounded. +fn read_bounded(path: &Path, document: &str, limit: u64) -> Result, HostError> { + let metadata = fs::metadata(path) + .map_err(|err| HostError::Io(format!("read {document} {}: {err}", path.display())))?; + if !metadata.is_file() { + return Err(HostError::MalformedDocument { + document: document.to_string(), + detail: format!("{} is not a regular file", path.display()), + }); + } + if metadata.len() > limit { + return Err(HostError::DocumentTooLarge { + document: document.to_string(), + size: metadata.len(), + limit, + }); + } + let file = fs::File::open(path) + .map_err(|err| HostError::Io(format!("open {document} {}: {err}", path.display())))?; + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(limit + 1) + .read_to_end(&mut bytes) + .map_err(|err| HostError::Io(format!("read {document} {}: {err}", path.display())))?; + if bytes.len() as u64 > limit { + return Err(HostError::DocumentTooLarge { + document: document.to_string(), + size: bytes.len() as u64, + limit, + }); + } + Ok(bytes) +} + +/// What the pre-spawn checks approved: the request, and the exact bytes they +/// verified. +/// +/// The bytes travel with the request on purpose. A digest proves something +/// about the file that was read; it proves nothing about the file that is later +/// opened by the same path. Carrying the verified bytes out of authorization is +/// what lets the caller execute *those* rather than whatever the store path +/// resolves to next. +struct Authorized { + request: AdapterRequest, + /// The digest-verified artifact, read exactly once. + artifact: Vec, +} + +/// Every integrity and compatibility check, in the order they can be decided. +/// +/// Returns the request that the checks approved together with the artifact +/// bytes they verified. Nothing here creates a process, writes outside a +/// caller-owned buffer, or consults the adapter. +fn authorize(input: &AdapterInput<'_>, exec_path: &Path) -> Result { + // The identity gate first, so a snapshot that may not select an adapter + // never reaches a registry record, a digest, or the filesystem. + let key = input + .identity + .exact_selection_key() + .map_err(HostError::IdentityRejected)?; + + let authorization = &input.authorization; + let record = authorization.record; + record + .validate() + .map_err(|err| HostError::RecordInvalid(err.to_string()))?; + + let record_digest = record + .sha256() + .map_err(|err| HostError::RecordInvalid(err.to_string()))?; + if record_digest != input.compatibility.record_sha256.as_str() { + return Err(HostError::RecordDigestMismatch { + expected: input.compatibility.record_sha256.to_string(), + actual: record_digest, + }); + } + + if record.protocol_major != PROTOCOL_MAJOR || record.model_major != MODEL_VERSION { + return Err(HostError::UnsupportedMajors { + record_protocol: record.protocol_major, + record_model: record.model_major, + }); + } + + if record.snapshot_hash != key.hash { + return Err(HostError::IdentityRecordMismatch { + record: record.snapshot_hash.clone(), + identity: key.hash.clone(), + }); + } + if record.target_arch != key.target_arch { + return Err(HostError::TargetMismatch { + record: record.target_arch.as_str().to_string(), + identity: key.target_arch.as_str().to_string(), + }); + } + let identity_fingerprint = canonical_feature_fingerprint(&key.features); + if record.features != key.features || record.feature_fingerprint != identity_fingerprint { + return Err(HostError::FeatureMismatch { + record_fingerprint: record.feature_fingerprint.clone(), + identity_fingerprint, + }); + } + + // The host architecture is not the target architecture. A record can be + // right about the snapshot and still name an executable this machine cannot + // run. + let variant = authorization.variant; + if !record + .artifact + .variants + .iter() + .any(|declared| declared == variant) + { + return Err(HostError::VariantNotInRecord { + artifact_id: record.artifact.id.clone(), + }); + } + if variant.host_os != std::env::consts::OS || variant.host_arch != std::env::consts::ARCH { + return Err(HostError::HostVariantMismatch { + variant_os: variant.host_os.clone(), + variant_arch: variant.host_arch.clone(), + host_os: std::env::consts::OS.to_string(), + host_arch: std::env::consts::ARCH.to_string(), + }); + } + + authorize_artifact(exec_path, authorization.store_root, variant)?; + // This read is the only time the store path is opened. The bytes it returns + // are what gets digested, and the same buffer is what the caller writes into + // the private workspace and executes, so a writer with access to the store + // has nothing left to race against: replacing the file after this point + // changes a path nobody reads again. + let artifact_bytes = read_bounded( + exec_path, + "artifact", + flutterdec_loader::registry::MAX_ARTIFACT_BYTES, + )?; + let artifact_digest = digest_of(&artifact_bytes); + if artifact_digest != variant.sha256 || artifact_bytes.len() as u64 != variant.size { + return Err(HostError::ArtifactDigestMismatch { + expected: variant.sha256.clone(), + actual: artifact_digest, + expected_size: variant.size, + actual_size: artifact_bytes.len() as u64, + }); + } + // The digest proves these are the bytes the record declares. It cannot + // prove anything was installed *for this record*: two records can name one + // artifact path with one digest, and the shipped registry has exactly that + // pair, so a file that satisfies every check above may have been put there + // by an install for some other snapshot. The store ledger is what records + // which record an install was authorized under, and it is the same ledger + // `adapter list` reports from, so what an operator is shown and what may + // run cannot disagree. + if let Err((state, detail)) = crate::store::installed_for(authorization.store_root, record) { + return Err(HostError::NotInstalled { state, detail }); + } + // The producer record travels to the adapter and into the model, so a digest + // there that is not the digest of the file being executed would be a claim + // about a different artifact. + if input.producer.artifact_sha256.as_str() != artifact_digest { + return Err(HostError::ProducerMismatch(format!( + "producer names artifact {} and the executable hashes to {artifact_digest}", + input.producer.artifact_sha256 + ))); + } + if input.producer.id != record.parser_family.id { + return Err(HostError::ProducerMismatch(format!( + "producer id {:?} is not the record's parser family {:?}", + input.producer.id, record.parser_family.id + ))); + } + if input.producer.trust != ProducerTrust::Registered { + return Err(HostError::ProducerMismatch(format!( + "a registry-authorized run cannot carry producer trust {:?}", + input.producer.trust + ))); + } + + let profile_bytes = read_bounded( + authorization.profile_path, + "profile", + flutterdec_loader::dart_profile::MAX_PROFILE_BYTES, + ) + .map_err(|err| HostError::ProfileRejected(err.to_string()))?; + let profile_digest = digest_of(&profile_bytes); + if profile_digest != record.profile.sha256 { + return Err(HostError::ProfileRejected(format!( + "profile {} hashes to {profile_digest} and the record pins {}", + authorization.profile_path.display(), + record.profile.sha256 + ))); + } + + if input.compatibility.parser_family_id != record.parser_family.id + || input.compatibility.profile_id != record.profile.id + || input.compatibility.profile_sha256.as_str() != record.profile.sha256 + { + return Err(HostError::BindingMismatch(format!( + "binding names {}/{}/{} and the record names {}/{}/{}", + input.compatibility.parser_family_id, + input.compatibility.profile_id, + input.compatibility.profile_sha256, + record.parser_family.id, + record.profile.id, + record.profile.sha256 + ))); + } + + Ok(Authorized { + request: build_request(input)?, + artifact: artifact_bytes, + }) +} + +/// The executable must be the file the record named, inside the store, and +/// actually executable. +fn authorize_artifact( + exec_path: &Path, + store_root: &Path, + variant: &HostArtifactVariant, +) -> Result<(), HostError> { + let root = store_root.canonicalize().map_err(|err| { + HostError::ArtifactPathRejected(format!( + "adapter store root {} is unavailable: {err}", + store_root.display() + )) + })?; + let resolved = exec_path.canonicalize().map_err(|err| { + HostError::ArtifactPathRejected(format!( + "adapter executable {} is unavailable: {err}", + exec_path.display() + )) + })?; + if !resolved.starts_with(&root) { + return Err(HostError::ArtifactPathRejected(format!( + "adapter executable {} is outside the adapter store {}", + resolved.display(), + root.display() + ))); + } + // Containment alone is not enough: any file inside the store is contained, + // and only one of them is the artifact this record authorized. + let declared = root.join(&variant.path).canonicalize().map_err(|err| { + HostError::ArtifactPathRejected(format!( + "artifact {} declared by the record is unavailable: {err}", + variant.path + )) + })?; + if declared != resolved { + return Err(HostError::ArtifactPathRejected(format!( + "the record authorizes {} and the caller resolved {}", + declared.display(), + resolved.display() + ))); + } + + let metadata = fs::symlink_metadata(&resolved).map_err(|err| { + HostError::ArtifactNotExecutable(format!("read {}: {err}", resolved.display())) + })?; + if !metadata.is_file() { + return Err(HostError::ArtifactNotExecutable(format!( + "{} is not a regular file", + resolved.display() + ))); + } + if metadata.permissions().mode() & 0o111 == 0 { + return Err(HostError::ArtifactNotExecutable(format!( + "{} has mode {:o} and no execute bit", + resolved.display(), + metadata.permissions().mode() & 0o7777 + ))); + } + Ok(()) +} + +/// The request, checked as hard as the host can check it before it is written. +fn build_request(input: &AdapterInput<'_>) -> Result { + let mut handles = Vec::with_capacity(input.regions.len()); + for region in &input.regions { + let size = region.bytes.len() as u64; + if size == 0 { + return Err(HostError::InputRejected(format!( + "region {} is empty", + region.region + ))); + } + if size > input.limits.max_region_bytes { + return Err(HostError::InputRejected(format!( + "region {} is {size} bytes and the limit is {}", + region.region, input.limits.max_region_bytes + ))); + } + handles.push(protocol::InputHandle { + region: region.region, + path: RelativePath::parse(region_file_name(region.region)) + .map_err(|err| HostError::InputRejected(err.to_string()))?, + size, + sha256: Sha256Digest::of(region.bytes), + virtual_address: region.virtual_address, + executable: region.region.is_executable(), + }); + } + handles.sort_by_key(|handle| handle.region); + + let output = RelativePath::parse(OUTPUT_MODEL_PATH) + .map_err(|err| HostError::OutputHandleRejected(err.to_string()))?; + if handles + .iter() + .any(|handle| handle.path.as_str() == output.as_str()) + { + return Err(HostError::OutputHandleRejected(format!( + "the output handle {} is also an input", + output.as_str() + ))); + } + + let request = AdapterRequest { + protocol_major: PROTOCOL_MAJOR, + model_major: MODEL_VERSION, + compatibility: input.compatibility.clone(), + producer: input.producer.clone(), + identity: input.identity.clone(), + requested_backend: input.requested_backend, + inputs: handles, + output, + }; + // A request the host itself would reject is not a request an adapter should + // get a chance to answer. + request + .validate() + .map_err(|err| HostError::RequestRejected(err.to_string()))?; + Ok(request) +} + +/// The private directory tree one invocation runs in. +/// +/// Cleanup is in `Drop` rather than at the end of the happy path, because the +/// paths that matter for cleanup are the ones that do not reach the end of the +/// happy path. +struct Workspace { + dir: Option, +} + +impl Workspace { + fn create() -> Result { + // `TempDir` creates with mode 0700 already; the sub-directories are made + // the same way rather than through the process umask. + let dir = tempfile::Builder::new() + .prefix("flutterdec-adapter-") + .tempdir() + .map_err(|err| HostError::Workspace(format!("create scratch directory: {err}")))?; + let workspace = Self { dir: Some(dir) }; + // `tempfile` creates through the process umask, which on a default host + // leaves the directory group and world readable. The invocation + // directory holds the snapshot the operator handed us, so it is set + // explicitly rather than left to whatever the umask happened to be. + fs::set_permissions(workspace.path(), fs::Permissions::from_mode(0o700)) + .map_err(|err| HostError::Workspace(format!("seal the invocation directory: {err}")))?; + for name in [INPUT_DIR, OUTPUT_DIR, HOME_DIR, TEMP_DIR, ARTIFACT_DIR] { + let path = workspace.path().join(name); + std::os::unix::fs::DirBuilderExt::mode(&mut fs::DirBuilder::new(), 0o700) + .create(&path) + .map_err(|err| HostError::Workspace(format!("create {}: {err}", path.display())))?; + } + Ok(workspace) + } + + fn path(&self) -> &Path { + self.dir + .as_ref() + .expect("workspace outlives its use") + .path() + } + + /// Write a file the adapter may read and must not change. + fn write_readonly(&self, relative: &str, bytes: &[u8]) -> Result { + let path = self.path().join(relative); + fs::write(&path, bytes) + .map_err(|err| HostError::Workspace(format!("write {}: {err}", path.display())))?; + fs::set_permissions(&path, fs::Permissions::from_mode(0o444)) + .map_err(|err| HostError::Workspace(format!("seal {}: {err}", path.display())))?; + Ok(path) + } +} + +/// The environment one invocation gets, as the `KEY=VALUE` entries `execve` +/// takes. +/// +/// An allowlist rather than a filter: `HOME`, `TMPDIR` and `PWD` point inside +/// the private workspace, and nothing else reaches the child unless it is named +/// in [`ENVIRONMENT_ALLOWLIST`]. +fn child_environment(work: &Path) -> Result, HostError> { + let mut entries = Vec::with_capacity(ENVIRONMENT_ALLOWLIST.len() + 3); + let mut push = |name: &str, value: &OsStr| -> Result<(), HostError> { + let mut entry = OsString::from(name); + entry.push("="); + entry.push(value); + entries.push(argument(&entry, "an environment entry")?); + Ok(()) + }; + push("HOME", work.join(HOME_DIR).as_os_str())?; + push("TMPDIR", work.join(TEMP_DIR).as_os_str())?; + push("PWD", work.as_os_str())?; + for name in ENVIRONMENT_ALLOWLIST { + if let Some(value) = std::env::var_os(name) { + push(name, &value)?; + } + } + Ok(entries) +} + +/// Block between holding the verified image and creating the child, when a test +/// asked for it. See [`PRESPAWN_RENDEZVOUS_VAR`]. +fn prespawn_rendezvous() -> Result<(), HostError> { + let Some(dir) = std::env::var_os(PRESPAWN_RENDEZVOUS_VAR).filter(|value| !value.is_empty()) + else { + return Ok(()); + }; + let dir = PathBuf::from(dir); + let ready = dir.join("ready"); + fs::write(&ready, b"ready") + .map_err(|err| HostError::Workspace(format!("signal {}: {err}", ready.display())))?; + let go = dir.join("go"); + let deadline = Instant::now() + RENDEZVOUS_TIMEOUT; + while !go.exists() { + if Instant::now() >= deadline { + return Err(HostError::Workspace(format!( + "the pre-spawn rendezvous at {} was never released", + dir.display() + ))); + } + std::thread::sleep(Duration::from_millis(2)); + } + Ok(()) +} + +/// Restore write permission everywhere before removal. +/// +/// A hostile adapter that leaves an unwritable directory behind would otherwise +/// defeat `remove_dir_all` and leak its own workspace onto the host. On the +/// platforms where the executable image is a frozen pathname rather than an +/// anonymous descriptor, the freeze is lifted here too: it is the host's own +/// flag, the run that needed it is over, and leaving it set would leak the +/// workspace for exactly the same reason. +fn force_writable(path: &Path) { + let Ok(metadata) = fs::symlink_metadata(path) else { + return; + }; + if metadata.file_type().is_symlink() { + return; + } + unfreeze(path); + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o700)); + if metadata.is_dir() { + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + force_writable(&entry.path()); + } + } + } +} + +/// Clear the immutability the image carried, where the platform has one. +#[cfg(not(target_os = "linux"))] +fn unfreeze(path: &Path) { + use std::os::unix::ffi::OsStrExt; + if let Ok(name) = std::ffi::CString::new(path.as_os_str().as_bytes()) { + unsafe { libc::chflags(name.as_ptr(), 0) }; + } +} + +#[cfg(target_os = "linux")] +fn unfreeze(_path: &Path) {} + +impl Drop for Workspace { + fn drop(&mut self) { + if let Some(dir) = self.dir.take() { + force_writable(dir.path()); + // `TempDir::drop` would swallow a failure here; doing it explicitly + // means the second attempt below is the one that reports nothing, + // not the only attempt. + let _ = fs::remove_dir_all(dir.path()); + drop(dir); + } + } +} + +/// Run one adapter and return a model that has already been checked against the +/// host's own view of the snapshot. +/// +/// Every gate runs before the child exists, the child runs inside a private +/// workspace under an explicit set of limits, and the model is validated against +/// host facts before it is handed back. +/// +/// `exec_path` names the artifact to authorize, not the file that is executed. +/// The bytes it holds are read and verified once, and what runs is a descriptor +/// onto those bytes with no pathname of its own, so neither the store path nor +/// anything inside the workspace can change what executes after verification. +pub fn run_adapter(exec_path: &Path, input: &AdapterInput<'_>) -> Result { + let Authorized { request, artifact } = authorize(input, exec_path)?; + + let workspace = Workspace::create()?; + let work = workspace.path().to_path_buf(); + + // Kept as `argv[0]`, so an interpreter, an `argv[0]` check and a diagnostic + // all still see the artifact the registry authorized. + let exec_name = exec_path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(EXEC_FALLBACK_NAME); + + let mut host_regions: Vec = Vec::with_capacity(input.regions.len()); + for region in &input.regions { + let handle = request + .input(region.region) + .expect("every region has a handle"); + workspace.write_readonly(handle.path.as_str(), region.bytes)?; + host_regions.push(InputRegion { + region: region.region, + size: handle.size, + sha256: handle.sha256.clone(), + virtual_address: region.virtual_address, + executable: region.region.is_executable(), + }); + } + host_regions.sort_by_key(|region| region.region); + workspace.write_readonly(REQUEST_PATH, &request.to_json())?; + + // `argv` and the environment are assembled here rather than on `Command`, + // because the hook that finally execs runs between `fork` and `exec` and can + // do nothing but hand the kernel vectors that already exist. + let mut argv = vec![ + argument(OsStr::new(exec_name), "the adapter name")?, + argument(OsStr::new("--request"), "an argument")?, + argument(OsStr::new(REQUEST_PATH), "an argument")?, + argument(OsStr::new("--result"), "an argument")?, + argument(OsStr::new(RESULT_PATH), "an argument")?, + ]; + if let Some(path) = input.input_path { + argv.push(argument(OsStr::new("--input-path"), "an argument")?); + argv.push(argument(absolute(path).as_os_str(), "the input path")?); + } + if let Some(source) = input.libapp { + let path = match source { + LibappSource::File(path) => absolute(path), + LibappSource::Member { name, bytes } => { + // A member name can be any depth, and only its final component + // is meaningful to a tool that opens it. + let file_name = Path::new(name) + .file_name() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("libapp.so")); + let relative = Path::new(ARTIFACT_DIR).join(file_name); + let relative = relative.to_string_lossy().into_owned(); + workspace.write_readonly(&relative, bytes)?; + work.join(relative) + } + }; + argv.push(argument(OsStr::new("--libapp-path"), "an argument")?); + argv.push(argument(path.as_os_str(), "the libapp path")?); + } + + // The verified bytes become an executable inode with no name, held open for + // the rest of the run. `Command` carries only what the standard library + // applies before a pre-exec hook: the working directory and the streams. + let image = ExecImage::prepare( + exec_name, + &artifact, + workspace.path(), + argv, + child_environment(&work)?, + )?; + let mut command = Command::new(UNREACHABLE_PROGRAM); + command + .current_dir(&work) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + prespawn_rendezvous()?; + let execution = exec::run(command, &input.limits, std::sync::Arc::new(image))?; + let containment = execution.containment; + match execution.completion { + Completion::Timeout { after } => return Err(HostError::Timeout { after }), + Completion::OutputLimit { stream, limit } => { + return Err(HostError::OutputLimitExceeded { stream, limit }) + } + Completion::Signalled { signal } => { + return Err(HostError::Crashed { + signal, + stderr: excerpt(&execution.stderr), + }) + } + Completion::Exited { .. } => {} + } + + let result_path = work.join(RESULT_PATH); + if !result_path.exists() { + return Err(HostError::NoResult { + status: match execution.completion { + Completion::Exited { code } => format!("exit code {code}"), + _ => "terminated".to_string(), + }, + stdout: excerpt(&execution.stdout), + stderr: excerpt(&execution.stderr), + }); + } + + let result_bytes = read_bounded(&result_path, "result", input.limits.max_result_bytes)?; + let result = + AdapterResult::from_json(&result_bytes).map_err(|err| HostError::MalformedDocument { + document: "result".to_string(), + detail: err.to_string(), + })?; + result + .validate_against(&request) + .map_err(|err| HostError::ResultMismatch(err.to_string()))?; + + if result.status != AdapterStatus::Ok { + let error = result + .error + .as_ref() + .expect("a non-ok result carries an error"); + return Err(HostError::AdapterFailed { + status: result.status, + code: error.code, + message: excerpt(error.message.as_bytes()), + }); + } + + let model_rel = result.model.as_ref().expect("an ok result carries a model"); + if model_rel.as_str() != request.output.as_str() { + return Err(HostError::ModelPathMismatch { + wrote: model_rel.as_str().to_string(), + requested: request.output.as_str().to_string(), + }); + } + let model_bytes = read_bounded( + &work.join(model_rel.as_str()), + "model", + input.limits.max_model_bytes, + )?; + let model = + ProgramModel::from_json(&model_bytes).map_err(|err| HostError::MalformedDocument { + document: "model".to_string(), + detail: err.to_string(), + })?; + + let host = HostSelectedContext { + identity: input.identity.clone(), + producer: input.producer.clone(), + // An adapter run is always authorized by a record, so this arm is always + // `Some` and a model answering with `null` is rejected as a host-fact + // mismatch. + compatibility: Some(input.compatibility.clone()), + regions: host_regions, + }; + validate::validate(&model, &host).map_err(|err| HostError::ModelRejected(err.to_string()))?; + + Ok(AdapterRun { + model, + resolved_backend: result + .resolved_backend + .expect("an ok result names its backend"), + fallback_reason: result.fallback_reason, + diagnostics: result.diagnostics, + containment, + }) +} + +/// An absolute path for a caller-supplied artifact. +/// +/// The adapter runs with its working directory set to the private workspace, so +/// a relative path handed straight through would resolve somewhere the caller +/// did not mean. +fn absolute(path: &Path) -> PathBuf { + if let Ok(canonical) = path.canonicalize() { + return canonical; + } + if path.is_absolute() { + return path.to_path_buf(); + } + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) +} diff --git a/crates/flutterdec-adapter/src/host/exec.rs b/crates/flutterdec-adapter/src/host/exec.rs new file mode 100644 index 00000000..94287781 --- /dev/null +++ b/crates/flutterdec-adapter/src/host/exec.rs @@ -0,0 +1,217 @@ +//! Starting, bounding, and reaping one adapter child. +//! +//! Three things have to be true at once and each of them can break the other +//! two. The host must not block forever on a child that never exits, must not +//! grow without bound on a child that never stops writing, and must not leave a +//! process behind when it gives up on either. So the output is drained by two +//! threads that never stop reading (a reader that stops reading is a child that +//! blocks on `write` and never notices it is being killed), the wait is a poll +//! against a deadline rather than a blocking `wait`, and every exit path ends +//! with one signal to the whole process group. +//! +//! The group signal is sent even when the child exited on its own. A child that +//! forked and abandoned a grandchild is the normal case for a backend that +//! shells out, and that grandchild holds the inherited pipe open: without the +//! sweep the drain threads would never see end of file and the host would hang +//! after a perfectly successful run. + +use super::image::ExecImage; +use super::{HostError, OutputStream}; +use crate::sandbox::{kill_tree, Containment, ContainmentReport, Limits}; +use std::io::Read; +use std::os::unix::process::{CommandExt, ExitStatusExt}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant}; + +/// How often the deadline loop looks at the child. +/// +/// Short enough that the overshoot past a deadline is noise next to any real +/// adapter, long enough that a slow adapter does not cost a busy loop. +const POLL_INTERVAL: Duration = Duration::from_millis(2); + +/// How one invocation ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Completion { + Exited { code: i32 }, + Signalled { signal: i32 }, + Timeout { after: Duration }, + OutputLimit { stream: OutputStream, limit: u64 }, +} + +pub(crate) struct Execution { + pub(crate) completion: Completion, + pub(crate) stdout: Vec, + pub(crate) stderr: Vec, + pub(crate) containment: ContainmentReport, +} + +/// Read a stream to end of file, keeping at most `limit` bytes. +/// +/// Reading continues past the limit and discards. That is the point: the caller +/// wants to *terminate* a flooding child, and it cannot do that if the child is +/// blocked writing into a pipe nobody is emptying. +fn drain( + mut source: R, + limit: u64, + seen: Arc, +) -> thread::JoinHandle> { + thread::spawn(move || { + let mut kept = Vec::new(); + let mut buffer = [0u8; 16 * 1024]; + loop { + let read = match source.read(&mut buffer) { + Ok(0) => break, + Ok(read) => read, + Err(ref err) if err.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + }; + let total = seen.fetch_add(read as u64, Ordering::Relaxed) + read as u64; + let room = limit.saturating_sub(total.saturating_sub(read as u64)); + if room > 0 { + let take = usize::try_from(room).unwrap_or(read).min(read); + kept.extend_from_slice(&buffer[..take]); + } + } + kept + }) +} + +/// Spawn, tolerating a file the kernel still considers open for writing. +/// +/// Where the image is an ordinary inode rather than an anonymous one, the host +/// writes it and executes it immediately. The descriptor it wrote through is +/// closed by then, but any other thread that forked while it was open holds a +/// copy until that child execs, and the kernel answers `ETXTBSY` for as long as +/// one exists. That is a property of the host being multi-threaded, not of the +/// artifact, so it is waited out rather than reported. +fn spawn_retrying_busy_text(command: &mut Command) -> std::io::Result { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match command.spawn() { + Err(err) if err.raw_os_error() == Some(libc::ETXTBSY) && Instant::now() < deadline => { + thread::sleep(POLL_INTERVAL); + } + other => return other, + } + } +} + +/// Start the command, hold it to its limits, and reap it. +/// +/// `command` carries only what the standard library sets up before a pre-exec +/// hook runs: the working directory and the three standard streams. What the +/// child actually becomes is `image`, executed from its descriptor by the last +/// hook registered here, with the arguments and environment the image was built +/// with. The program named by `command` is never reached. +pub(crate) fn run( + mut command: Command, + limits: &Limits, + image: Arc, +) -> Result { + let containment = Containment::prepare(limits) + .map_err(|err| HostError::Spawn(format!("create the containment status pipe: {err}")))?; + containment.install(&mut command); + // Registered after the containment hook so it runs after it: every limit, + // every isolation step and the status record all have to be in place before + // the image replaces the child, and this call does not return. + // Read and kept before the image is handed to the hook: the state belongs + // to the image, and the only thing that may look at it afterwards is the + // report. + let integrity = image.integrity(); + let refusal = Arc::clone(&image); + unsafe { + command.pre_exec(move || Err(image.exec())); + } + + let mut child = match spawn_retrying_busy_text(&mut command) { + Ok(child) => child, + // Nothing was created, so there is nothing to describe. The image knows + // whether this is a failure to start a process or its own refusal to + // execute something that is no longer the verified bytes. + Err(err) => return Err(refusal.spawn_refusal(err)), + }; + let pid = child.id(); + + let stdout_seen = Arc::new(AtomicU64::new(0)); + let stderr_seen = Arc::new(AtomicU64::new(0)); + let stdout = drain( + child.stdout.take().expect("stdout is piped"), + limits.max_stdout_bytes, + Arc::clone(&stdout_seen), + ); + let stderr = drain( + child.stderr.take().expect("stderr is piped"), + limits.max_stderr_bytes, + Arc::clone(&stderr_seen), + ); + + let started = Instant::now(); + let mut completion = None; + let mut terminated = false; + loop { + match child.try_wait() { + Ok(Some(status)) => { + // Reaped. Sweep the group before anything else so an abandoned + // grandchild cannot outlive the run or hold the pipes open. + kill_tree(pid); + completion = Some(match status.code() { + Some(code) => Completion::Exited { code }, + None => Completion::Signalled { + signal: status.signal().unwrap_or(0), + }, + }); + break; + } + Ok(None) => {} + Err(err) => { + kill_tree(pid); + let _ = child.kill(); + let _ = child.wait(); + return Err(HostError::Io(format!("wait for the adapter: {err}"))); + } + } + + if stdout_seen.load(Ordering::Relaxed) > limits.max_stdout_bytes { + completion = Some(Completion::OutputLimit { + stream: OutputStream::Stdout, + limit: limits.max_stdout_bytes, + }); + } else if stderr_seen.load(Ordering::Relaxed) > limits.max_stderr_bytes { + completion = Some(Completion::OutputLimit { + stream: OutputStream::Stderr, + limit: limits.max_stderr_bytes, + }); + } else if started.elapsed() >= limits.wall_clock { + completion = Some(Completion::Timeout { + after: limits.wall_clock, + }); + } + + if completion.is_some() { + // The group first, so a fork bomb loses its members before the + // leader is reaped and the pid is free to be reused. + terminated = true; + kill_tree(pid); + let _ = child.kill(); + let _ = child.wait(); + break; + } + + thread::sleep(POLL_INTERVAL); + } + + // Both ends of both pipes are closed once the tree is gone, so these joins + // terminate. + let stdout = stdout.join().unwrap_or_default(); + let stderr = stderr.join().unwrap_or_default(); + + Ok(Execution { + completion: completion.expect("the loop only exits with a completion"), + stdout, + stderr, + containment: containment.collect(terminated, integrity), + }) +} diff --git a/crates/flutterdec-adapter/src/host/image.rs b/crates/flutterdec-adapter/src/host/image.rs new file mode 100644 index 00000000..72805152 --- /dev/null +++ b/crates/flutterdec-adapter/src/host/image.rs @@ -0,0 +1,734 @@ +//! The executable image one invocation runs, held as a descriptor. +//! +//! A pathname is not a promise. Anything the host verifies by reading a file and +//! then executes by naming a file is two different objects joined by a string +//! that a same-user process is free to re-point in between, and mode bits do not +//! close that gap: the owner of a `0500` file can `chmod` it back, rename it, or +//! unlink it and put another file in its place. The only way to make "the bytes +//! that ran are the bytes that were checked" true rather than likely is to stop +//! naming the file at all. +//! +//! So the verified bytes are turned into an inode here, a descriptor onto it is +//! held for the rest of the run, and every pathname to that inode is gone before +//! the caller does anything another process could synchronize against. On Linux +//! the inode is an anonymous `memfd` that never had a name, sealed so its +//! contents cannot change even for something that reaches the descriptor through +//! `/proc`, and the seals are read back before anything is executed. The child +//! is then created with `execveat(AT_EMPTY_PATH)`, which resolves the +//! descriptor and never a path. +//! +//! There is no fallback between the two on Linux. A host that cannot produce a +//! sealed anonymous image refuses the run: falling back to a pathname there +//! would quietly hand back the property the caller was promised, and it would do +//! it exactly on the hosts — old kernels, restrictive seccomp policies — where +//! the guarantee is worth the most. +//! +//! Darwin cannot execute a descriptor at all. `/dev/fd/N` there is not another +//! name for the file: it is a node whose reported mode is the *descriptor's* +//! access mode, so it never carries an execute bit and `execve` on it is refused +//! with `EACCES` whether the file behind it is linked or not. There is no +//! `fexecve` and no `execveat`. So the image is a file inside the invocation's +//! own private directory, created `O_EXCL` at mode `0500`, and then made +//! immutable through the descriptor this module holds. While that flag is set +//! the name cannot be written through, truncated, renamed, or unlinked. +//! +//! That is not the same guarantee, because the flag is a *user* flag: its owner +//! may clear it, and a same-user attacker is the owner. Two things follow, and +//! both are here rather than in a comment. The child re-checks, in the instant +//! before `execve` and after every containment control is in place, that the +//! pathname it is about to execute still resolves to the descriptor the host +//! verified and is still frozen; a name that has been re-pointed or thawed is +//! not executed at all, and the run ends as a typed pre-spawn refusal with no +//! process. And the state this platform actually reached is reported rather +//! than claimed: [`ExecImage::integrity`] answers `Applied` only for the sealed +//! anonymous inode, and on Darwin answers `Unavailable` naming `UF_IMMUTABLE` +//! and who can clear it. +//! +//! What that check reaches is worth stating exactly, because it is narrower +//! than it looks. Comparing device and inode against the held descriptor catches +//! every attack that puts a *different* object at the name: unlink-and-replace, +//! rename, a swap of one file for another, anything at all that moves the inode. +//! Re-reading the flag catches a name left thawed. What neither half catches is +//! an owner who clears the flag, rewrites the bytes through the *same* inode, +//! and sets the flag again: afterwards the device matches, the inode matches and +//! the flag is present, so the check passes and the rewritten bytes are what +//! executes. Only re-reading the whole image between the check and the `execve` +//! would see that, and the platform cannot make those two atomic, so it is not +//! attempted. That residue is why [`ExecImage::integrity`] reports Darwin as +//! best effort rather than as a guarantee: the image is narrowed as far as the +//! platform allows, the state reached is named rather than claimed, and it is +//! never called sealed. +//! +//! Scripts keep working. On Linux that is the reason the descriptor is inherited +//! rather than closed on exec: the kernel hands a `#!` interpreter `/dev/fd/N` +//! as the script to read, and that name resolves through the child's own +//! descriptor table. It is the image itself, read-only and sealed, so the child +//! gaining a second way to look at its own code costs nothing. On Darwin the +//! interpreter is handed the image's own pathname, and the descriptor stays +//! close-on-exec: handing the child a writable-flags handle on its own image +//! would give it the one capability the flag exists to deny. + +use super::HostError; +use crate::sandbox::ControlState; +use std::ffi::{CString, OsStr}; +use std::fs::File; +use std::io::Write; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd}; +use std::path::Path; + +/// The empty path `execveat` needs alongside `AT_EMPTY_PATH`. +#[cfg(target_os = "linux")] +const EMPTY_PATH: &[u8] = b"\0"; + +/// `MFD_EXEC`, which only kernels that can also refuse an executable anonymous +/// file understand. Not in every `libc` release, and asking for it on an older +/// kernel is an error rather than a no-op, so it is tried and then dropped. +#[cfg(target_os = "linux")] +const MFD_EXEC: libc::c_uint = 0x0010; + +/// What the child reports instead of executing, when the pathname it was about +/// to execute is no longer the image the host verified. +/// +/// The standard library carries whatever a pre-exec hook returns back to the +/// parent as a bare integer and nothing else, so this has to be a number no +/// syscall can produce. A real `EACCES` from `execve` would otherwise be read as +/// a subverted image, and a subverted image would be reported as an ordinary +/// failure to start a process. +#[cfg(not(target_os = "linux"))] +const IMAGE_REPLACED: i32 = 0x7fff_0001; + +/// A verified adapter image plus the exact `argv` and `envp` it is executed +/// with. +/// +/// The argument and environment vectors are built here, before the fork, +/// because the process that finally calls `exec` runs between `fork` and `exec` +/// and may not allocate: everything it needs has to already exist. +pub(crate) struct ExecImage { + fd: OwnedFd, + /// What this host established about the bytes it is about to run, as the + /// branch that built the image found it. Reported, not re-derived by the + /// caller from the platform name. + integrity: ControlState, + /// Owns the bytes the pointer arrays below point at. Never read directly; + /// the vectors of pointers are what `exec` passes to the kernel. + _argv: Vec, + _envp: Vec, + argv_ptrs: Vec<*const libc::c_char>, + envp_ptrs: Vec<*const libc::c_char>, + /// The image's own pathname, which is what Darwin executes because Darwin + /// cannot execute a descriptor. Immutable for as long as the run lasts. + #[cfg(not(target_os = "linux"))] + image_path: CString, +} + +// The pointers are read-only views into `_argv`/`_envp`, which this value owns and +// never mutates after construction, so moving it between threads moves the whole +// graph together. +unsafe impl Send for ExecImage {} +unsafe impl Sync for ExecImage {} + +impl ExecImage { + /// Materialize `bytes` as an executable inode with no pathname. + /// + /// `scratch` is only used where anonymous files are unavailable, and even + /// then the file it holds is unlinked before this returns. + pub(crate) fn prepare( + name: &str, + bytes: &[u8], + scratch: &Path, + argv: Vec, + envp: Vec, + ) -> Result { + #[cfg(target_os = "linux")] + let (fd, integrity) = open_image(name, bytes, scratch)?; + #[cfg(not(target_os = "linux"))] + let (fd, image_path, integrity) = open_image(name, bytes, scratch)?; + Ok(Self { + fd, + integrity, + argv_ptrs: pointers(&argv), + envp_ptrs: pointers(&envp), + _argv: argv, + _envp: envp, + #[cfg(not(target_os = "linux"))] + image_path, + }) + } + + /// What the host actually established about the image, for the report. + /// + /// Cloned out of the value the image-building branch produced, so a platform + /// that reached a weaker state cannot be described by a caller that only + /// knows which platform it compiled for. + pub(crate) fn integrity(&self) -> ControlState { + self.integrity.clone() + } + + /// Why the child never became the adapter. + /// + /// Everything the kernel refuses is a spawn failure. The pre-exec identity + /// check is not: nothing was executed, the host caught the image being + /// re-pointed or thawed between verifying it and running it, and that is a + /// refusal about the image rather than about creating a process. + pub(crate) fn spawn_refusal(&self, err: std::io::Error) -> HostError { + #[cfg(not(target_os = "linux"))] + if err.raw_os_error() == Some(IMAGE_REPLACED) { + return HostError::ImageNotSealed(format!( + "the image at {} is no longer the file the host verified: it was re-pointed or unfrozen between the digest and the exec, so nothing was executed", + self.image_path.to_string_lossy() + )); + } + HostError::Spawn(err.to_string()) + } + + /// Whether the pathname about to be executed is still the image. + /// + /// Darwin executes a name, and the flag welded to that name is the owner's + /// to clear. So the name is compared against the descriptor the host has + /// held since it verified the bytes — same device, same inode — and the + /// freeze is required to still be on it. Anything else means someone has + /// already taken hold of the name, and nothing is executed. + /// + /// # Safety + /// Called between `fork` and `exec`. Allocates nothing, takes no locks, and + /// calls only `fstat` and `stat`. + #[cfg(not(target_os = "linux"))] + unsafe fn is_the_verified_image(&self) -> bool { + let mut held: libc::stat = std::mem::zeroed(); + if libc::fstat(self.fd.as_raw_fd(), &mut held) != 0 { + return false; + } + let mut named: libc::stat = std::mem::zeroed(); + if libc::stat(self.image_path.as_ptr(), &mut named) != 0 { + return false; + } + held.st_dev == named.st_dev + && held.st_ino == named.st_ino + && named.st_flags & libc::UF_IMMUTABLE != 0 + } + + /// Replace the calling process with the image. + /// + /// Returns only when the execution failed, and then returns why. + /// + /// # Safety + /// Called between `fork` and `exec`. Allocates nothing, takes no locks, and + /// calls only async-signal-safe syscalls. + pub(crate) unsafe fn exec(&self) -> std::io::Error { + // The containment plan marks every inherited descriptor close-on-exec, + // including this one, and a `#!` adapter needs it open on the other + // side: the interpreter is handed `/dev/fd/N` and nothing else names the + // script. Cleared last, so the sweep cannot undo it. Only where the + // child has a use for it — elsewhere the image's descriptor is the one + // handle that can lift its immutability, and the child gets no share of + // it. + #[cfg(target_os = "linux")] + { + libc::fcntl(self.fd.as_raw_fd(), libc::F_SETFD, 0); + libc::syscall( + libc::SYS_execveat, + self.fd.as_raw_fd(), + EMPTY_PATH.as_ptr().cast::(), + self.argv_ptrs.as_ptr(), + self.envp_ptrs.as_ptr(), + libc::AT_EMPTY_PATH, + ); + } + // The last thing before the image replaces this process, so no window + // is left between deciding and doing: every containment control is + // already established and nothing else runs in this child afterwards. + #[cfg(not(target_os = "linux"))] + { + if !self.is_the_verified_image() { + return std::io::Error::from_raw_os_error(IMAGE_REPLACED); + } + libc::execve( + self.image_path.as_ptr(), + self.argv_ptrs.as_ptr(), + self.envp_ptrs.as_ptr(), + ); + } + std::io::Error::last_os_error() + } +} + +/// Lift the freeze through the descriptor that applied it. +/// +/// The run is over by the time this happens, and the flag would otherwise +/// outlive it: the invocation directory cannot be removed while something +/// inside it refuses to be unlinked. Done through the descriptor rather than +/// the name for the same reason it was set that way — the host does not decide +/// what to unfreeze by re-resolving a string. +#[cfg(not(target_os = "linux"))] +impl Drop for ExecImage { + fn drop(&mut self) { + unsafe { libc::fchflags(self.fd.as_raw_fd(), 0) }; + } +} + +/// `argv`/`envp` as the null-terminated vector `execve` expects. +fn pointers(values: &[CString]) -> Vec<*const libc::c_char> { + values + .iter() + .map(|value| value.as_ptr()) + .chain(std::iter::once(std::ptr::null())) + .collect() +} + +/// One argument or environment entry, rejected rather than truncated if it +/// cannot survive the C boundary. +pub(crate) fn argument(value: &OsStr, what: &str) -> Result { + CString::new(value.as_bytes()).map_err(|_| { + HostError::Workspace(format!( + "{what} contains a NUL byte and cannot be passed to a child" + )) + }) +} + +/// The image, or the reason there is not one. +/// +/// Two implementations, not one implementation with a fallback: on Linux the +/// only way to hold the bytes is a sealed anonymous file, and `scratch` is +/// untouched because nothing here may put an executable pathname in it. +#[cfg(target_os = "linux")] +fn open_image( + name: &str, + bytes: &[u8], + _scratch: &Path, +) -> Result<(OwnedFd, ControlState), HostError> { + let fd = reserve(anonymous_image(name, bytes)?); + // Read off the descriptor that will actually be executed, which is not + // necessarily the one `anonymous_image` sealed: `reserve` may have moved it. + // The state reported below is what this read-back found, not what the + // platform is assumed to provide. + verify_seals(fd.as_raw_fd())?; + Ok((fd, ControlState::Applied { limit: None })) +} + +#[cfg(not(target_os = "linux"))] +fn open_image( + name: &str, + bytes: &[u8], + scratch: &Path, +) -> Result<(OwnedFd, CString, ControlState), HostError> { + let (fd, path) = immutable_image(name, bytes, scratch)?; + // Said plainly, because it is less than the other branch establishes: what + // is in force here is a frozen name plus a check the child makes before it + // executes, and the freeze belongs to a user who may be the attacker. + // + // The invocation's own pathname is deliberately not in here. It is different + // on every run, and two runs of this product have to be able to report the + // same state; where the path matters — the refusal — it is in the error. + let reason = format!( + "{} cannot execute a descriptor, so the image is a pathname inside the invocation directory frozen with UF_IMMUTABLE; that flag is a user flag its owner can clear, so image integrity is best effort: the host re-checks the pathname against the descriptor it verified immediately before exec and refuses to execute anything else", + std::env::consts::OS + ); + Ok((reserve(fd), path, ControlState::Unavailable { reason })) +} + +/// Keep the image off the descriptor numbers the child reassigns. +/// +/// The containment plan moves its status pipe onto a fixed low descriptor and +/// the standard library moves the three standard streams onto theirs, both after +/// the fork and both with `dup2`, which silently replaces whatever was there. An +/// image that happened to be allocated one of those numbers would be executed as +/// a pipe. +fn reserve(fd: OwnedFd) -> OwnedFd { + if fd.as_raw_fd() > crate::sandbox::STATUS_FD { + return fd; + } + let moved = unsafe { + libc::fcntl( + fd.as_raw_fd(), + libc::F_DUPFD_CLOEXEC, + crate::sandbox::STATUS_FD + 1, + ) + }; + if moved < 0 { + return fd; + } + unsafe { OwnedFd::from_raw_fd(moved) } +} + +/// The seals an image carries before anything is allowed to execute it, and the +/// names to say which one is missing. +/// +/// Each is load-bearing. `F_SEAL_WRITE` is the content itself; `F_SEAL_GROW` and +/// `F_SEAL_SHRINK` stop the size from moving under a mapping; `F_SEAL_SEAL` +/// stops the set from being taken back off. Sealing is the contract rather than +/// a nicety: a descriptor is reachable through `/proc//fd` by anything +/// running as the same user, and a seal is the one restriction that survives +/// being reopened there. +#[cfg(target_os = "linux")] +const REQUIRED_SEALS: [(libc::c_int, &str); 4] = [ + (libc::F_SEAL_WRITE, "F_SEAL_WRITE"), + (libc::F_SEAL_GROW, "F_SEAL_GROW"), + (libc::F_SEAL_SHRINK, "F_SEAL_SHRINK"), + (libc::F_SEAL_SEAL, "F_SEAL_SEAL"), +]; + +/// An inode that never had a pathname and can never be written again. +/// +/// Every failure is returned, not absorbed. An old kernel, a seccomp policy that +/// refuses `memfd_create`, a short write, a rejected seal: each of them means +/// this host cannot make the promise the caller is relying on, and the run stops +/// before a process exists rather than continuing with a weaker image. +#[cfg(target_os = "linux")] +fn anonymous_image(name: &str, bytes: &[u8]) -> Result { + let label = CString::new(format!("flutterdec-adapter:{name}")) + .map_err(|_| refused("the adapter name contains a NUL byte"))?; + let flags = libc::MFD_CLOEXEC | libc::MFD_ALLOW_SEALING; + // `MFD_EXEC` is rejected outright by kernels that predate it, so the ask is + // made once and then dropped. Both attempts failing is the kernel declining + // to provide an anonymous file at all. + let mut raw = unsafe { libc::memfd_create(label.as_ptr(), flags | MFD_EXEC) }; + if raw < 0 { + raw = unsafe { libc::memfd_create(label.as_ptr(), flags) }; + } + if raw < 0 { + return Err(refused(&format!( + "create an anonymous executable image: {}", + std::io::Error::last_os_error() + ))); + } + // Owned from here on, so every early return below closes it. + let mut file = unsafe { File::from_raw_fd(raw) }; + file.write_all(bytes).map_err(|err| { + refused(&format!( + "write {} byte(s) into the anonymous image: {err}", + bytes.len() + )) + })?; + seal(file) +} + +/// Make the written bytes immutable, and read the seals back rather than +/// assuming `F_ADD_SEALS` did what it was asked. +/// +/// The verification is not ceremony. `F_ADD_SEALS` takes a mask, and a kernel +/// that understands the call but not every bit in that mask would leave a +/// descriptor that looks sealed and is not. What executes has to be a descriptor +/// this host has seen the whole seal set on. +#[cfg(target_os = "linux")] +fn seal(file: File) -> Result { + let fd = file.as_raw_fd(); + let mask = REQUIRED_SEALS + .iter() + .fold(0, |mask, (seal, _)| mask | *seal); + if unsafe { libc::fcntl(fd, libc::F_ADD_SEALS, mask) } != 0 { + return Err(refused(&format!( + "seal the anonymous image: {}", + std::io::Error::last_os_error() + ))); + } + verify_seals(fd)?; + Ok(OwnedFd::from(file)) +} + +/// The seals actually present, checked against the ones that were required. +#[cfg(target_os = "linux")] +fn verify_seals(fd: libc::c_int) -> Result<(), HostError> { + let present = unsafe { libc::fcntl(fd, libc::F_GET_SEALS) }; + if present < 0 { + return Err(refused(&format!( + "read the seals back from the anonymous image: {}", + std::io::Error::last_os_error() + ))); + } + let missing: Vec<&str> = REQUIRED_SEALS + .iter() + .filter(|(seal, _)| present & seal == 0) + .map(|(_, name)| *name) + .collect(); + if !missing.is_empty() { + return Err(refused(&format!( + "the anonymous image is missing {}", + missing.join(", ") + ))); + } + Ok(()) +} + +/// Why the image could not be made, as the refusal the caller sees. +#[cfg(target_os = "linux")] +fn refused(detail: &str) -> HostError { + HostError::ImageNotSealed(detail.to_string()) +} + +/// A file whose name stops being a way to change anything. +/// +/// It is created inside the invocation's own directory with `O_EXCL`, written, +/// reopened read-only, and then frozen through that descriptor: while the flag +/// is set the kernel refuses a write, a truncation, a rename and an unlink +/// through the name, so the name and the bytes stay welded together for the +/// whole run. The flag is set through the descriptor rather than the path, so +/// nothing between creating the file and freezing it is decided by re-resolving +/// a string. +/// +/// Every step is a hard failure. This is the platform's whole guarantee, and a +/// host that could not establish it would be executing a pathname anyone running +/// as this user could re-point between the digest and the `exec`. +/// +/// Compiled only where a descriptor cannot be executed. On Linux this does not +/// exist, so no Linux code path can reach a pathname-backed executable. +#[cfg(not(target_os = "linux"))] +fn immutable_image( + name: &str, + bytes: &[u8], + scratch: &Path, +) -> Result<(OwnedFd, CString), HostError> { + use std::fs; + use std::os::unix::fs::OpenOptionsExt; + + let path = scratch.join(format!(".image-{name}")); + let fail = |what: &str, err: std::io::Error| { + HostError::ImageNotSealed(format!("{what} {}: {err}", path.display())) + }; + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o500) + .open(&path) + .map_err(|err| fail("materialize the image at", err))?; + file.write_all(bytes) + .map_err(|err| fail("write the image at", err))?; + drop(file); + let opened = File::open(&path).map_err(|err| fail("open the image at", err))?; + if unsafe { libc::fchflags(opened.as_raw_fd(), libc::UF_IMMUTABLE) } != 0 { + let err = std::io::Error::last_os_error(); + // The name is already there and cannot be frozen, so it is taken back + // out before anything else can find it. + let _ = fs::remove_file(&path); + return Err(fail("make the image immutable at", err)); + } + let as_argument = CString::new(path.as_os_str().as_bytes()).map_err(|_| { + HostError::ImageNotSealed(format!( + "the image path {} contains a NUL byte", + path.display() + )) + })?; + Ok((OwnedFd::from(opened), as_argument)) +} + +/// The image boundary, forced to fail. +/// +/// Both failures are made by the kernel rather than by a hook in the product: +/// `memfd_create` rejects a label longer than it will store, and `F_ADD_SEALS` +/// rejects a descriptor that was not created sealable. So these exercise the +/// same syscalls a real run makes, in the same order, and what they observe is +/// what a host with an unsealable kernel would observe. +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + + /// Longer than the 249 bytes `memfd_create` will accept as a name, label + /// included, so both attempts fail with `EINVAL` on every kernel. + fn unnameable() -> String { + "a".repeat(250) + } + + fn vectors() -> (Vec, Vec) { + ( + vec![CString::new("adapter").expect("no NUL")], + vec![CString::new("PATH=/nonexistent").expect("no NUL")], + ) + } + + fn seals_on(fd: libc::c_int) -> libc::c_int { + let present = unsafe { libc::fcntl(fd, libc::F_GET_SEALS) }; + assert!(present >= 0, "{}", std::io::Error::last_os_error()); + present + } + + /// A memfd created the way the product creates one, minus whichever part the + /// caller wants to break. + fn memfd(sealable: bool) -> File { + let label = CString::new("flutterdec-adapter-test").expect("no NUL"); + let mut flags = libc::MFD_CLOEXEC; + if sealable { + flags |= libc::MFD_ALLOW_SEALING; + } + let raw = unsafe { libc::memfd_create(label.as_ptr(), flags) }; + assert!(raw >= 0, "{}", std::io::Error::last_os_error()); + unsafe { File::from_raw_fd(raw) } + } + + #[test] + fn an_image_the_kernel_will_not_create_is_refused_before_anything_runs() { + let scratch = tempfile::TempDir::new().expect("tempdir"); + let (argv, envp) = vectors(); + + let Err(err) = ExecImage::prepare(&unnameable(), b"\x7fELF", scratch.path(), argv, envp) + else { + panic!("an image the kernel refused became an executable"); + }; + + let HostError::ImageNotSealed(detail) = &err else { + panic!("a failed image was reported as something else: {err}"); + }; + assert!( + detail.contains("create an anonymous executable image"), + "the refusal does not say what failed: {detail}" + ); + assert!( + err.is_pre_spawn(), + "a refusal with no child was not classified as pre-spawn: {err}" + ); + let left_behind: Vec<_> = std::fs::read_dir(scratch.path()) + .expect("read the scratch directory") + .map(|entry| entry.expect("entry").path()) + .collect(); + assert!( + left_behind.is_empty(), + "the host fell back to a pathname: {left_behind:?}" + ); + } + + #[test] + fn a_descriptor_that_cannot_be_sealed_is_refused() { + let err = seal(memfd(false)) + .expect_err("a descriptor that refuses seals must not become an executable"); + + let HostError::ImageNotSealed(detail) = &err else { + panic!("a rejected seal was reported as something else: {err}"); + }; + assert!( + detail.contains("seal the anonymous image"), + "the refusal does not say what failed: {detail}" + ); + assert!(err.is_pre_spawn(), "not classified as pre-spawn: {err}"); + } + + /// The check that `F_ADD_SEALS` succeeding is not taken as proof. + #[test] + fn an_incomplete_seal_set_is_refused_and_names_what_is_missing() { + let file = memfd(true); + // Exactly the one seal a caller might think is the whole story. + assert_eq!( + 0, + unsafe { libc::fcntl(file.as_raw_fd(), libc::F_ADD_SEALS, libc::F_SEAL_WRITE) }, + "{}", + std::io::Error::last_os_error() + ); + + let err = + verify_seals(file.as_raw_fd()).expect_err("a partly sealed image must not be accepted"); + + let HostError::ImageNotSealed(detail) = &err else { + panic!("an incomplete seal set was reported as something else: {err}"); + }; + for missing in ["F_SEAL_GROW", "F_SEAL_SHRINK", "F_SEAL_SEAL"] { + assert!( + detail.contains(missing), + "the refusal does not name {missing}: {detail}" + ); + } + assert!( + !detail.contains("F_SEAL_WRITE"), + "the refusal names a seal that is present: {detail}" + ); + } + + #[test] + fn a_prepared_image_carries_every_required_seal_and_leaves_no_pathname() { + let scratch = tempfile::TempDir::new().expect("tempdir"); + let (argv, envp) = vectors(); + + let Ok(image) = + ExecImage::prepare("adapter", b"\x7fELF payload", scratch.path(), argv, envp) + else { + panic!("a sealable kernel must produce an image"); + }; + + let present = seals_on(image.fd.as_raw_fd()); + for (seal, name) in REQUIRED_SEALS { + assert!( + present & seal != 0, + "the image executed without {name} (seals {present:#x})" + ); + } + // The seal is what it claims to be rather than a flag that reads back. + // A write is refused through the descriptor the host holds, and through + // the `/proc` path any process running as this user can reach. + let overwrite = b"overwritten"; + let direct = unsafe { + libc::pwrite( + image.fd.as_raw_fd(), + overwrite.as_ptr().cast(), + overwrite.len(), + 0, + ) + }; + assert_eq!( + -1, direct, + "the sealed image accepted a write through its own descriptor" + ); + if let Ok(mut reopened) = std::fs::OpenOptions::new() + .write(true) + .open(format!("/proc/self/fd/{}", image.fd.as_raw_fd())) + { + assert!( + reopened.write_all(overwrite).is_err(), + "the sealed image accepted a write through /proc" + ); + } + let left_behind: Vec<_> = std::fs::read_dir(scratch.path()) + .expect("read the scratch directory") + .map(|entry| entry.expect("entry").path()) + .collect(); + assert!( + left_behind.is_empty(), + "a successful image still named a file: {left_behind:?}" + ); + } +} + +/// The image boundary, forced to fail where the image is a frozen file. +/// +/// The refusal has to be the same shape as the anonymous one: typed, before any +/// process exists, and leaving nothing behind that a later caller could mistake +/// for an executable. +#[cfg(all(test, not(target_os = "linux")))] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn an_image_that_cannot_be_written_is_refused_before_anything_runs() { + let scratch = tempfile::TempDir::new().expect("tempdir"); + // A directory the invocation cannot create inside, which is what a host + // with no room, no permission, or a read-only workspace would look like. + std::fs::set_permissions(scratch.path(), std::fs::Permissions::from_mode(0o500)) + .expect("seal the scratch directory"); + let argv = vec![CString::new("adapter").expect("no NUL")]; + let envp = vec![CString::new("PATH=/nonexistent").expect("no NUL")]; + + let err = ExecImage::prepare("adapter", b"\x7fELF", scratch.path(), argv, envp) + .err() + .expect("an image that cannot be written must not become an executable"); + + let HostError::ImageNotSealed(detail) = &err else { + panic!("a failed image was reported as something else: {err}"); + }; + assert!( + detail.contains("materialize the image at"), + "the refusal does not say what failed: {detail}" + ); + assert!( + err.is_pre_spawn(), + "a refusal with no child was not classified as pre-spawn: {err}" + ); + + std::fs::set_permissions(scratch.path(), std::fs::Permissions::from_mode(0o700)) + .expect("reopen the scratch directory"); + let left_behind: Vec<_> = std::fs::read_dir(scratch.path()) + .expect("read the scratch directory") + .map(|entry| entry.expect("entry").path()) + .collect(); + assert!( + left_behind.is_empty(), + "a refused image still named a file: {left_behind:?}" + ); + } +} diff --git a/crates/flutterdec-adapter/src/lib.rs b/crates/flutterdec-adapter/src/lib.rs index d91cf4b1..347a3657 100644 --- a/crates/flutterdec-adapter/src/lib.rs +++ b/crates/flutterdec-adapter/src/lib.rs @@ -1,203 +1,38 @@ //! Host side of the adapter boundary. //! //! One adapter run is one process: the host writes the snapshot regions and an -//! [`protocol::AdapterRequest`] into a scratch directory, runs the adapter -//! there, and reads back an [`protocol::AdapterResult`] plus a -//! [`model::ProgramModel`]. Nothing about the run is decided by the adapter: the -//! identity, the producer record, the compatibility binding, and the region -//! table are host facts that the model is checked against before it is returned. +//! [`protocol::AdapterRequest`] into a private invocation directory, runs the +//! adapter there under an explicit set of limits, and reads back an +//! [`protocol::AdapterResult`] plus a [`model::ProgramModel`]. Nothing about the +//! run is decided by the adapter: the identity, the registry record, the +//! producer record, the compatibility binding, and the region table are host +//! facts, and every one of them is checked before a process exists. //! //! There is no v2/v3 path. [`model::ProgramModel::from_json`] rejects those //! documents by version, so an old adapter fails loudly instead of being //! silently reinterpreted. +pub mod host; pub mod model; pub mod primitives; pub mod protocol; +pub mod sandbox; +pub mod store; pub mod validate; - -use anyhow::{anyhow, bail, Context, Result}; -use flutterdec_loader::identity::IdentityRejection; -use model::{CompatibilityBinding, InputRegion, InputRegionName, Producer, ProgramModel}; -use primitives::{RelativePath, Sha256Digest}; -use protocol::{AdapterRequest, AdapterResult, AdapterStatus, BackendId, RequestedBackend}; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; -use std::process::Command; -use tempfile::tempdir; -use validate::HostSelectedContext; - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct AdapterManifest { - pub entries: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AdapterManifestEntry { - pub snapshot_hash: String, - pub version: String, - pub adapter: String, -} - -/// One snapshot region, as the host read it. -#[derive(Debug, Clone, Copy)] -pub struct AdapterRegionInput<'a> { - pub region: InputRegionName, - pub bytes: &'a [u8], - /// Load address. Required for executable regions, forbidden for data ones; - /// [`AdapterRequest::validate`] rejects the other combinations. - pub virtual_address: Option, -} - -/// Everything the host hands one adapter invocation. -/// -/// The host-selected facts are here rather than derived from adapter output on -/// the way back, because a fact the adapter supplies cannot check the adapter. -#[derive(Debug, Clone)] -pub struct AdapterInput<'a> { - /// Header-derived identity of the snapshot. Authoritative. - pub identity: &'a flutterdec_loader::identity::SnapshotIdentity, - /// Who the host believes is about to run, including the digest of the - /// artifact it is about to execute. - pub producer: Producer, - /// The compatibility decision that authorized this run. - pub compatibility: CompatibilityBinding, - pub regions: Vec>, - /// The original artifact, for backends that re-read it themselves. - pub input_path: Option<&'a Path>, - pub libapp_path: Option<&'a Path>, - pub requested_backend: RequestedBackend, -} - -/// What one adapter invocation produced, with the facts about the run that the -/// core needs and must not re-derive from the model. -#[derive(Debug, Clone)] -pub struct AdapterRun { - pub model: ProgramModel, - /// The backend that actually ran, as the protocol reported it. - pub resolved_backend: BackendId, - pub fallback_reason: Option, - pub diagnostics: Vec, -} - -fn manifest_path(repo_root: &Path) -> PathBuf { - repo_root.join("adapters/manifest.json") -} - -fn template_path(repo_root: &Path) -> PathBuf { - repo_root.join("adapters/python/adapter_template.py") -} - -fn installed_dir(repo_root: &Path) -> PathBuf { - repo_root.join("adapters/installed") -} - -pub fn load_manifest(repo_root: &Path) -> Result { - let path = manifest_path(repo_root); - if !path.exists() { - return Ok(AdapterManifest::default()); - } - let bytes = fs::read(&path).with_context(|| format!("read manifest: {}", path.display()))?; - let m = - serde_json::from_slice::(&bytes).context("parse adapter manifest JSON")?; - Ok(m) -} - -pub fn save_manifest(repo_root: &Path, manifest: &AdapterManifest) -> Result<()> { - let path = manifest_path(repo_root); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let body = serde_json::to_vec_pretty(manifest)?; - fs::write(&path, body).with_context(|| format!("write manifest: {}", path.display()))?; - Ok(()) +/// Host compatibility records live in the loader crate so profile and identity +/// selection cannot depend on adapter model DTOs; re-export them at the adapter +/// boundary for callers that own adapter lifecycle. +pub mod registry { + pub use flutterdec_loader::registry::*; } -pub fn resolve_adapter_name(repo_root: &Path, dart_hash: &str) -> Result { - let m = load_manifest(repo_root)?; - if let Some(entry) = m.entries.iter().find(|e| e.snapshot_hash == dart_hash) { - return Ok(entry.adapter.clone()); - } - Ok(format!("dart_adapter_{}", dart_hash)) -} +pub use host::{ + run_adapter, AdapterInput, AdapterRegionInput, AdapterRun, HostAuthorization, HostError, + LibappSource, OutputStream, +}; +pub use sandbox::{ContainmentReport, ControlState, Limits}; -pub fn install_adapter(repo_root: &Path, dart_hash: &str) -> Result { - if dart_hash.is_empty() { - bail!("dart hash cannot be empty"); - } - - let template = template_path(repo_root); - if !template.exists() { - bail!("missing adapter template: {}", template.display()); - } - - let mut manifest = load_manifest(repo_root)?; - if !manifest - .entries - .iter() - .any(|e| e.snapshot_hash == dart_hash) - { - manifest.entries.push(AdapterManifestEntry { - snapshot_hash: dart_hash.to_string(), - version: "unknown".to_string(), - adapter: format!("dart_adapter_{}", dart_hash), - }); - save_manifest(repo_root, &manifest)?; - } - - let name = resolve_adapter_name(repo_root, dart_hash)?; - let out_dir = installed_dir(repo_root); - fs::create_dir_all(&out_dir)?; - let out = out_dir.join(name); - - let script = "#!/usr/bin/env python3\nfrom pathlib import Path\nimport sys\nroot = Path(__file__).resolve().parents[1]\nsys.path.insert(0, str(root / 'python'))\nimport adapter_template\nif __name__ == '__main__':\n raise SystemExit(adapter_template.entrypoint())\n"; - - fs::write(&out, script).with_context(|| format!("write adapter script: {}", out.display()))?; - let mut perms = fs::metadata(&out)?.permissions(); - perms.set_mode(0o755); - fs::set_permissions(&out, perms)?; - - Ok(out) -} - -pub fn list_adapters(repo_root: &Path) -> Result> { - let m = load_manifest(repo_root)?; - let base = installed_dir(repo_root); - let mut out = Vec::new(); - for entry in m.entries { - let installed = base.join(&entry.adapter).exists(); - out.push((entry, installed)); - } - Ok(out) -} - -pub fn resolve_adapter_exec(repo_root: &Path, dart_hash: &str) -> Result { - let name = resolve_adapter_name(repo_root, dart_hash)?; - let exec = installed_dir(repo_root).join(name); - if !exec.exists() { - bail!( - "adapter not installed for hash {}. run: flutterdec adapter install --dart-hash {}", - dart_hash, - dart_hash - ); - } - Ok(exec) -} - -fn region_file_name(region: InputRegionName) -> &'static str { - match region { - InputRegionName::VmData => "vm_data.bin", - InputRegionName::IsolateData => "isolate_data.bin", - InputRegionName::VmInstructions => "vm_instructions.bin", - InputRegionName::IsolateInstructions => "isolate_instructions.bin", - } -} - -const OUTPUT_MODEL_PATH: &str = "model.json"; -const REQUEST_PATH: &str = "request.json"; -const RESULT_PATH: &str = "result.json"; +use flutterdec_loader::identity::IdentityRejection; /// Wrap an identity rejection so it survives as a typed cause. /// @@ -206,191 +41,3 @@ const RESULT_PATH: &str = "result.json"; pub fn identity_rejected(rejection: IdentityRejection) -> anyhow::Error { anyhow::Error::new(rejection).context("snapshot identity may not authorize an adapter") } - -/// Run one adapter and return a model that has already been checked against the -/// host's own view of the snapshot. -/// -/// The order matters: the request is validated before the process is spawned, -/// and the model is validated before it is handed back, so neither a malformed -/// question nor a mismatched answer reaches the core. -pub fn run_adapter(exec_path: &Path, input: &AdapterInput<'_>) -> Result { - // The gate, restated at the boundary itself. Callers gate earlier so that a - // rejected identity never reaches a manifest or the filesystem, but this is - // the last place a process can be spawned, and a public entry point that - // trusts its caller to have checked is a public entry point that will one - // day be called by a caller that did not. - input - .identity - .exact_selection_key() - .map_err(identity_rejected)?; - - let tmp = tempdir().context("create scratch directory for adapter")?; - let work = tmp.path(); - - let mut handles = Vec::with_capacity(input.regions.len()); - let mut host_regions: Vec = Vec::with_capacity(input.regions.len()); - for region in &input.regions { - let name = region_file_name(region.region); - fs::write(work.join(name), region.bytes) - .with_context(|| format!("write adapter input region {}", region.region))?; - let digest = Sha256Digest::of(region.bytes); - let size = region.bytes.len() as u64; - handles.push(protocol::InputHandle { - region: region.region, - path: RelativePath::parse(name).map_err(|err| anyhow!(err))?, - size, - sha256: digest.clone(), - virtual_address: region.virtual_address, - executable: region.region.is_executable(), - }); - host_regions.push(InputRegion { - region: region.region, - size, - sha256: digest, - virtual_address: region.virtual_address, - executable: region.region.is_executable(), - }); - } - handles.sort_by_key(|h| h.region); - host_regions.sort_by_key(|r| r.region); - - let request = AdapterRequest { - protocol_major: protocol::PROTOCOL_MAJOR, - model_major: model::MODEL_VERSION, - compatibility: input.compatibility.clone(), - producer: input.producer.clone(), - identity: input.identity.clone(), - requested_backend: input.requested_backend, - inputs: handles, - output: RelativePath::parse(OUTPUT_MODEL_PATH).map_err(|err| anyhow!(err))?, - }; - // Fail before spawn, not after: a request the host itself would reject is - // not a request an adapter should get a chance to answer. - request - .validate() - .map_err(|err| anyhow!("adapter request is invalid: {}", err))?; - fs::write(work.join(REQUEST_PATH), request.to_json()).context("write adapter request")?; - - let mut cmd = Command::new(exec_path); - cmd.current_dir(work) - .arg("--request") - .arg(REQUEST_PATH) - .arg("--result") - .arg(RESULT_PATH); - if let Some(path) = input.input_path { - cmd.arg("--input-path").arg(absolute(path)); - } - if let Some(path) = input.libapp_path { - cmd.arg("--libapp-path").arg(absolute(path)); - } - let output = cmd - .output() - .with_context(|| format!("launch adapter: {}", exec_path.display()))?; - - let result_path = work.join(RESULT_PATH); - if !output.status.success() && !result_path.exists() { - return Err(anyhow!( - "adapter failed with status {} and wrote no result document\nstdout:\n{}\nstderr:\n{}", - output.status, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - )); - } - - let result_bytes = fs::read(&result_path) - .with_context(|| format!("read adapter result: {}", result_path.display()))?; - let result = AdapterResult::from_json(&result_bytes) - .map_err(|err| anyhow!("adapter result is not protocol v1: {}", err))?; - result - .validate_against(&request) - .map_err(|err| anyhow!("adapter result does not answer the request: {}", err))?; - - if result.status != AdapterStatus::Ok { - let error = result - .error - .as_ref() - .expect("a non-ok result carries an error"); - return Err(anyhow!( - "adapter reported {:?} ({:?}): {}\nstderr:\n{}", - result.status, - error.code, - error.message, - String::from_utf8_lossy(&output.stderr) - )); - } - - let model_rel = result.model.as_ref().expect("an ok result carries a model"); - if model_rel.as_str() != request.output.as_str() { - bail!( - "adapter wrote its model to {:?} instead of the requested {:?}", - model_rel.as_str(), - request.output.as_str() - ); - } - let model_bytes = fs::read(work.join(model_rel.as_str())) - .with_context(|| format!("read adapter model: {}", model_rel.as_str()))?; - let model = ProgramModel::from_json(&model_bytes) - .map_err(|err| anyhow!("adapter model rejected: {}", err))?; - - let host = HostSelectedContext { - identity: input.identity.clone(), - producer: input.producer.clone(), - compatibility: input.compatibility.clone(), - regions: host_regions, - }; - validate::validate(&model, &host) - .map_err(|err| anyhow!("adapter model failed semantic validation: {}", err))?; - - Ok(AdapterRun { - model, - resolved_backend: result - .resolved_backend - .expect("an ok result names its backend"), - fallback_reason: result.fallback_reason, - diagnostics: result.diagnostics, - }) -} - -/// An absolute path for a caller-supplied artifact. -/// -/// The adapter runs with its working directory set to the scratch dir, so a -/// relative path handed straight through would resolve somewhere the caller did -/// not mean. Canonicalizing fails only for a path that does not exist yet, and -/// joining the current directory is still absolute. -fn absolute(path: &Path) -> PathBuf { - if let Ok(canonical) = path.canonicalize() { - return canonical; - } - if path.is_absolute() { - return path.to_path_buf(); - } - std::env::current_dir() - .map(|cwd| cwd.join(path)) - .unwrap_or_else(|_| path.to_path_buf()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[test] - fn install_adds_manifest_entry() { - let td = tempdir().expect("tempdir"); - let repo = td.path(); - fs::create_dir_all(repo.join("adapters/python")).expect("mkdir"); - fs::create_dir_all(repo.join("adapters/installed")).expect("mkdir"); - fs::write( - repo.join("adapters/python/adapter_template.py"), - "def entrypoint(): return 0\n", - ) - .expect("write template"); - - let out = install_adapter(repo, "abcd1234").expect("install"); - assert!(out.exists()); - - let manifest = load_manifest(repo).expect("load"); - assert_eq!(manifest.entries.len(), 1); - assert_eq!(manifest.entries[0].snapshot_hash, "abcd1234"); - } -} diff --git a/crates/flutterdec-adapter/src/model.rs b/crates/flutterdec-adapter/src/model.rs index 87e0a8a1..8390f8d1 100644 --- a/crates/flutterdec-adapter/src/model.rs +++ b/crates/flutterdec-adapter/src/model.rs @@ -600,7 +600,16 @@ pub struct ProgramModel { pub model_version: u32, pub producer: Producer, pub input: ObservedInput, - pub compatibility: CompatibilityBinding, + /// The registry decision that authorized this run, or `None` when there was + /// no decision to record. + /// + /// `None` is reachable exactly one way: the host recovered the program + /// itself because no compatibility record authorized an adapter. Writing a + /// binding there would mean inventing a record digest, a parser family and a + /// profile that no registry ever selected, which is the same class of lie as + /// inventing a name. An adapter-produced model is always checked against the + /// binding the host handed it, so `null` from an adapter is a rejection. + pub compatibility: Option, pub capabilities: Capabilities, pub libraries: Vec, pub classes: Vec, @@ -739,6 +748,15 @@ fn confidence_schema() -> Value { json!({ "type": ["number", "null"], "minimum": 0.0, "maximum": 1.0 }) } +/// The same object, or JSON `null`. +/// +/// `oneOf` rather than a `"type": ["object", "null"]` widening, so the object's +/// own `required`/`additionalProperties` rules still apply to the non-null arm +/// instead of being silently dropped. +fn nullable(schema: Value) -> Value { + json!({ "oneOf": [schema, { "type": "null" }] }) +} + fn object(properties: Value, required: Vec<&str>) -> Value { json!({ "type": "object", @@ -928,7 +946,7 @@ pub fn schema() -> Value { }), vec!["identity", "regions"], ), - "compatibility": object( + "compatibility": nullable(object( json!({ "record_sha256": digest_schema(), "parser_family_id": { "type": "string", "minLength": 1 }, @@ -936,7 +954,7 @@ pub fn schema() -> Value { "profile_sha256": digest_schema(), }), vec!["record_sha256", "parser_family_id", "profile_id", "profile_sha256"], - ), + )), "capabilities": object( json!({ "libraries": level_enum(), diff --git a/crates/flutterdec-adapter/src/sandbox.rs b/crates/flutterdec-adapter/src/sandbox.rs new file mode 100644 index 00000000..153b0325 --- /dev/null +++ b/crates/flutterdec-adapter/src/sandbox.rs @@ -0,0 +1,847 @@ +//! Containment controls for one adapter child process. +//! +//! Everything here exists to make one claim checkable: the host never says a +//! control is in force unless the control was established in the child that +//! actually ran. A limit is set by the child itself, between `fork` and `exec`, +//! where the only thing that knows whether `setrlimit` succeeded is the child. +//! So the child reports back: it writes one fixed-size record of per-control +//! result codes into a close-on-exec pipe, `exec` closes the pipe, and the +//! parent reads the record and turns it into a [`ContainmentReport`]. A control +//! whose code is not zero is reported `Unavailable` with the reason, never +//! silently as applied. +//! +//! The pre-exec code runs in a forked child of a possibly multi-threaded +//! process, so it does no allocation, takes no locks, and calls nothing but +//! syscalls. +//! +//! Platform differences are conditional and stated rather than smoothed over. +//! Linux can unshare a network namespace and can observe its own per-user +//! process count, so those controls are real there. Darwin can do neither, and +//! its kernel does not enforce `RLIMIT_AS`, so all three are reported +//! unavailable on Darwin rather than claimed. Nothing here compiles or claims +//! anything on Windows. + +use serde::Serialize; +use std::fs; +use std::io::Read; +use std::os::unix::io::{FromRawFd, OwnedFd, RawFd}; +use std::os::unix::process::CommandExt; +use std::process::Command; +use std::time::Duration; + +/// How the six named resource controls plus session, descriptor, and network +/// isolation are reported. One slot per control, in a fixed order, because the +/// child writes them positionally. +const SLOT_SESSION: usize = 0; +const SLOT_DESCRIPTOR_ISOLATION: usize = 1; +const SLOT_CPU: usize = 2; +const SLOT_FILE_SIZE: usize = 3; +const SLOT_ADDRESS_SPACE: usize = 4; +const SLOT_PROCESS_COUNT: usize = 5; +const SLOT_DESCRIPTORS: usize = 6; +const SLOT_NETWORK: usize = 7; +const SLOT_COUNT: usize = 8; + +/// The control was established. +const CODE_APPLIED: i32 = 0; +/// The caller did not ask for this control. +const CODE_NOT_REQUESTED: i32 = -1; +/// This host has no mechanism for the control. +const CODE_UNSUPPORTED: i32 = -2; + +/// Descriptor scan ceiling. +/// +/// The child marks every descriptor from 4 upwards to this bound close-on-exec. +/// A soft `RLIMIT_NOFILE` can be a million on a systemd host, and a million +/// `fcntl` calls per adapter run buys nothing: descriptors above this are not +/// something a host process holds open by accident. +const MAX_DESCRIPTOR_SCAN: u32 = 65_536; + +/// The descriptor the status pipe is moved to before the scan runs, so the scan +/// has one contiguous range to close and no exception to test for. +pub(crate) const STATUS_FD: RawFd = 3; + +/// What one adapter invocation is allowed to consume. +/// +/// These are deliberately concrete numbers rather than "unlimited unless +/// configured": an adapter is a third-party executable, and the default for a +/// third-party executable cannot be no bound at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limits { + /// Overall wall-clock deadline for the whole invocation. + pub wall_clock: Duration, + /// `RLIMIT_CPU` seconds. Distinct from `wall_clock`: a process asleep for an + /// hour has burned no CPU, and a process spinning for an hour has burned no + /// more wall clock than one that slept. + pub cpu_seconds: u64, + /// `RLIMIT_FSIZE`. Bounds any single file the adapter writes, including one + /// it writes outside its workspace. + pub max_file_bytes: u64, + /// `RLIMIT_AS`. `None` leaves address space unbounded, which is reported as + /// such. + pub max_address_space_bytes: Option, + /// How many more processes the adapter tree may create. Added to the current + /// per-user process count, because `RLIMIT_NPROC` counts every process of + /// the real user id and not just this tree. + pub extra_processes: Option, + /// `RLIMIT_NOFILE`. + pub max_descriptors: u64, + pub max_stdout_bytes: u64, + pub max_stderr_bytes: u64, + /// Cap on the model document the adapter writes. + pub max_model_bytes: u64, + /// Cap on the protocol result document. + pub max_result_bytes: u64, + /// Cap on one snapshot region handed to the adapter. + pub max_region_bytes: u64, + /// Whether to put the child in an empty network namespace. + pub isolate_network: bool, +} + +impl Default for Limits { + fn default() -> Self { + Self { + wall_clock: Duration::from_secs(600), + cpu_seconds: 600, + max_file_bytes: 512 * 1024 * 1024, + max_address_space_bytes: Some(8 * 1024 * 1024 * 1024), + extra_processes: Some(64), + max_descriptors: 512, + max_stdout_bytes: 1024 * 1024, + max_stderr_bytes: 1024 * 1024, + max_model_bytes: 256 * 1024 * 1024, + max_result_bytes: 1024 * 1024, + max_region_bytes: 512 * 1024 * 1024, + isolate_network: true, + } + } +} + +/// Whether one control is in force, and the bound if it is. +/// +/// There is no third variant on purpose. "Probably applied" is the state this +/// type exists to make unrepresentable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum ControlState { + Applied { + /// The effective bound, when the control has one. A session or a network + /// namespace is on or off and has no number. + limit: Option, + }, + Unavailable { + reason: String, + }, +} + +impl ControlState { + pub fn is_applied(&self) -> bool { + matches!(self, Self::Applied { .. }) + } + + fn from_code(code: i32, value: u64, control: &str) -> Self { + match code { + CODE_APPLIED => Self::Applied { + limit: (value != u64::MAX).then_some(value), + }, + CODE_NOT_REQUESTED => Self::Unavailable { + reason: format!("{control} was not requested for this invocation"), + }, + CODE_UNSUPPORTED => Self::Unavailable { + reason: format!("{control} has no mechanism on {}", std::env::consts::OS), + }, + errno => Self::Unavailable { + reason: format!( + "{control} could not be established: {}", + std::io::Error::from_raw_os_error(errno) + ), + }, + } + } + + fn unavailable(reason: impl Into) -> Self { + Self::Unavailable { + reason: reason.into(), + } + } +} + +/// The containment state of one completed or terminated invocation. +/// +/// Serialized into the decompile report so the accuracy of every claim is +/// inspectable from outside the process that made it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ContainmentReport { + /// The host-side wall-clock deadline. Enforced by the host, so it is applied + /// whenever an invocation happened at all. + pub wall_clock_deadline: ControlState, + /// A new session and process group, which is what makes a tree kill + /// possible. + pub process_group: ControlState, + /// Inherited descriptors above the three standard ones closed before `exec`. + pub descriptor_isolation: ControlState, + /// What the host established about the bytes the child executed. + /// + /// `Applied` means an inode that never had a pathname and whose whole seal + /// set the host read back off the descriptor it executed. `Unavailable` + /// means the platform could not give that, and the reason says what it gave + /// instead and what the ceiling is. The value comes from the image itself, + /// so it describes what this run did rather than what the platform is + /// usually able to do. + pub image_integrity: ControlState, + pub cpu_seconds: ControlState, + pub file_size: ControlState, + pub address_space: ControlState, + pub process_count: ControlState, + pub descriptors: ControlState, + pub network: ControlState, + /// Host-side output caps. Applied by the reader, so always in force. + pub stdout_bytes: ControlState, + pub stderr_bytes: ControlState, + pub model_bytes: ControlState, + /// Whether the host had to terminate a tree that was still running, rather + /// than reaping a child that finished on its own. + /// + /// The group is signalled on every exit path, including the clean one, + /// because a backend that forked and abandoned a grandchild leaves one + /// behind on the clean path too. This field is about the other case: the + /// deadline or an output cap ran out and the host ended the run. + pub process_tree_terminated: bool, +} + +impl ContainmentReport { + /// Every control this report names, for callers that iterate rather than + /// hard-code the list. + pub fn controls(&self) -> Vec<(&'static str, &ControlState)> { + vec![ + ("wall_clock_deadline", &self.wall_clock_deadline), + ("process_group", &self.process_group), + ("descriptor_isolation", &self.descriptor_isolation), + ("image_integrity", &self.image_integrity), + ("cpu_seconds", &self.cpu_seconds), + ("file_size", &self.file_size), + ("address_space", &self.address_space), + ("process_count", &self.process_count), + ("descriptors", &self.descriptors), + ("network", &self.network), + ("stdout_bytes", &self.stdout_bytes), + ("stderr_bytes", &self.stderr_bytes), + ("model_bytes", &self.model_bytes), + ] + } +} + +/// The integer plan the child carries across `fork`. +/// +/// `Copy` and nothing but integers, because the pre-exec closure has to be +/// `'static` and must not allocate. +#[derive(Debug, Clone, Copy)] +struct ChildPlan { + status_fd: RawFd, + descriptor_scan_ceiling: u32, + cpu_seconds: u64, + max_file_bytes: u64, + /// `u64::MAX` means "not requested"; `RLIM_INFINITY` is a legitimate value + /// so it cannot double as the sentinel. + max_address_space_bytes: u64, + /// Budget when the child shares the host's user namespace: the host's + /// current task count plus the allowance. + process_count: u64, + /// Budget when the child gets its own user namespace: the allowance plus the + /// one task that is the child itself. + process_count_isolated: u64, + max_descriptors: u64, + isolate_network: bool, + /// Whether the host confines a process the moment it makes an unprivileged + /// user namespace. Read before the fork, because the child may not read + /// files between `fork` and `exec`. + userns_is_confined: bool, +} + +const NOT_REQUESTED: u64 = u64::MAX; + +/// The record the child writes and the parent reads. +/// +/// Fixed size and well under `PIPE_BUF`, so one `write` is atomic and a short +/// read means the child died before it finished rather than that the record +/// interleaved with something else. +const STATUS_BYTES: usize = SLOT_COUNT * 4 + SLOT_COUNT * 8; + +/// One `write` of this size is atomic, so a short read in the parent means the +/// child died mid-record rather than that two writers interleaved. +const _: () = assert!(STATUS_BYTES < 512); + +fn errno() -> i32 { + std::io::Error::last_os_error().raw_os_error().unwrap_or(-3) +} + +/// Lower one resource limit, clamping to the inherited hard limit. +/// +/// Clamping rather than failing matters: a host whose hard limit is already +/// below what the caller asked for still gets a limit, and the value reported is +/// the one that is actually in force. +/// +/// # Safety +/// Called between `fork` and `exec`. Only makes syscalls. +#[allow(clippy::unnecessary_cast)] +unsafe fn lower_limit(resource: RlimitResource, requested: u64, applied: &mut u64) -> i32 { + let mut current = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + if libc::getrlimit(resource, &mut current) != 0 { + return errno(); + } + let want = requested as libc::rlim_t; + let effective = if current.rlim_max != libc::RLIM_INFINITY && want > current.rlim_max { + current.rlim_max + } else { + want + }; + let next = libc::rlimit { + rlim_cur: effective, + rlim_max: current.rlim_max, + }; + if libc::setrlimit(resource, &next) != 0 { + return errno(); + } + *applied = effective as u64; + CODE_APPLIED +} + +/// The integer type `getrlimit` names a resource with, which glibc and Darwin +/// spell differently. +#[cfg(target_env = "gnu")] +type RlimitResource = libc::__rlimit_resource_t; +#[cfg(not(target_env = "gnu"))] +type RlimitResource = libc::c_int; + +/// Drop the child into an empty network namespace. +/// +/// Two attempts, because the two ways to get one need different authority: +/// `CLONE_NEWNET` alone needs `CAP_SYS_ADMIN`, and pairing it with +/// `CLONE_NEWUSER` gets that capability inside a fresh user namespace on hosts +/// that allow unprivileged user namespaces. Where neither works the errno from +/// the first attempt is reported and nothing is claimed. +/// +/// The second return value says whether a *user* namespace was entered, which +/// the caller needs: the kernel counts `RLIMIT_NPROC` per user namespace and +/// uid, so entering one resets that count and a budget computed against the +/// host's count would no longer bound anything. +/// +/// The user-namespace route is skipped entirely where the host confines a +/// process for taking it. Some kernels answer an unprivileged +/// `CLONE_NEWUSER` by putting the caller under a mandatory access control +/// profile instead of by refusing it, and such a profile decides what may be +/// executed by *pathname* — which an anonymous image does not have. Taking the +/// namespace there would buy an empty route table with the ability to start the +/// adapter at all, so the control is reported unavailable rather than bought at +/// that price. +/// +/// # Safety +/// Called between `fork` and `exec`. Only makes syscalls. +#[cfg(target_os = "linux")] +unsafe fn isolate_network(userns_is_confined: bool) -> (i32, bool) { + if libc::unshare(libc::CLONE_NEWNET) == 0 { + return (CODE_APPLIED, false); + } + let first = errno(); + if !userns_is_confined && libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET) == 0 { + return (CODE_APPLIED, true); + } + (first, false) +} + +#[cfg(not(target_os = "linux"))] +unsafe fn isolate_network(_userns_is_confined: bool) -> (i32, bool) { + (CODE_UNSUPPORTED, false) +} + +/// Where the kernel says whether an unprivileged user namespace costs the +/// caller its freedom to execute. +#[cfg(target_os = "linux")] +const USERNS_CONFINEMENT_SWITCH: &str = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns"; + +/// Read as `false` when the switch is absent: a host that does not have the +/// restriction is a host where the namespace is free. +#[cfg(target_os = "linux")] +fn userns_is_confined() -> bool { + fs::read_to_string(USERNS_CONFINEMENT_SWITCH) + .map(|value| confinement_is_on(&value)) + .unwrap_or(false) +} + +#[cfg(not(target_os = "linux"))] +fn userns_is_confined() -> bool { + false +} + +/// The switch as the kernel writes it: a single number and a newline, where +/// anything other than zero turns the confinement on. +#[cfg(target_os = "linux")] +fn confinement_is_on(value: &str) -> bool { + match value.trim().parse::() { + Ok(setting) => setting != 0, + // An unreadable setting is treated as on. Losing an empty route table + // is a reported, recoverable loss; losing the ability to execute the + // verified image is not. + Err(_) => true, + } +} + +/// Apply the plan and report each outcome. Runs in the forked child. +/// +/// # Safety +/// Called between `fork` and `exec` from `pre_exec`. Allocates nothing, takes no +/// locks, and calls only async-signal-safe syscalls. +unsafe fn apply_plan(plan: &ChildPlan) { + let mut codes = [CODE_NOT_REQUESTED; SLOT_COUNT]; + let mut values = [NOT_REQUESTED; SLOT_COUNT]; + + // A new session, so the child is a process-group leader and the whole tree + // can be signalled with one negative pid. Done first: everything after this + // point is a limit on a process the host can already terminate. + codes[SLOT_SESSION] = if libc::setsid() < 0 { + errno() + } else { + CODE_APPLIED + }; + + // Move the status pipe somewhere known so the scan below is one range with + // no exception in it. `dup2` clears close-on-exec, so it is set again: the + // parent detects `exec` by this descriptor closing. + if plan.status_fd != STATUS_FD && libc::dup2(plan.status_fd, STATUS_FD) < 0 { + // Nothing can be reported without the pipe. Exec still proceeds; the + // parent treats a missing record as a failed invocation. + return; + } + libc::fcntl(STATUS_FD, libc::F_SETFD, libc::FD_CLOEXEC); + + // Close-on-exec rather than close. The two are equivalent for the adapter, + // which only exists after `exec`, but `close` would also destroy the pipe + // the standard library uses to report an `exec` failure back to the host, + // turning "artifact vanished" into an unexplained abort. + let mut scan_failures = 0i32; + for fd in (STATUS_FD + 1)..=(plan.descriptor_scan_ceiling as RawFd) { + if libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) != 0 && errno() != libc::EBADF { + scan_failures = errno(); + } + } + codes[SLOT_DESCRIPTOR_ISOLATION] = if scan_failures == 0 { + values[SLOT_DESCRIPTOR_ISOLATION] = u64::from(plan.descriptor_scan_ceiling); + CODE_APPLIED + } else { + scan_failures + }; + + codes[SLOT_CPU] = lower_limit(libc::RLIMIT_CPU, plan.cpu_seconds, &mut values[SLOT_CPU]); + codes[SLOT_FILE_SIZE] = lower_limit( + libc::RLIMIT_FSIZE, + plan.max_file_bytes, + &mut values[SLOT_FILE_SIZE], + ); + + // Darwin accepts `RLIMIT_AS` and does not enforce it, so setting it there + // would produce a true syscall and a false claim. + #[cfg(target_os = "linux")] + if plan.max_address_space_bytes != NOT_REQUESTED { + codes[SLOT_ADDRESS_SPACE] = lower_limit( + libc::RLIMIT_AS, + plan.max_address_space_bytes, + &mut values[SLOT_ADDRESS_SPACE], + ); + } + #[cfg(not(target_os = "linux"))] + { + // Read on this platform too, so the field cannot rot into one that is + // only ever written. + let _ = plan.max_address_space_bytes; + codes[SLOT_ADDRESS_SPACE] = CODE_UNSUPPORTED; + } + + codes[SLOT_DESCRIPTORS] = lower_limit( + libc::RLIMIT_NOFILE, + plan.max_descriptors, + &mut values[SLOT_DESCRIPTORS], + ); + + // Network isolation before the process budget, because which namespace this + // task ends up in decides what the budget has to be. + let mut own_user_namespace = false; + if plan.isolate_network { + let (code, entered) = isolate_network(plan.userns_is_confined); + codes[SLOT_NETWORK] = code; + own_user_namespace = entered; + } + + // In a fresh user namespace the per-uid task count starts at this task + // alone, so the budget is the tree's own allowance. Sharing the host's + // namespace means sharing its count, so the budget is that count plus the + // allowance. + let process_count = if own_user_namespace { + plan.process_count_isolated + } else { + plan.process_count + }; + if process_count != NOT_REQUESTED { + codes[SLOT_PROCESS_COUNT] = lower_limit( + libc::RLIMIT_NPROC, + process_count, + &mut values[SLOT_PROCESS_COUNT], + ); + } + + let mut record = [0u8; STATUS_BYTES]; + for (slot, code) in codes.iter().enumerate() { + record[slot * 4..slot * 4 + 4].copy_from_slice(&code.to_ne_bytes()); + } + for (slot, value) in values.iter().enumerate() { + let base = SLOT_COUNT * 4 + slot * 8; + record[base..base + 8].copy_from_slice(&value.to_ne_bytes()); + } + libc::write( + STATUS_FD, + record.as_ptr() as *const libc::c_void, + STATUS_BYTES, + ); +} + +/// How many tasks the real user id already owns. +/// +/// `RLIMIT_NPROC` is a per-user ceiling, not a per-tree one, so a fixed number +/// would either be unreachable on an idle host or already breached on a busy +/// one. Counting first turns it into a real budget for this tree. +/// +/// Tasks, not processes. The kernel charges the limit per task, so a host +/// process with sixteen threads costs sixteen. Counting `/proc/` entries +/// alone undercounts a threaded host by an order of magnitude and produces a +/// budget the very next `fork` breaches. +#[cfg(target_os = "linux")] +fn current_process_count() -> Option { + use std::os::unix::fs::MetadataExt; + + let uid = unsafe { libc::getuid() }; + let mut count = 0u64; + for entry in fs::read_dir("/proc").ok()?.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if name.is_empty() || !name.bytes().all(|byte| byte.is_ascii_digit()) { + continue; + } + let Ok(metadata) = entry.metadata() else { + continue; + }; + if metadata.uid() != uid { + continue; + } + // A process that exits between the two reads still counted for one task + // while it existed, so the fallback is the safe direction. + count += match fs::read_dir(entry.path().join("task")) { + Ok(tasks) => tasks.count() as u64, + Err(_) => 1, + }; + } + Some(count) +} + +#[cfg(not(target_os = "linux"))] +fn current_process_count() -> Option { + None +} + +fn descriptor_scan_ceiling() -> u32 { + let mut current = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + let soft: u64 = if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut current) } == 0 { + current.rlim_cur + } else { + u64::from(MAX_DESCRIPTOR_SCAN) + }; + u32::try_from(soft.min(u64::from(MAX_DESCRIPTOR_SCAN))).unwrap_or(MAX_DESCRIPTOR_SCAN) +} + +/// A pipe whose two ends both close on `exec`. +/// +/// `pipe2` does it in one syscall where it exists; Darwin has no `pipe2`, so the +/// flag is set afterwards. The window between the two calls is not a leak risk +/// here: the descriptors are used by this process and by the child it is about +/// to fork, and nothing else runs in between. +fn close_on_exec_pipe() -> std::io::Result<[libc::c_int; 2]> { + let mut fds = [0 as libc::c_int; 2]; + #[cfg(target_os = "linux")] + let created = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) }; + #[cfg(not(target_os = "linux"))] + let created = unsafe { libc::pipe(fds.as_mut_ptr()) }; + if created != 0 { + return Err(std::io::Error::last_os_error()); + } + #[cfg(not(target_os = "linux"))] + for fd in fds { + if unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } != 0 { + let err = std::io::Error::last_os_error(); + unsafe { + libc::close(fds[0]); + libc::close(fds[1]); + } + return Err(err); + } + } + Ok(fds) +} + +/// One status pipe, and the plan the child will apply through it. +pub(crate) struct Containment { + read_end: OwnedFd, + /// Held only until the child is forked. The parent must drop it or the read + /// below never reaches end of file. + write_end: Option, + plan: ChildPlan, + limits: Limits, + /// Reasons the parent already knows, before the child says anything. + process_count_note: Option, +} + +impl Containment { + /// Build the pipe and the plan. Fails only if a pipe cannot be created. + pub(crate) fn prepare(limits: &Limits) -> std::io::Result { + // Both ends close on exec: the write end so the parent sees end of file + // the moment the child execs, the read end so the child never holds it. + let fds = close_on_exec_pipe()?; + let read_end = unsafe { OwnedFd::from_raw_fd(fds[0]) }; + let write_end = unsafe { OwnedFd::from_raw_fd(fds[1]) }; + + let (process_count, process_count_isolated, process_count_note) = match limits + .extra_processes + { + None => (NOT_REQUESTED, NOT_REQUESTED, None), + Some(extra) => match current_process_count() { + Some(current) => ( + current.saturating_add(extra), + extra.saturating_add(1), + None, + ), + None => ( + NOT_REQUESTED, + NOT_REQUESTED, + Some(format!( + "RLIMIT_NPROC counts every task of the real user id and {} cannot observe that count, so no process budget was set", + std::env::consts::OS + )), + ), + }, + }; + + Ok(Self { + plan: ChildPlan { + status_fd: std::os::unix::io::AsRawFd::as_raw_fd(&write_end), + descriptor_scan_ceiling: descriptor_scan_ceiling(), + cpu_seconds: limits.cpu_seconds, + max_file_bytes: limits.max_file_bytes, + max_address_space_bytes: limits.max_address_space_bytes.unwrap_or(NOT_REQUESTED), + process_count, + process_count_isolated, + max_descriptors: limits.max_descriptors, + isolate_network: limits.isolate_network, + userns_is_confined: userns_is_confined(), + }, + read_end, + write_end: Some(write_end), + limits: *limits, + process_count_note, + }) + } + + /// Register the pre-exec hook that applies the plan. + /// + /// Registering any pre-exec closure also takes `Command` off its + /// `posix_spawn` fast path, which is required: `posix_spawn` cannot run + /// arbitrary code in the child. + pub(crate) fn install(&self, command: &mut Command) { + let plan = self.plan; + unsafe { + command.pre_exec(move || { + apply_plan(&plan); + Ok(()) + }); + } + } + + /// Read the child's record after the fork. + /// + /// Must be called once the child exists. Blocks until `exec` closes the + /// write end, which is bounded: `exec` either happens or the child exits, + /// and both close the descriptor. + pub(crate) fn collect( + mut self, + terminated: bool, + image_integrity: ControlState, + ) -> ContainmentReport { + // The parent's own copy of the write end would keep the pipe open + // forever. + drop(self.write_end.take()); + + let mut bytes = Vec::with_capacity(STATUS_BYTES); + let mut file = fs::File::from(self.read_end); + let record = match file.read_to_end(&mut bytes) { + Ok(_) if bytes.len() == STATUS_BYTES => Some(bytes), + _ => None, + }; + + let (codes, values) = match &record { + Some(bytes) => { + let mut codes = [CODE_UNSUPPORTED; SLOT_COUNT]; + let mut values = [NOT_REQUESTED; SLOT_COUNT]; + for slot in 0..SLOT_COUNT { + codes[slot] = i32::from_ne_bytes( + bytes[slot * 4..slot * 4 + 4] + .try_into() + .expect("four bytes"), + ); + let base = SLOT_COUNT * 4 + slot * 8; + values[slot] = + u64::from_ne_bytes(bytes[base..base + 8].try_into().expect("eight bytes")); + } + (Some(codes), Some(values)) + } + None => (None, None), + }; + + let state = |slot: usize, control: &str| match (codes, values) { + (Some(codes), Some(values)) => { + ControlState::from_code(codes[slot], values[slot], control) + } + _ => ControlState::unavailable(format!( + "{control} cannot be reported: the child wrote no containment record" + )), + }; + + let process_count = match &self.process_count_note { + Some(note) => ControlState::unavailable(note.clone()), + None => state(SLOT_PROCESS_COUNT, "a per-user process budget"), + }; + + ContainmentReport { + wall_clock_deadline: ControlState::Applied { + limit: Some(self.limits.wall_clock.as_millis().min(u128::from(u64::MAX)) as u64), + }, + process_group: state(SLOT_SESSION, "a private session and process group"), + descriptor_isolation: state(SLOT_DESCRIPTOR_ISOLATION, "descriptor isolation"), + image_integrity, + cpu_seconds: state(SLOT_CPU, "a CPU time limit"), + file_size: state(SLOT_FILE_SIZE, "a file size limit"), + address_space: state(SLOT_ADDRESS_SPACE, "an address space limit"), + process_count, + descriptors: state(SLOT_DESCRIPTORS, "a descriptor limit"), + network: state(SLOT_NETWORK, "network isolation"), + stdout_bytes: ControlState::Applied { + limit: Some(self.limits.max_stdout_bytes), + }, + stderr_bytes: ControlState::Applied { + limit: Some(self.limits.max_stderr_bytes), + }, + model_bytes: ControlState::Applied { + limit: Some(self.limits.max_model_bytes), + }, + process_tree_terminated: terminated, + } + } +} + +/// Signal a whole process group and swallow the "already gone" case. +/// +/// The negative pid is the point: the child made itself a group leader, so this +/// reaches grandchildren the child forked and then abandoned. +pub(crate) fn kill_tree(pid: u32) -> bool { + let group = match i32::try_from(pid) { + Ok(pid) => -pid, + Err(_) => return false, + }; + unsafe { libc::kill(group, libc::SIGKILL) == 0 } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_defaulted_limit_set_is_bounded_everywhere() { + let limits = Limits::default(); + assert!(limits.wall_clock > Duration::ZERO); + for bound in [ + limits.cpu_seconds, + limits.max_file_bytes, + limits.max_descriptors, + limits.max_stdout_bytes, + limits.max_stderr_bytes, + limits.max_model_bytes, + limits.max_result_bytes, + limits.max_region_bytes, + ] { + assert!(bound > 0, "a default bound of zero is not a bound"); + } + } + + /// A missing record must degrade to "unavailable" for every child-applied + /// control rather than to a claim. + #[test] + fn a_child_that_reports_nothing_yields_no_applied_claim() { + let limits = Limits::default(); + let containment = Containment::prepare(&limits).expect("pipe"); + // Nothing was forked, so dropping the write end gives an empty read. + // The image's own state is not in that record and must survive it + // unchanged: the report carries what the image said, not what a missing + // record implies. + let integrity = ControlState::Applied { limit: None }; + let report = containment.collect(false, integrity.clone()); + assert_eq!( + report.image_integrity, integrity, + "the report did not carry the image's own state through" + ); + for (name, state) in report.controls() { + match name { + // Host-side controls need no child cooperation. + "wall_clock_deadline" + | "stdout_bytes" + | "stderr_bytes" + | "model_bytes" + | "image_integrity" => { + assert!( + state.is_applied(), + "{name} is host-side and must be applied" + ) + } + _ => assert!( + !state.is_applied(), + "{name} claims to be applied with no record from the child: {state:?}" + ), + } + } + } + + /// The record has to be small enough for one `write` to be atomic, or a + /// short read would be ambiguous between "child died" and "record split". + #[test] + fn the_status_record_fits_one_atomic_pipe_write() { + assert_eq!(STATUS_BYTES, 96); + } + + /// The switch that decides whether the user-namespace route to an empty + /// route table is affordable. Reading it wrong in the permissive direction + /// costs the run: on a host that answers `CLONE_NEWUSER` with a mandatory + /// access control profile, the child that took the namespace can no longer + /// execute an image that has no pathname. + #[cfg(target_os = "linux")] + #[test] + fn a_host_that_confines_unprivileged_user_namespaces_is_recognized() { + assert!(confinement_is_on("1\n"), "the restriction was read as off"); + assert!(confinement_is_on("2\n"), "any nonzero setting is on"); + assert!(!confinement_is_on("0\n"), "an off switch was read as on"); + assert!(!confinement_is_on(" 0 "), "surrounding space changed it"); + assert!( + confinement_is_on("unexpected"), + "an unreadable setting must fail towards keeping the run alive" + ); + } +} diff --git a/crates/flutterdec-adapter/src/store.rs b/crates/flutterdec-adapter/src/store.rs new file mode 100644 index 00000000..ff077021 --- /dev/null +++ b/crates/flutterdec-adapter/src/store.rs @@ -0,0 +1,1472 @@ +//! The local adapter store. +//! +//! The store is the one writable place in an installation. Package data stays +//! read-only: the compatibility registry decides *what* may be installed, and +//! this module only ever publishes bytes that already match a registry-declared +//! digest, size, host variant, and contained relative path. +//! +//! Publication is atomic in the sense that matters to a concurrent reader: each +//! file is staged under a temporary name **in its final directory** and then +//! renamed into place, so a reader sees either the old file or the new one and +//! never a half-written one. A cross-directory rename could land on another +//! filesystem and degrade to copy-then-truncate, which is exactly the partial +//! state this is meant to prevent. +//! +//! Concurrent installs are serialized by an exclusive `flock` on +//! `/.lock` held across the read/decide/publish sequence. Two processes +//! installing the same adapter at once therefore produce one install and one +//! idempotent no-op, not two racing rewrites of the state file. + +use flutterdec_loader::dart_profile::load_profile_artifact; +use flutterdec_loader::layout::Layout; +use flutterdec_loader::registry::{ + CompatibilityRecord, CompatibilityRegistry, HostArtifactVariant, MAX_ARTIFACT_BYTES, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fmt; +use std::fs; +use std::io::Read; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::os::unix::io::AsRawFd; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub const STORE_VERSION: u32 = 1; +pub const STATE_FILE: &str = "store.json"; +pub const LOCK_FILE: &str = ".lock"; +pub const MAX_STATE_BYTES: u64 = 4 * 1024 * 1024; + +const ARTIFACT_MODE: u32 = 0o755; +const STATE_MODE: u32 = 0o644; + +/// Test hook: fail immediately before a named publish step. +/// +/// Injected failure is the only way to prove "no partial state" for a step that +/// otherwise always succeeds, so the hook is part of the product rather than a +/// test-only build. +pub const FAIL_BEFORE_VAR: &str = "FLUTTERDEC_INSTALL_FAIL_BEFORE"; + +/// A publish step that can be failed on purpose through [`FAIL_BEFORE_VAR`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PublishStep { + /// Before the store lock is taken. + Lock, + /// After validation, before any temporary file is created. + Stage, + /// After staging, before the artifact rename. + PublishArtifact, + /// After the artifact is live, before the state file rename. + PublishState, +} + +impl PublishStep { + pub fn as_str(self) -> &'static str { + match self { + Self::Lock => "lock", + Self::Stage => "stage", + Self::PublishArtifact => "publish_artifact", + Self::PublishState => "publish_state", + } + } + + fn parse(text: &str) -> Option { + [ + Self::Lock, + Self::Stage, + Self::PublishArtifact, + Self::PublishState, + ] + .into_iter() + .find(|step| step.as_str() == text) + } +} + +// In-crate tests select the step through a thread-local rather than the +// environment variable, because a process-global variable set by one test is +// visible to every other test running beside it. +#[cfg(test)] +thread_local! { + static FAIL_OVERRIDE: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +#[cfg(test)] +fn set_fail_before(step: Option) { + FAIL_OVERRIDE.with(|cell| cell.set(step)); +} + +fn requested_fail_step() -> Option { + #[cfg(test)] + if let Some(step) = FAIL_OVERRIDE.with(|cell| cell.get()) { + return Some(step.as_str().to_string()); + } + std::env::var(FAIL_BEFORE_VAR) + .ok() + .filter(|value| !value.is_empty()) +} + +fn injected_failure(step: PublishStep) -> Result<(), StoreError> { + let requested = requested_fail_step(); + let Some(requested) = requested.as_deref() else { + return Ok(()); + }; + match PublishStep::parse(requested) { + Some(parsed) if parsed == step => Err(StoreError::Injected(step)), + Some(_) => Ok(()), + None => Err(StoreError::InvalidInput(format!( + "{FAIL_BEFORE_VAR}={requested} is not a publish step" + ))), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StoreError { + InvalidInput(String), + /// The registry has no record for this snapshot hash. + NoRecord(String), + /// More than one record shares the hash, so `install` cannot pick one. + Ambiguous(String), + /// The record does not serve this host or this target. + Incompatible(String), + /// The artifact source is not a usable regular file. + Source(String), + /// The bytes do not match the registry-declared content address. + DigestMismatch { + expected: String, + actual: String, + expected_size: u64, + actual_size: u64, + }, + /// A path would leave the store, or the destination is not a regular file. + Containment(String), + /// The profile the record points at is missing, oversized, or corrupt. + Profile(String), + /// The store's own state file cannot be trusted. + Malformed(String), + Io(String), + Injected(PublishStep), +} + +impl fmt::Display for StoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidInput(detail) => write!(f, "invalid input: {detail}"), + Self::NoRecord(hash) => write!( + f, + "no compatibility record for snapshot hash {hash}; the registry is the only install authority" + ), + Self::Ambiguous(detail) => write!(f, "ambiguous compatibility record: {detail}"), + Self::Incompatible(detail) => write!(f, "incompatible compatibility record: {detail}"), + Self::Source(detail) => write!(f, "adapter artifact source rejected: {detail}"), + Self::DigestMismatch { + expected, + actual, + expected_size, + actual_size, + } => write!( + f, + "adapter artifact does not match the compatibility record: expected {expected_size} bytes with {expected}, got {actual_size} bytes with {actual}" + ), + Self::Containment(detail) => write!(f, "adapter store path rejected: {detail}"), + Self::Profile(detail) => write!(f, "profile artifact rejected: {detail}"), + Self::Malformed(detail) => write!(f, "adapter store state is unusable: {detail}"), + Self::Io(detail) => write!(f, "adapter store I/O failed: {detail}"), + Self::Injected(step) => write!( + f, + "install failed on purpose before {} ({FAIL_BEFORE_VAR})", + step.as_str() + ), + } + } +} + +impl std::error::Error for StoreError {} + +fn io(context: &str, err: std::io::Error) -> StoreError { + StoreError::Io(format!("{context}: {err}")) +} + +/// One installed adapter, as the store claims it. +/// +/// Every field here is copied from the compatibility record that authorized the +/// install, so a later run can tell a stale install from a current one without +/// re-deriving anything from the file on disk. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InstalledAdapter { + pub snapshot_hash: String, + pub target_arch: String, + pub host_os: String, + pub host_arch: String, + pub artifact_id: String, + /// Store-relative path of the published executable. + pub artifact_path: String, + pub size: u64, + pub sha256: String, + pub parser_family_id: String, + pub profile_id: String, + pub profile_sha256: String, + pub compatibility_record_sha256: String, + pub protocol_major: u32, + pub model_major: u32, + /// Where the published bytes came from. + pub source: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StoreState { + pub version: u32, + #[serde(default)] + pub adapters: Vec, +} + +impl Default for StoreState { + fn default() -> Self { + Self { + version: STORE_VERSION, + adapters: Vec::new(), + } + } +} + +/// The verified state of one compatibility record on this host. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EntryState { + /// Installed, present, regular, and byte-for-byte what the record declares. + Verified, + /// The store claims an install whose artifact file is gone. + Missing, + /// The artifact is present but is not what the record declares. + Corrupt, + /// No artifact variant, or no supported protocol/model major, for this host. + Incompatible, + /// Authorized by a record but not installed. + Unavailable, +} + +impl EntryState { + pub fn as_str(self) -> &'static str { + match self { + Self::Verified => "verified", + Self::Missing => "missing", + Self::Corrupt => "corrupt", + Self::Incompatible => "incompatible", + Self::Unavailable => "unavailable", + } + } + + /// Whether this state is broken store content rather than a fact about the + /// host or an absent install. + pub fn is_failure(self) -> bool { + matches!(self, Self::Missing | Self::Corrupt) + } +} + +impl fmt::Display for EntryState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// One row of `adapter list`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct StoreEntry { + pub snapshot_hash: String, + pub state: EntryState, + pub artifact_id: String, + pub target_arch: String, + pub host_os: String, + pub host_arch: String, + pub artifact_path: Option, + pub expected_sha256: Option, + pub expected_size: Option, + pub profile_id: Option, + pub profile_sha256: Option, + pub compatibility_record_sha256: Option, + /// Why the state is not `verified`. + pub detail: Option, +} + +/// What one `install` did. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Installation { + pub record: InstalledAdapter, + /// True when the store already held exactly this install, so nothing was + /// written. + pub idempotent: bool, + pub store_dir: PathBuf, + pub artifact_path: PathBuf, + /// Absolute path of the verified profile the record points at. + pub profile_path: PathBuf, +} + +fn valid_snapshot_hash(hash: &str) -> bool { + hash.len() == 32 + && hash + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn digest_of(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +/// Reject anything that is not a plain contained relative path. +/// +/// Absolute paths, `..`, `.`, backslashes, and NUL all fail here rather than at +/// the filesystem, so a hostile registry cannot aim a write outside the store. +fn contained_relative(text: &str, label: &str) -> Result { + if text.is_empty() || text.contains('\\') || text.contains('\0') { + return Err(StoreError::Containment(format!( + "{label} {text:?} is not a contained relative path" + ))); + } + let path = Path::new(text); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(StoreError::Containment(format!( + "{label} {text:?} is not a contained relative path" + ))); + } + Ok(path.to_path_buf()) +} + +/// Read a regular file with a hard cap, refusing symlinks and non-files. +/// +/// `symlink_metadata` rather than `metadata`: a symlink that happens to point at +/// a regular file is still a path whose target can be swapped between the check +/// and the read. +fn read_regular_file(path: &Path, label: &str, max_bytes: u64) -> Result, StoreError> { + let metadata = fs::symlink_metadata(path) + .map_err(|err| StoreError::Source(format!("read {label} {}: {err}", path.display())))?; + if metadata.file_type().is_symlink() { + return Err(StoreError::Source(format!( + "{label} {} is a symbolic link", + path.display() + ))); + } + if !metadata.is_file() { + return Err(StoreError::Source(format!( + "{label} {} is not a regular file", + path.display() + ))); + } + if metadata.len() > max_bytes { + return Err(StoreError::Source(format!( + "{label} {} exceeds the {max_bytes} byte limit", + path.display() + ))); + } + let file = fs::File::open(path) + .map_err(|err| StoreError::Source(format!("open {label} {}: {err}", path.display())))?; + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(max_bytes + 1) + .read_to_end(&mut bytes) + .map_err(|err| StoreError::Source(format!("read {label} {}: {err}", path.display())))?; + if bytes.len() as u64 > max_bytes { + return Err(StoreError::Source(format!( + "{label} {} exceeds the {max_bytes} byte limit", + path.display() + ))); + } + Ok(bytes) +} + +/// An exclusive lock over the whole store, released on drop. +struct StoreLock { + file: fs::File, +} + +impl StoreLock { + fn acquire(store_dir: &Path) -> Result { + let path = store_dir.join(LOCK_FILE); + let file = fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&path) + .map_err(|err| io(&format!("open store lock {}", path.display()), err))?; + // Blocking, so a concurrent install waits instead of failing. The + // alternative, a try-lock plus retry loop, turns contention into a + // spurious error the operator has to interpret. + let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; + if rc != 0 { + return Err(io( + &format!("lock store {}", path.display()), + std::io::Error::last_os_error(), + )); + } + Ok(Self { file }) + } +} + +impl Drop for StoreLock { + fn drop(&mut self) { + unsafe { + libc::flock(self.file.as_raw_fd(), libc::LOCK_UN); + } + } +} + +/// A staged temporary file that removes itself unless it is published. +struct Staged { + path: PathBuf, + published: bool, +} + +impl Staged { + /// Stage `bytes` in `dest`'s own directory. + fn write(dest: &Path, bytes: &[u8], mode: u32) -> Result { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let parent = dest.parent().ok_or_else(|| { + StoreError::Containment(format!("{} has no parent directory", dest.display())) + })?; + let name = dest + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + StoreError::Containment(format!("{} has no file name", dest.display())) + })?; + let path = parent.join(format!( + ".{name}.tmp-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let staged = Self { + path, + published: false, + }; + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(mode) + .open(&staged.path) + .map_err(|err| io(&format!("stage {}", staged.path.display()), err))?; + use std::io::Write; + file.write_all(bytes) + .map_err(|err| io(&format!("write {}", staged.path.display()), err))?; + // Durability before visibility: a rename that beats its own data to disk + // publishes a name with no bytes behind it after a crash. + file.sync_all() + .map_err(|err| io(&format!("sync {}", staged.path.display()), err))?; + Ok(staged) + } + + fn publish(mut self, dest: &Path) -> Result<(), StoreError> { + fs::rename(&self.path, dest).map_err(|err| { + io( + &format!("publish {} as {}", self.path.display(), dest.display()), + err, + ) + })?; + self.published = true; + if let Some(parent) = dest.parent() { + // Directory entries need their own fsync; the file's sync says + // nothing about the name pointing at it. + if let Ok(dir) = fs::File::open(parent) { + let _ = dir.sync_all(); + } + } + Ok(()) + } +} + +impl Drop for Staged { + fn drop(&mut self) { + if !self.published { + let _ = fs::remove_file(&self.path); + } + } +} + +/// Resolve a store-relative destination, creating its directory inside the +/// store and refusing anything that leaves the store or is not a regular file. +fn destination(store_dir: &Path, relative: &str) -> Result { + let relative = contained_relative(relative, "artifact path")?; + fs::create_dir_all(store_dir) + .map_err(|err| io(&format!("create store {}", store_dir.display()), err))?; + let canonical_store = store_dir + .canonicalize() + .map_err(|err| io(&format!("canonicalize store {}", store_dir.display()), err))?; + let dest = canonical_store.join(&relative); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent) + .map_err(|err| io(&format!("create {}", parent.display()), err))?; + // The directory chain is canonicalized after creation, so a component + // that is a symbolic link out of the store is caught even though the + // final file does not exist yet. + let canonical_parent = parent + .canonicalize() + .map_err(|err| io(&format!("canonicalize {}", parent.display()), err))?; + if !canonical_parent.starts_with(&canonical_store) { + return Err(StoreError::Containment(format!( + "{} escapes the adapter store {}", + parent.display(), + canonical_store.display() + ))); + } + } + if let Ok(metadata) = fs::symlink_metadata(&dest) { + if metadata.file_type().is_symlink() { + return Err(StoreError::Containment(format!( + "{} is a symbolic link", + dest.display() + ))); + } + if !metadata.is_file() { + return Err(StoreError::Containment(format!( + "{} is not a regular file", + dest.display() + ))); + } + } + Ok(dest) +} + +pub fn state_path(store_dir: &Path) -> PathBuf { + store_dir.join(STATE_FILE) +} + +/// Read the store state. An absent store is an empty store, a malformed one is +/// an error. +pub fn load_state(store_dir: &Path) -> Result { + let path = state_path(store_dir); + match fs::symlink_metadata(&path) { + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(StoreState::default()), + Err(err) => return Err(io(&format!("read {}", path.display()), err)), + Ok(metadata) => { + if !metadata.is_file() { + return Err(StoreError::Malformed(format!( + "{} is not a regular file", + path.display() + ))); + } + if metadata.len() > MAX_STATE_BYTES { + return Err(StoreError::Malformed(format!( + "{} exceeds the {MAX_STATE_BYTES} byte limit", + path.display() + ))); + } + } + } + let bytes = fs::read(&path).map_err(|err| io(&format!("read {}", path.display()), err))?; + let state = serde_json::from_slice::(&bytes) + .map_err(|err| StoreError::Malformed(format!("parse {}: {err}", path.display())))?; + if state.version != STORE_VERSION { + return Err(StoreError::Malformed(format!( + "{} declares version {} rather than {STORE_VERSION}", + path.display(), + state.version + ))); + } + Ok(state) +} + +fn variant_for_host<'a>( + record: &'a CompatibilityRecord, + host_os: &str, + host_arch: &str, +) -> Option<&'a HostArtifactVariant> { + record + .artifact + .variants + .iter() + .find(|variant| variant.host_os == host_os && variant.host_arch == host_arch) +} + +/// Reject a record whose wire majors this build cannot speak. +fn supported_majors(record: &CompatibilityRecord) -> Result<(), String> { + if record.protocol_major != 1 || record.model_major != crate::model::MODEL_VERSION { + return Err(format!( + "record declares protocol/model majors {}/{} rather than 1/{}", + record.protocol_major, + record.model_major, + crate::model::MODEL_VERSION + )); + } + Ok(()) +} + +/// Select the single record that authorizes installing `hash`. +/// +/// `install` has no snapshot in scope, so it cannot resolve a feature tuple. +/// More than one record for a hash is therefore refused rather than guessed at. +fn select_record<'a>( + registry: &'a CompatibilityRegistry, + hash: &str, + target_arch: Option<&str>, +) -> Result<&'a CompatibilityRecord, StoreError> { + let candidates = registry + .records + .iter() + .filter(|record| record.snapshot_hash == hash) + .collect::>(); + if candidates.is_empty() { + return Err(StoreError::NoRecord(hash.to_string())); + } + let candidates = match target_arch { + Some(requested) => { + let filtered = candidates + .iter() + .copied() + .filter(|record| record.target_arch.as_str() == requested) + .collect::>(); + if filtered.is_empty() { + return Err(StoreError::Incompatible(format!( + "no record for snapshot hash {hash} targets {requested}" + ))); + } + filtered + } + None => candidates, + }; + if candidates.len() > 1 { + let fingerprints = candidates + .iter() + .map(|record| record.feature_fingerprint.as_str()) + .collect::>() + .join(", "); + return Err(StoreError::Ambiguous(format!( + "{} records share snapshot hash {hash} (feature fingerprints {fingerprints})", + candidates.len() + ))); + } + Ok(candidates[0]) +} + +/// Verify the profile the record points at, in the read-only data directory. +fn verify_profile(layout: &Layout, record: &CompatibilityRecord) -> Result { + let relative = contained_relative(&record.profile.path, "profile path")?; + let path = layout.data_dir().join(&relative); + load_profile_artifact( + &path, + &record.profile.id, + &record.profile.sha256, + record.sdk_aliases.clone(), + ) + .map_err(StoreError::Profile)?; + Ok(path) +} + +/// Install the adapter authorized for `hash` into the layout's store. +/// +/// `source` overrides the artifact bytes; the default is the checked-in producer +/// in the read-only data directory. Either way the bytes must match the record's +/// declared digest and size, so an operator cannot install something the +/// registry did not authorize. +pub fn install( + layout: &Layout, + registry: &CompatibilityRegistry, + hash: &str, + target_arch: Option<&str>, + source: Option<&Path>, +) -> Result { + if !valid_snapshot_hash(hash) { + return Err(StoreError::InvalidInput(format!( + "snapshot hash {hash:?} is not 32 lowercase hexadecimal characters" + ))); + } + let record = select_record(registry, hash, target_arch)?; + supported_majors(record).map_err(StoreError::Incompatible)?; + + let host_os = std::env::consts::OS; + let host_arch = std::env::consts::ARCH; + let variant = variant_for_host(record, host_os, host_arch).ok_or_else(|| { + StoreError::Incompatible(format!( + "record for snapshot hash {hash} has no artifact variant for host {host_os}/{host_arch}" + )) + })?; + + let profile_path = verify_profile(layout, record)?; + let record_sha256 = record + .sha256() + .map_err(|err| StoreError::Malformed(err.to_string()))?; + + let (bytes, source_label) = match source { + Some(path) => ( + read_regular_file(path, "adapter artifact source", MAX_ARTIFACT_BYTES)?, + format!("operator:{}", path.display()), + ), + None => ( + read_regular_file( + &layout.producer_path(), + "packaged producer", + MAX_ARTIFACT_BYTES, + )?, + "packaged-producer".to_string(), + ), + }; + let actual = digest_of(&bytes); + if actual != variant.sha256 || bytes.len() as u64 != variant.size { + return Err(StoreError::DigestMismatch { + expected: variant.sha256.clone(), + actual, + expected_size: variant.size, + actual_size: bytes.len() as u64, + }); + } + + let installed = InstalledAdapter { + snapshot_hash: hash.to_string(), + target_arch: record.target_arch.as_str().to_string(), + host_os: host_os.to_string(), + host_arch: host_arch.to_string(), + artifact_id: record.artifact.id.clone(), + artifact_path: variant.path.clone(), + size: variant.size, + sha256: variant.sha256.clone(), + parser_family_id: record.parser_family.id.clone(), + profile_id: record.profile.id.clone(), + profile_sha256: record.profile.sha256.clone(), + compatibility_record_sha256: record_sha256, + protocol_major: record.protocol_major, + model_major: record.model_major, + source: source_label, + }; + + injected_failure(PublishStep::Lock)?; + fs::create_dir_all(layout.store_dir()).map_err(|err| { + io( + &format!("create store {}", layout.store_dir().display()), + err, + ) + })?; + let _lock = StoreLock::acquire(layout.store_dir())?; + + let dest = destination(layout.store_dir(), &variant.path)?; + let state_dest = destination(layout.store_dir(), STATE_FILE)?; + let mut state = load_state(layout.store_dir())?; + + // Idempotence is decided under the lock, against both the state entry and + // the bytes on disk, so "already installed" cannot be claimed for a record + // whose artifact was deleted or edited after the fact. + let existing = state.adapters.iter().position(|entry| { + entry.snapshot_hash == installed.snapshot_hash + && entry.host_os == installed.host_os + && entry.host_arch == installed.host_arch + }); + if let Some(index) = existing { + if state.adapters[index] == installed && artifact_matches(&dest, &installed).is_ok() { + return Ok(Installation { + record: installed, + idempotent: true, + store_dir: layout.store_dir().to_path_buf(), + artifact_path: dest, + profile_path, + }); + } + } + + injected_failure(PublishStep::Stage)?; + // Captured before anything is replaced. If the state file cannot be + // published, the artifact is put back exactly as it was: a live artifact no + // state file mentions is precisely the partial state this must not leave. + let previous_artifact = match fs::read(&dest) { + Ok(bytes) => Some(bytes), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, + Err(err) => return Err(io(&format!("read {}", dest.display()), err)), + }; + let staged_artifact = Staged::write(&dest, &bytes, ARTIFACT_MODE)?; + match existing { + Some(index) => state.adapters[index] = installed.clone(), + None => state.adapters.push(installed.clone()), + } + state.adapters.sort_by(|left, right| { + (&left.snapshot_hash, &left.host_os, &left.host_arch).cmp(&( + &right.snapshot_hash, + &right.host_os, + &right.host_arch, + )) + }); + let mut state_bytes = serde_json::to_vec_pretty(&state) + .map_err(|err| StoreError::Malformed(format!("serialize store state: {err}")))?; + state_bytes.push(b'\n'); + let staged_state = Staged::write(&state_dest, &state_bytes, STATE_MODE)?; + + injected_failure(PublishStep::PublishArtifact)?; + staged_artifact.publish(&dest)?; + if let Err(err) = injected_failure(PublishStep::PublishState) + .and_then(move |()| staged_state.publish(&state_dest)) + { + return Err( + match restore_artifact(&dest, previous_artifact.as_deref()) { + Ok(()) => err, + Err(rollback) => StoreError::Io(format!( + "{err}; and restoring {} failed: {rollback}", + dest.display() + )), + }, + ); + } + + Ok(Installation { + record: installed, + idempotent: false, + store_dir: layout.store_dir().to_path_buf(), + artifact_path: dest, + profile_path, + }) +} + +/// Undo a published artifact, back to absent or back to its previous bytes. +fn restore_artifact(dest: &Path, previous: Option<&[u8]>) -> Result<(), StoreError> { + match previous { + Some(bytes) => Staged::write(dest, bytes, ARTIFACT_MODE)?.publish(dest), + None => fs::remove_file(dest).map_err(|err| io(&format!("remove {}", dest.display()), err)), + } +} + +/// Compare the file at `path` against what the store claims about it. +fn artifact_matches(path: &Path, installed: &InstalledAdapter) -> Result<(), (EntryState, String)> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(( + EntryState::Missing, + format!("{} is registered but absent", path.display()), + )) + } + Err(err) => { + return Err(( + EntryState::Corrupt, + format!("read {}: {err}", path.display()), + )) + } + }; + if metadata.file_type().is_symlink() { + return Err(( + EntryState::Corrupt, + format!("{} is a symbolic link", path.display()), + )); + } + if !metadata.is_file() { + return Err(( + EntryState::Corrupt, + format!("{} is not a regular file", path.display()), + )); + } + if metadata.len() != installed.size { + return Err(( + EntryState::Corrupt, + format!( + "{} is {} bytes, expected {}", + path.display(), + metadata.len(), + installed.size + ), + )); + } + if metadata.permissions().mode() & 0o111 == 0 { + return Err(( + EntryState::Corrupt, + format!("{} is not executable", path.display()), + )); + } + let bytes = match read_regular_file(path, "installed adapter", MAX_ARTIFACT_BYTES) { + Ok(bytes) => bytes, + Err(err) => return Err((EntryState::Corrupt, err.to_string())), + }; + let actual = digest_of(&bytes); + if actual != installed.sha256 { + return Err(( + EntryState::Corrupt, + format!( + "{} has SHA-256 {actual}, expected {}", + path.display(), + installed.sha256 + ), + )); + } + Ok(()) +} + +/// What the store ledger claims about `record` on this host. +/// +/// Ledger only: nothing here reads the artifact. A published file is not an +/// install. Two compatibility records can name one artifact path with one +/// digest — the shipped registry has exactly that pair — so the file being +/// present says that somebody installed something and not which record they +/// installed it for. The ledger entry says, because it is keyed by the snapshot +/// hash the install was authorized under. +fn ledger_claim<'a>( + state: &'a StoreState, + record: &CompatibilityRecord, + variant: &HostArtifactVariant, + host_os: &str, + host_arch: &str, +) -> Result<&'a InstalledAdapter, (EntryState, String)> { + let installed = state + .adapters + .iter() + .find(|entry| { + entry.snapshot_hash == record.snapshot_hash + && entry.host_os == host_os + && entry.host_arch == host_arch + }) + .ok_or_else(|| { + ( + EntryState::Unavailable, + "not installed in the local adapter store".to_string(), + ) + })?; + if installed.sha256 != variant.sha256 + || installed.size != variant.size + || installed.artifact_path != variant.path + { + return Err(( + EntryState::Corrupt, + format!( + "installed record claims {} bytes with {} at {}, the compatibility record declares {} bytes with {} at {}", + installed.size, + installed.sha256, + installed.artifact_path, + variant.size, + variant.sha256, + variant.path + ), + )); + } + Ok(installed) +} + +/// The ledger's claim for `record` on this host, loading the store state. +/// +/// This is the authority `adapter list` reports from, exposed so that deciding +/// whether an adapter may run and telling an operator whether one is installed +/// are the same question answered once. The refusal carries the [`EntryState`] +/// the listing would print for this record, so a caller can report the state in +/// the operator's own vocabulary rather than inventing a second one. +/// +/// Whether the bytes on disk are still the declared bytes is deliberately not +/// asked here. Callers that are about to execute read and digest the artifact +/// themselves, exactly once, and a second read would only widen the window +/// between the check and the use. +pub fn installed_for( + store_dir: &Path, + record: &CompatibilityRecord, +) -> Result { + let host_os = std::env::consts::OS; + let host_arch = std::env::consts::ARCH; + let state = load_state(store_dir).map_err(|err| (EntryState::Corrupt, err.to_string()))?; + let variant = variant_for_host(record, host_os, host_arch).ok_or_else(|| { + ( + EntryState::Incompatible, + format!("no artifact variant for host {host_os}/{host_arch}"), + ) + })?; + ledger_claim(&state, record, variant, host_os, host_arch).cloned() +} + +/// The state of every record the registry authorizes, plus any installed +/// adapter the registry no longer authorizes. +/// +/// File existence is never the answer: a `verified` row means the bytes were +/// read and hashed against the record that authorized them. +pub fn inspect( + layout: &Layout, + registry: &CompatibilityRegistry, +) -> Result, StoreError> { + let state = load_state(layout.store_dir())?; + let host_os = std::env::consts::OS; + let host_arch = std::env::consts::ARCH; + let mut rows = Vec::new(); + // Computed before the record walk rather than inside it: a record that + // turns out to be incompatible still accounts for its own store entry, and + // reporting that entry twice would read as two separate problems. + let claimed = state + .adapters + .iter() + .filter(|entry| { + entry.host_os == host_os + && entry.host_arch == host_arch + && registry + .records + .iter() + .any(|record| record.snapshot_hash == entry.snapshot_hash) + }) + .map(|entry| { + ( + entry.snapshot_hash.clone(), + entry.host_os.clone(), + entry.host_arch.clone(), + ) + }) + .collect::>(); + + for record in ®istry.records { + let mut row = StoreEntry { + snapshot_hash: record.snapshot_hash.clone(), + state: EntryState::Unavailable, + artifact_id: record.artifact.id.clone(), + target_arch: record.target_arch.as_str().to_string(), + host_os: host_os.to_string(), + host_arch: host_arch.to_string(), + artifact_path: None, + expected_sha256: None, + expected_size: None, + profile_id: Some(record.profile.id.clone()), + profile_sha256: Some(record.profile.sha256.clone()), + compatibility_record_sha256: record.sha256().ok(), + detail: None, + }; + if let Err(detail) = supported_majors(record) { + row.state = EntryState::Incompatible; + row.detail = Some(detail); + rows.push(row); + continue; + } + let Some(variant) = variant_for_host(record, host_os, host_arch) else { + row.state = EntryState::Incompatible; + row.detail = Some(format!( + "no artifact variant for host {host_os}/{host_arch}" + )); + rows.push(row); + continue; + }; + row.artifact_path = Some(variant.path.clone()); + row.expected_sha256 = Some(variant.sha256.clone()); + row.expected_size = Some(variant.size); + + let installed = match ledger_claim(&state, record, variant, host_os, host_arch) { + Ok(installed) => installed, + Err((state, detail)) => { + row.state = state; + row.detail = Some(detail); + rows.push(row); + continue; + } + }; + let path = layout.store_dir().join(&installed.artifact_path); + match artifact_matches(&path, installed) { + Ok(()) => row.state = EntryState::Verified, + Err((state, detail)) => { + row.state = state; + row.detail = Some(detail); + } + } + rows.push(row); + } + + // An install the registry no longer authorizes is reported rather than + // hidden: the registry is the authority, so the store entry is the thing + // that is wrong. + for installed in &state.adapters { + let key = ( + installed.snapshot_hash.clone(), + installed.host_os.clone(), + installed.host_arch.clone(), + ); + if claimed.contains(&key) { + continue; + } + if registry + .records + .iter() + .any(|record| record.snapshot_hash == installed.snapshot_hash) + && (installed.host_os != host_os || installed.host_arch != host_arch) + { + // Installed for a different host. Not this host's problem. + continue; + } + rows.push(StoreEntry { + snapshot_hash: installed.snapshot_hash.clone(), + state: EntryState::Incompatible, + artifact_id: installed.artifact_id.clone(), + target_arch: installed.target_arch.clone(), + host_os: installed.host_os.clone(), + host_arch: installed.host_arch.clone(), + artifact_path: Some(installed.artifact_path.clone()), + expected_sha256: Some(installed.sha256.clone()), + expected_size: Some(installed.size), + profile_id: Some(installed.profile_id.clone()), + profile_sha256: Some(installed.profile_sha256.clone()), + compatibility_record_sha256: Some(installed.compatibility_record_sha256.clone()), + detail: Some("no compatibility record authorizes this installed adapter".to_string()), + }); + } + + rows.sort_by(|left, right| { + (&left.snapshot_hash, &left.host_os, &left.host_arch).cmp(&( + &right.snapshot_hash, + &right.host_os, + &right.host_arch, + )) + }); + Ok(rows) +} + +#[cfg(test)] +mod tests { + use super::*; + use flutterdec_loader::identity::{SnapshotKind, TargetArch}; + use flutterdec_loader::registry::{ + canonical_feature_fingerprint, ArtifactReference, CompatibilityEvidence, + CompatibilityRecord, CompatibilityRegistry, ParserFamilyReference, ProfileReference, + TrustTier, REGISTRY_VERSION, + }; + use tempfile::TempDir; + + const HASH: &str = "80a49c7111088100a233b2ae788e1f48"; + const PRODUCER: &str = "#!/bin/sh\nexit 0\n"; + + /// A packaged data directory plus an empty store, both in one temp tree. + struct Fixture { + _dir: TempDir, + layout: Layout, + registry: CompatibilityRegistry, + } + + fn profile_json() -> String { + serde_json::to_string_pretty(&serde_json::json!({ + "profiles": { + "test-profile": { + "tag_style": "CID_INT32", + "compressed_word_size": 4, + "header_fields": 5, + "max_alignment": 16, + "heap_object_tag": 1, + "cids": {"class": 1, "object_pool": 23} + } + } + })) + .expect("profile json") + } + + /// `variant` decides the store-relative artifact path, which is what the + /// traversal and symlink cases need to control. + fn fixture(artifact_relative: &str, host_os: &str, host_arch: &str) -> Fixture { + let dir = TempDir::new().expect("tempdir"); + let data = dir.path().join("share/flutterdec"); + let store = dir.path().join("store"); + fs::create_dir_all(data.join("data")).expect("mkdir data"); + fs::create_dir_all(data.join("adapters/python")).expect("mkdir adapters"); + let profile = profile_json(); + fs::write(data.join("data/test-profile.json"), &profile).expect("write profile"); + fs::write(data.join(PRODUCER_RELATIVE), PRODUCER).expect("write producer"); + + let features = ["android", "arm64", "compressed-pointers", "product"] + .into_iter() + .map(ToString::to_string) + .collect::>(); + let record = CompatibilityRecord { + snapshot_hash: HASH.to_string(), + snapshot_kind: SnapshotKind::FullAot, + target_arch: TargetArch::Arm64, + feature_fingerprint: canonical_feature_fingerprint(&features), + features, + known_features: Vec::new(), + forbidden_features: Vec::new(), + sdk_aliases: Vec::new(), + parser_family: ParserFamilyReference { + id: "fixture-family".to_string(), + version: Some("1".to_string()), + sha256: None, + }, + profile: ProfileReference { + id: "test-profile".to_string(), + path: "data/test-profile.json".to_string(), + sha256: digest_of(profile.as_bytes()), + }, + artifact: ArtifactReference { + id: "fixture-artifact".to_string(), + variants: vec![HostArtifactVariant { + host_os: host_os.to_string(), + host_arch: host_arch.to_string(), + path: artifact_relative.to_string(), + size: PRODUCER.len() as u64, + sha256: digest_of(PRODUCER.as_bytes()), + provenance: "fixture".to_string(), + }], + }, + evidence: CompatibilityEvidence { + source: "fixture".to_string(), + provenance: "unit test".to_string(), + references: Vec::new(), + }, + trust_tier: TrustTier::Experimental, + protocol_major: 1, + model_major: crate::model::MODEL_VERSION, + }; + let registry = CompatibilityRegistry { + version: REGISTRY_VERSION, + records: vec![record], + }; + let layout = Layout::new(data, store, dir.path().join("symbols")); + Fixture { + _dir: dir, + layout, + registry, + } + } + + const PRODUCER_RELATIVE: &str = "adapters/python/adapter_template.py"; + + fn host() -> (&'static str, &'static str) { + (std::env::consts::OS, std::env::consts::ARCH) + } + + fn default_fixture() -> Fixture { + let (os, arch) = host(); + fixture("artifacts/dart_adapter", os, arch) + } + + fn install_default(fixture: &Fixture) -> Result { + install(&fixture.layout, &fixture.registry, HASH, None, None) + } + + /// Nothing of the install exists: no artifact, no state entry, no leftover + /// temporary file anywhere under the store. + fn assert_store_is_untouched(store: &Path) { + let state = state_path(store); + assert!(!state.exists(), "{} exists", state.display()); + let mut leftovers = Vec::new(); + let mut stack = vec![store.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + if path.file_name().and_then(|name| name.to_str()) == Some(LOCK_FILE) { + continue; + } + leftovers.push(path); + } + } + assert!( + leftovers.is_empty(), + "store holds files after a failed install: {leftovers:?}" + ); + } + + #[test] + fn install_publishes_a_verified_artifact_and_is_idempotent() { + let fixture = default_fixture(); + let first = install_default(&fixture).expect("install"); + assert!(!first.idempotent); + assert_eq!( + fs::read(&first.artifact_path).expect("read"), + PRODUCER.as_bytes() + ); + let mode = fs::metadata(&first.artifact_path) + .expect("metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o777, ARTIFACT_MODE, "artifact is not executable"); + + let state = load_state(fixture.layout.store_dir()).expect("state"); + assert_eq!(state.adapters.len(), 1); + assert_eq!(state.adapters[0].sha256, digest_of(PRODUCER.as_bytes())); + + let before = fs::read(state_path(fixture.layout.store_dir())).expect("state bytes"); + let second = install_default(&fixture).expect("reinstall"); + assert!(second.idempotent, "a repeated install rewrote the store"); + assert_eq!(second.record, first.record); + assert_eq!( + fs::read(state_path(fixture.layout.store_dir())).expect("state bytes"), + before + ); + + let rows = inspect(&fixture.layout, &fixture.registry).expect("inspect"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].state, EntryState::Verified); + } + + #[test] + fn a_source_that_does_not_match_the_record_publishes_nothing() { + let fixture = default_fixture(); + let other = fixture.layout.data_dir().join("other.sh"); + fs::write(&other, "#!/bin/sh\nexit 1\n").expect("write other"); + let err = install(&fixture.layout, &fixture.registry, HASH, None, Some(&other)) + .expect_err("wrong bytes cannot be installed"); + assert!(matches!(err, StoreError::DigestMismatch { .. }), "{err}"); + assert_store_is_untouched(fixture.layout.store_dir()); + } + + #[test] + fn a_source_that_is_not_a_regular_file_is_refused() { + let fixture = default_fixture(); + let dir = fixture.layout.data_dir().join("a_directory"); + fs::create_dir_all(&dir).expect("mkdir"); + let err = install(&fixture.layout, &fixture.registry, HASH, None, Some(&dir)) + .expect_err("a directory is not an artifact"); + assert!(matches!(err, StoreError::Source(_)), "{err}"); + + let link = fixture.layout.data_dir().join("a_link"); + std::os::unix::fs::symlink(fixture.layout.producer_path(), &link).expect("symlink"); + let err = install(&fixture.layout, &fixture.registry, HASH, None, Some(&link)) + .expect_err("a symbolic link is not an artifact"); + assert!(matches!(err, StoreError::Source(_)), "{err}"); + assert_store_is_untouched(fixture.layout.store_dir()); + } + + #[test] + fn a_record_path_that_leaves_the_store_is_refused() { + for relative in [ + "../escape", + "/etc/escape", + "artifacts/../../escape", + "./escape", + ] { + let (os, arch) = host(); + let fixture = fixture(relative, os, arch); + match install_default(&fixture) { + Err(StoreError::Containment(_)) => {} + other => panic!("{relative:?} was not refused: {other:?}"), + } + assert_store_is_untouched(fixture.layout.store_dir()); + } + } + + #[test] + fn a_store_directory_that_is_a_symlink_out_of_the_store_is_refused() { + let fixture = default_fixture(); + let outside = fixture.layout.data_dir().parent().unwrap().join("outside"); + fs::create_dir_all(&outside).expect("mkdir outside"); + fs::create_dir_all(fixture.layout.store_dir()).expect("mkdir store"); + std::os::unix::fs::symlink(&outside, fixture.layout.store_dir().join("artifacts")) + .expect("symlink artifacts"); + + let err = install_default(&fixture).expect_err("a symlinked store directory escapes"); + assert!(matches!(err, StoreError::Containment(_)), "{err}"); + assert!( + fs::read_dir(&outside) + .expect("read outside") + .next() + .is_none(), + "the install wrote outside the store" + ); + } + + #[test] + fn a_host_the_record_does_not_serve_is_refused() { + let fixture = fixture("artifacts/dart_adapter", "plan9", "vax"); + let err = install_default(&fixture).expect_err("no variant for this host"); + assert!(matches!(err, StoreError::Incompatible(_)), "{err}"); + assert_store_is_untouched(fixture.layout.store_dir()); + + let rows = inspect(&fixture.layout, &fixture.registry).expect("inspect"); + assert_eq!(rows[0].state, EntryState::Incompatible); + } + + #[test] + fn a_target_the_record_does_not_serve_is_refused() { + let fixture = default_fixture(); + let err = install(&fixture.layout, &fixture.registry, HASH, Some("x64"), None) + .expect_err("the record targets arm64"); + assert!(matches!(err, StoreError::Incompatible(_)), "{err}"); + assert_store_is_untouched(fixture.layout.store_dir()); + } + + #[test] + fn an_unregistered_or_malformed_hash_is_refused() { + let fixture = default_fixture(); + for hash in ["", "ZZZ", "80A49C7111088100A233B2AE788E1F48", "80a49c71"] { + let err = install(&fixture.layout, &fixture.registry, hash, None, None) + .expect_err("bad hash syntax"); + assert!(matches!(err, StoreError::InvalidInput(_)), "{hash}: {err}"); + } + let err = install( + &fixture.layout, + &fixture.registry, + "00000000000000000000000000000000", + None, + None, + ) + .expect_err("no record"); + assert!(matches!(err, StoreError::NoRecord(_)), "{err}"); + assert_store_is_untouched(fixture.layout.store_dir()); + } + + /// Every publish step, failed on purpose, leaves the store as it was. + #[test] + fn an_injected_failure_leaves_no_partial_state() { + for step in [ + PublishStep::Lock, + PublishStep::Stage, + PublishStep::PublishArtifact, + PublishStep::PublishState, + ] { + let fixture = default_fixture(); + set_fail_before(Some(step)); + let result = install_default(&fixture); + set_fail_before(None); + let err = result.expect_err("the injected failure did not fail"); + assert_eq!(err, StoreError::Injected(step)); + assert_store_is_untouched(fixture.layout.store_dir()); + } + } + + /// A file with the right name is not an install. This is the distinction the + /// old existence check could not make. + #[test] + fn inspect_never_treats_existence_as_installation() { + let fixture = default_fixture(); + let path = fixture.layout.store_dir().join("artifacts/dart_adapter"); + fs::create_dir_all(path.parent().unwrap()).expect("mkdir"); + fs::write(&path, PRODUCER).expect("write imposter"); + + let rows = inspect(&fixture.layout, &fixture.registry).expect("inspect"); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].state, + EntryState::Unavailable, + "a file with the right name was reported as installed" + ); + } + + #[test] + fn inspect_separates_a_missing_artifact_from_a_corrupt_one() { + let fixture = default_fixture(); + let installed = install_default(&fixture).expect("install"); + + let mut edited = PRODUCER.as_bytes().to_vec(); + let last = edited.len() - 2; + edited[last] = b'1'; + fs::write(&installed.artifact_path, &edited).expect("edit artifact"); + let rows = inspect(&fixture.layout, &fixture.registry).expect("inspect"); + assert_eq!(rows[0].state, EntryState::Corrupt); + assert!(rows[0].detail.as_deref().unwrap().contains("SHA-256")); + + fs::remove_file(&installed.artifact_path).expect("remove artifact"); + let rows = inspect(&fixture.layout, &fixture.registry).expect("inspect"); + assert_eq!(rows[0].state, EntryState::Missing); + } + + #[test] + fn a_malformed_state_file_is_an_error_rather_than_an_empty_store() { + let fixture = default_fixture(); + fs::create_dir_all(fixture.layout.store_dir()).expect("mkdir store"); + fs::write(state_path(fixture.layout.store_dir()), "{ not json").expect("write state"); + let err = inspect(&fixture.layout, &fixture.registry).expect_err("malformed state"); + assert!(matches!(err, StoreError::Malformed(_)), "{err}"); + } + + /// The profile is verified in the read-only data directory, so a record + /// pointing at a digest that no longer matches cannot be installed. + #[test] + fn a_profile_that_does_not_match_its_digest_is_refused() { + let fixture = default_fixture(); + fs::write( + fixture.layout.data_dir().join("data/test-profile.json"), + "{\"profiles\": {}}", + ) + .expect("rewrite profile"); + let err = install_default(&fixture).expect_err("profile digest changed"); + assert!(matches!(err, StoreError::Profile(_)), "{err}"); + assert_store_is_untouched(fixture.layout.store_dir()); + } +} diff --git a/crates/flutterdec-adapter/src/validate.rs b/crates/flutterdec-adapter/src/validate.rs index 699e4701..aef479d9 100644 --- a/crates/flutterdec-adapter/src/validate.rs +++ b/crates/flutterdec-adapter/src/validate.rs @@ -31,7 +31,13 @@ use std::fmt; pub struct HostSelectedContext { pub identity: SnapshotIdentity, pub producer: Producer, - pub compatibility: CompatibilityBinding, + /// The registry decision the host acted on, or `None` when the host + /// recovered the program itself and no record authorized anything. + /// + /// An adapter run always has one, so a model that answers an adapter request + /// with `null` here fails the same equality check that catches a model + /// claiming someone else's record. + pub compatibility: Option, pub regions: Vec, } @@ -40,7 +46,13 @@ pub struct HostSelectedContext { /// A model must leave an unknown name absent. Writing one of these into a /// required field is how v3 turned "no name" into a name, and every consumer /// downstream then treated it as one. -const PLACEHOLDER_NAMES: &[&str] = &[ +/// Strings like "unknown", "n/a", or "" are matched case-insensitively +/// because they are never valid Dart identifiers. Plain "none", "null", and +/// "nil" are matched exact/lowercase-only: in Dart `Null` is a standard core +/// class (`dart:core::Null`), and `None` or `Nil` are common class names in +/// functional Dart packages, whereas lowercase "null" / "none" / "nil" are +/// dummy sentinel values emitted by naive adapter scripts. +const CASE_INSENSITIVE_PLACEHOLDERS: &[&str] = &[ "", "-", "?", @@ -48,9 +60,6 @@ const PLACEHOLDER_NAMES: &[&str] = &[ "???", "n/a", "na", - "none", - "null", - "nil", "todo", "tbd", "unknown", @@ -61,9 +70,15 @@ const PLACEHOLDER_NAMES: &[&str] = &[ "undefined", ]; +const EXACT_LOWERCASE_PLACEHOLDERS: &[&str] = &["none", "null", "nil"]; + fn is_placeholder(text: &str) -> bool { - let normalized = text.trim().to_ascii_lowercase(); - PLACEHOLDER_NAMES.contains(&normalized.as_str()) + let trimmed = text.trim(); + if EXACT_LOWERCASE_PLACEHOLDERS.contains(&trimmed) { + return true; + } + let normalized = trimmed.to_ascii_lowercase(); + CASE_INSENSITIVE_PLACEHOLDERS.contains(&normalized.as_str()) } /// The first invariant a model breaks. @@ -427,24 +442,39 @@ fn check_host_facts(model: &ProgramModel, host: &HostSelectedContext) -> Check { } } - let compatibility_fields: [(&'static str, bool); 4] = [ - ( - "compatibility record digest", - model.compatibility.record_sha256 == host.compatibility.record_sha256, - ), - ( - "parser family", - model.compatibility.parser_family_id == host.compatibility.parser_family_id, - ), - ( - "profile id", - model.compatibility.profile_id == host.compatibility.profile_id, - ), - ( - "profile digest", - model.compatibility.profile_sha256 == host.compatibility.profile_sha256, - ), - ]; + // Presence is checked first and separately: a model that drops the binding + // entirely is a different failure from one that carries someone else's, and + // the two arms below cannot be expressed as one field comparison. + let compatibility_fields: [(&'static str, bool); 4] = + match (&model.compatibility, &host.compatibility) { + (None, None) => [("", true); 4], + (model_binding, host_binding) => { + let (Some(model_binding), Some(host_binding)) = (model_binding, host_binding) + else { + return Err(ValidationError::HostFactMismatch { + field: "compatibility binding presence", + }); + }; + [ + ( + "compatibility record digest", + model_binding.record_sha256 == host_binding.record_sha256, + ), + ( + "parser family", + model_binding.parser_family_id == host_binding.parser_family_id, + ), + ( + "profile id", + model_binding.profile_id == host_binding.profile_id, + ), + ( + "profile digest", + model_binding.profile_sha256 == host_binding.profile_sha256, + ), + ] + } + }; for (field, matches) in compatibility_fields { if !matches { return Err(ValidationError::HostFactMismatch { field }); @@ -508,8 +538,10 @@ fn not_placeholder(value: &str, field: &'static str) -> Check { fn check_strings(model: &ProgramModel) -> Check { non_empty(&model.producer.id, "producer id")?; non_empty(&model.producer.version, "producer version")?; - non_empty(&model.compatibility.parser_family_id, "parser family id")?; - non_empty(&model.compatibility.profile_id, "profile id")?; + if let Some(compatibility) = &model.compatibility { + non_empty(&compatibility.parser_family_id, "parser family id")?; + non_empty(&compatibility.profile_id, "profile id")?; + } for library in &model.libraries { not_placeholder(&library.uri, "library uri")?; if let Some(display) = &library.display_name { diff --git a/crates/flutterdec-adapter/tests/host_execution.rs b/crates/flutterdec-adapter/tests/host_execution.rs new file mode 100644 index 00000000..4dcc1481 --- /dev/null +++ b/crates/flutterdec-adapter/tests/host_execution.rs @@ -0,0 +1,1494 @@ +//! Hostile adapters, run for real. +//! +//! Every adapter in this file is a real executable that does something a host +//! must survive: sleep past its deadline, fork and abandon a child, flood a +//! pipe, crash, lie about what it produced, or try to consume more of the +//! machine than it was given. Nothing here is mocked, because the failure modes +//! under test are properties of processes and pipes rather than of Rust types. +//! +//! Each adapter can also leave evidence outside its own workspace. The one path +//! it knows about that the host did not create is `--input-path`, so probes are +//! written beside it; that is how a test can inspect the cwd, environment and +//! descriptors of a child that is long gone, and how "the workspace was removed" +//! becomes checkable rather than assumed. + +mod support; + +use flutterdec_adapter::model::{ + InputRegion, InputRegionName, Producer, ProgramModel, {CompatibilityBinding, ObservedInput}, +}; +use flutterdec_adapter::primitives::Sha256Digest; +use flutterdec_adapter::protocol::{AdapterErrorCode, AdapterStatus, RequestedBackend}; +use flutterdec_adapter::{ + run_adapter, AdapterInput, AdapterRegionInput, AdapterRun, ContainmentReport, HostError, + LibappSource, Limits, OutputStream, +}; +use flutterdec_loader::identity::SnapshotIdentity; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use tempfile::TempDir; + +const RET: [u8; 4] = 0xD65F_03C0u32.to_le_bytes(); + +/// The one environment variable a child must never see. +const SECRET: &str = "FLUTTERDEC_TEST_HOST_SECRET"; +const SECRET_VALUE: &str = "a-host-secret-no-adapter-may-read"; + +/// Shared preamble for every hostile adapter. +/// +/// It gives each one the request, a way to answer correctly, and a way to leave +/// a probe beside `--input-path`. What each adapter does with them is the test. +const PRELUDE: &str = r#"#!/usr/bin/env python3 +import argparse, json, os, pathlib, sys + +P = argparse.ArgumentParser() +P.add_argument("--request", required=True) +P.add_argument("--result", required=True) +P.add_argument("--input-path") +P.add_argument("--libapp-path") +ARGS = P.parse_args() +REQUEST = json.loads(pathlib.Path(ARGS.request).read_text()) +OUTPUT = REQUEST["output"] + + +def sidecar(name): + return pathlib.Path(ARGS.input_path + "." + name) + + +def write_model(text=None): + pathlib.Path(OUTPUT).write_text( + text if text is not None else sidecar("model").read_text() + ) + + +def write_result(**over): + doc = { + "protocol_major": 1, + "model_major": 4, + "status": "ok", + "model": OUTPUT, + "error": None, + "resolved_backend": "internal", + "fallback_reason": None, + "diagnostics": [], + } + doc.update(over) + pathlib.Path(ARGS.result).write_text(json.dumps(doc)) + + +def succeed(**over): + write_model() + write_result(**over) +"#; + +/// One hostile adapter, published as the artifact a record authorizes. +struct Rig { + dir: TempDir, + installed: support::Authorized, + identity: SnapshotIdentity, + input_path: PathBuf, + regions: Vec>, +} + +impl Rig { + /// `body` is appended to [`PRELUDE`] and is what the adapter actually does. + fn new(body: &str) -> Self { + Self::named(body, None) + } + + /// The same adapter, published under a file name the caller chooses. + fn named(body: &str, file_name: Option<&str>) -> Self { + let dir = TempDir::new().expect("tempdir"); + let source = dir.path().join("hostile_adapter"); + fs::write(&source, format!("{PRELUDE}\n{body}")).expect("write hostile adapter"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o755)).expect("chmod"); + Self::publish(dir, &source, file_name) + } + + /// An adapter that is already an executable file: a compiled one, rather + /// than a script the kernel hands to an interpreter. + fn built(dir: TempDir, executable: &Path) -> Self { + Self::publish(dir, executable, None) + } + + /// Authorize `source` as this snapshot's adapter and prepare the answer a + /// well-behaved one would give. + fn publish(dir: TempDir, source: &Path, file_name: Option<&str>) -> Self { + let identity = support::identity(); + let installed = support::Authorized::install_named(source, &identity, file_name); + let input_path = dir.path().join("app.apk"); + fs::write(&input_path, b"not really a zip").expect("write input"); + + let rig = Self { + dir, + installed, + identity, + input_path, + regions: vec![vec![0u8; 64], vec![0u8; 64], RET.to_vec(), RET.repeat(4)], + }; + // The valid answer, prepared by the host side so a hostile adapter that + // is only hostile in one respect can still be correct in every other. + fs::write(rig.sidecar("model"), rig.valid_model().to_canonical_json()) + .expect("write the prepared model"); + rig + } + + fn sidecar(&self, name: &str) -> PathBuf { + self.dir.path().join(format!("app.apk.{name}")) + } + + fn host_regions(&self) -> Vec { + self.region_inputs() + .into_iter() + .map(|region| InputRegion { + region: region.region, + size: region.bytes.len() as u64, + sha256: Sha256Digest::of(region.bytes), + virtual_address: region.virtual_address, + executable: region.region.is_executable(), + }) + .collect() + } + + /// A model that carries exactly the host facts this invocation was built + /// from, and admits it recovered nothing. + fn valid_model(&self) -> ProgramModel { + let mut model = support::unavailable_model(); + model.producer = self.producer(); + model.compatibility = Some(self.binding()); + model.input = ObservedInput { + identity: self.identity.clone(), + regions: self.host_regions(), + }; + model + } + + fn producer(&self) -> Producer { + self.installed.producer() + } + + fn binding(&self) -> CompatibilityBinding { + self.installed.binding() + } + + fn region_inputs(&self) -> Vec> { + vec![ + AdapterRegionInput { + region: InputRegionName::VmData, + bytes: &self.regions[0], + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::IsolateData, + bytes: &self.regions[1], + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::VmInstructions, + bytes: &self.regions[2], + virtual_address: Some(0x1000), + }, + AdapterRegionInput { + region: InputRegionName::IsolateInstructions, + bytes: &self.regions[3], + virtual_address: Some(0x2000), + }, + ] + } + + fn input(&self, limits: Limits) -> AdapterInput<'_> { + AdapterInput { + identity: &self.identity, + authorization: self.installed.authorization(), + producer: self.producer(), + compatibility: self.binding(), + regions: self.region_inputs(), + input_path: Some(&self.input_path), + libapp: Some(LibappSource::File(&self.input_path)), + requested_backend: RequestedBackend::Auto, + limits, + } + } + + fn run(&self, limits: Limits) -> Result { + run_adapter(&self.installed.exec, &self.input(limits)) + } + + fn fail(&self, limits: Limits) -> HostError { + let err = self + .run(limits) + .expect_err("this adapter cannot produce a usable model"); + assert!( + !err.is_pre_spawn(), + "a failure of a running child was classified as a pre-spawn refusal: {err}" + ); + err + } +} + +/// Short deadlines everywhere, so a test that is supposed to hit a bound does it +/// in test time rather than in adapter time. +fn brisk() -> Limits { + Limits { + wall_clock: Duration::from_millis(1500), + ..Limits::default() + } +} + +// -- VAL-HOST-002: bounded, process-tree-safe execution ----------------------- + +#[test] +fn an_adapter_that_never_finishes_is_terminated_at_its_deadline() { + let rig = Rig::new("import time\ntime.sleep(600)\n"); + let started = Instant::now(); + let err = rig.fail(Limits { + wall_clock: Duration::from_millis(400), + ..Limits::default() + }); + let elapsed = started.elapsed(); + + assert!( + matches!(err, HostError::Timeout { .. }), + "wrong failure: {err}" + ); + assert!( + elapsed < Duration::from_secs(20), + "the host waited {elapsed:?} on a 400ms deadline" + ); + assert!( + elapsed >= Duration::from_millis(400), + "the host gave up in {elapsed:?}, before the deadline it promised" + ); +} + +/// A backend that shells out and abandons the child is the ordinary case, not an +/// exotic one. The grandchild must not outlive the run, and it must not hold the +/// host's pipes open either. +#[test] +fn a_grandchild_the_adapter_abandoned_does_not_outlive_the_run() { + let rig = Rig::new( + r#"import os, time +if os.fork() == 0: + # Detached from the adapter's own lifetime on purpose. + time.sleep(4) + sidecar("grandchild").write_text("still running") + os._exit(0) +time.sleep(0.1) +raise SystemExit(3) +"#, + ); + let started = Instant::now(); + let err = rig.fail(brisk()); + let elapsed = started.elapsed(); + + assert!( + matches!(err, HostError::NoResult { .. }), + "wrong failure: {err}" + ); + assert!( + elapsed < Duration::from_secs(3), + "the host waited {elapsed:?} for a grandchild that was holding its pipes open" + ); + std::thread::sleep(Duration::from_secs(6)); + assert!( + !rig.sidecar("grandchild").exists(), + "a grandchild outlived the adapter run that created it" + ); +} + +#[test] +fn an_adapter_that_floods_a_stream_is_capped_and_terminated() { + for (stream, target) in [ + (OutputStream::Stdout, "stdout"), + (OutputStream::Stderr, "stderr"), + ] { + let rig = Rig::new(&format!( + "import sys\nblock = 'x' * 65536\nwhile True:\n sys.{target}.write(block)\n sys.{target}.flush()\n" + )); + let started = Instant::now(); + let err = rig.fail(Limits { + max_stdout_bytes: 256 * 1024, + max_stderr_bytes: 256 * 1024, + ..brisk() + }); + assert!( + matches!( + err, + HostError::OutputLimitExceeded { stream: seen, limit } if seen == stream && limit == 256 * 1024 + ), + "{target}: wrong failure: {err}" + ); + assert!( + started.elapsed() < Duration::from_secs(20), + "{target}: the flood was not stopped promptly" + ); + } +} + +#[test] +fn an_oversized_model_is_refused_by_size_rather_than_read() { + let rig = Rig::new( + r#"write_model("[" + "0," * 400000 + "0]") +write_result() +"#, + ); + let err = rig.fail(Limits { + max_model_bytes: 4096, + ..brisk() + }); + assert!( + matches!( + err, + HostError::DocumentTooLarge { ref document, limit: 4096, .. } if document == "model" + ), + "wrong failure: {err}" + ); +} + +#[test] +fn an_oversized_result_document_is_refused() { + let rig = Rig::new( + r#"write_model() +write_result(diagnostics=[ + {"code": "domain_not_recovered", "severity": "warning", + "subject": "functions", "message": "x" * 200000} +]) +"#, + ); + let err = rig.fail(Limits { + max_result_bytes: 4096, + ..brisk() + }); + assert!( + matches!( + err, + HostError::DocumentTooLarge { ref document, limit: 4096, .. } if document == "result" + ), + "wrong failure: {err}" + ); +} + +#[test] +fn an_adapter_that_crashes_is_reported_as_a_signal() { + let rig = Rig::new("import os, signal\nos.kill(os.getpid(), signal.SIGSEGV)\n"); + let err = rig.fail(brisk()); + assert!( + matches!(err, HostError::Crashed { signal, .. } if signal == libc::SIGSEGV), + "wrong failure: {err}" + ); +} + +#[test] +fn a_nonzero_exit_with_no_result_is_reported_with_bounded_output() { + let rig = Rig::new( + r#"import sys +sys.stderr.write("A" * 500000) +sys.stderr.write("\nthe last line is the one that matters\n") +raise SystemExit(9) +"#, + ); + let err = rig.fail(brisk()); + let HostError::NoResult { ref stderr, .. } = err else { + panic!("wrong failure: {err}"); + }; + assert!( + stderr.contains("the last line is the one that matters"), + "the excerpt dropped the useful end of the stream" + ); + assert!( + stderr.len() < 4096, + "a diagnostic carried {} bytes of child output", + stderr.len() + ); + assert!( + format!("{err}").len() < 8192, + "the rendered error is not bounded" + ); +} + +#[test] +fn a_malformed_result_document_is_rejected_as_such() { + let rig = Rig::new( + r#"import pathlib +write_model() +pathlib.Path(ARGS.result).write_text("{ this is not json") +"#, + ); + let err = rig.fail(brisk()); + assert!( + matches!( + err, + HostError::MalformedDocument { ref document, .. } if document == "result" + ), + "wrong failure: {err}" + ); +} + +#[test] +fn a_malformed_model_document_is_rejected_as_such() { + let rig = Rig::new("write_model(\"{\\\"model_version\\\": 4}\")\nwrite_result()\n"); + let err = rig.fail(brisk()); + assert!( + matches!( + err, + HostError::MalformedDocument { ref document, .. } if document == "model" + ), + "wrong failure: {err}" + ); +} + +/// A model that claims a different snapshot than the one the host read. The +/// adapter does not get to describe its own input. +#[test] +fn a_model_that_claims_another_identity_is_rejected() { + let rig = Rig::new( + r#"import json +model = json.loads(sidecar("model").read_text()) +model["input"]["identity"]["hash"] = "ffffffffffffffffffffffffffffffff" +write_model(json.dumps(model)) +write_result() +"#, + ); + let err = rig.fail(brisk()); + assert!( + matches!(err, HostError::ModelRejected(_)), + "wrong failure: {err}" + ); +} + +#[test] +fn a_model_written_somewhere_other_than_the_output_handle_is_refused() { + let rig = Rig::new( + r#"import pathlib +pathlib.Path("elsewhere.json").write_text(sidecar("model").read_text()) +write_result(model="elsewhere.json") +"#, + ); + let err = rig.fail(brisk()); + assert!( + matches!(err, HostError::ModelPathMismatch { .. }), + "wrong failure: {err}" + ); +} + +#[test] +fn an_adapter_that_reports_failure_is_reported_verbatim_and_bounded() { + let rig = Rig::new( + r#"write_result(status="failed", model=None, resolved_backend=None, + error={"code": "parse_failed", "message": "B" * 300000}) +"#, + ); + let err = rig.fail(Limits { + max_result_bytes: 1024 * 1024, + ..brisk() + }); + let HostError::AdapterFailed { + status, + code, + ref message, + } = err + else { + panic!("wrong failure: {err}"); + }; + assert_eq!(status, AdapterStatus::Failed); + assert_eq!(code, AdapterErrorCode::ParseFailed); + assert!( + message.len() < 4096, + "the adapter's own message was quoted unbounded: {} bytes", + message.len() + ); +} + +// -- VAL-HOST-003: workspace and inherited authority -------------------------- + +/// The probe every isolation case runs. +/// +/// It records what the child could see, then answers correctly, so the same +/// adapter serves both the success path and the "what was visible" question. +const PROBE: &str = r#"import json, os, pathlib, sys + +cwd = os.getcwd() +visible = [] +try: + for name in sorted(os.listdir("/proc/self/fd")): + try: + visible.append(os.readlink("/proc/self/fd/" + name)) + except OSError: + pass +except OSError: + visible = None + +inputs_writable = {} +for handle in REQUEST["inputs"]: + try: + with open(handle["path"], "ab") as fp: + fp.write(b"tampered") + inputs_writable[handle["region"]] = True + except OSError: + inputs_writable[handle["region"]] = False + +sidecar("probe").write_text(json.dumps({ + "cwd": cwd, + "cwd_mode": oct(os.stat(cwd).st_mode & 0o7777), + "env": dict(os.environ), + "fds": visible, + "inputs_writable": inputs_writable, + # Resolved, because `os.getcwd()` above is resolved too and Darwin's + # temporary directory lives behind a `/var` -> `/private/var` symlink. Two + # spellings of one directory would fail a containment check that is true. + "home": os.path.realpath(os.environ["HOME"]) if "HOME" in os.environ else None, + "tmpdir": os.path.realpath(os.environ["TMPDIR"]) if "TMPDIR" in os.environ else None, + "stdin_is_tty": sys.stdin.isatty(), + "stdin_read": (lambda: sys.stdin.read(16))(), +})) +"#; + +#[derive(serde::Deserialize)] +struct Probe { + cwd: String, + cwd_mode: String, + env: std::collections::BTreeMap, + fds: Option>, + inputs_writable: std::collections::BTreeMap, + home: Option, + tmpdir: Option, + stdin_read: String, +} + +fn probe_of(rig: &Rig) -> Probe { + serde_json::from_slice(&fs::read(rig.sidecar("probe")).expect("the adapter wrote a probe")) + .expect("the probe is JSON") +} + +/// A file the host has open across the run, whose name is unmistakable if it +/// ever shows up in a child's descriptor table. +fn open_sentinel(dir: &Path) -> fs::File { + let path = dir.join("host-only-descriptor-sentinel"); + fs::File::create(path).expect("open the descriptor sentinel") +} + +#[test] +fn an_invocation_sees_a_private_directory_and_nothing_of_the_host() { + std::env::set_var(SECRET, SECRET_VALUE); + let rig = Rig::new(&format!("{PROBE}\nsucceed()\n")); + let _sentinel = open_sentinel(rig.dir.path()); + + let run = rig.run(brisk()).expect("the probe adapter succeeds"); + assert!(run.model.functions.is_empty()); + let probe = probe_of(&rig); + + assert_eq!( + probe.cwd_mode, "0o700", + "the invocation directory is readable by someone other than its owner" + ); + assert!( + !Path::new(&probe.cwd).exists(), + "the invocation directory {} survived a successful run", + probe.cwd + ); + + // The environment is an allowlist, not a filter of things that looked + // dangerous. Anything not named is simply not there. + let allowed: std::collections::BTreeSet<&str> = [ + "PATH", + "LANG", + "LC_ALL", + "PYTHON", + "XDG_CACHE_HOME", + "FLUTTERDEC_BLUTTER_CMD", + "FLUTTERDEC_BLUTTER_PY", + "FLUTTERDEC_R2FLUTTER_CMD", + "FLUTTERDEC_R2FLUTTER_BIN", + "FLUTTERDEC_R2FLUTTER_TIMEOUT", + "HOME", + "TMPDIR", + "PWD", + ] + .into_iter() + .collect(); + // Darwin's CoreFoundation writes this into its *own* environment while the + // library initializes, so it appears in any child that links it no matter + // what the host passed. It is the platform naming the user's text encoding + // in the child, not a host variable that leaked through: the host builds the + // child's environment vector explicitly and this name is not in it. + #[cfg(not(target_os = "linux"))] + let allowed = { + let mut allowed = allowed; + allowed.insert("__CF_USER_TEXT_ENCODING"); + allowed + }; + for name in probe.env.keys() { + assert!( + allowed.contains(name.as_str()), + "the adapter inherited {name}, which is not on the allowlist" + ); + } + assert!( + !probe.env.contains_key(SECRET), + "the adapter inherited the host secret" + ); + let rendered = serde_json::to_string(&probe.env).expect("env is serializable"); + assert!( + !rendered.contains(SECRET_VALUE), + "the secret's value reached the adapter under another name" + ); + + // `HOME` and `TMPDIR` point inside the workspace, so an adapter that writes + // to either writes somewhere that gets cleaned up. + for (label, value) in [ + ("HOME", probe.home.clone()), + ("TMPDIR", probe.tmpdir.clone()), + ] { + let value = value.unwrap_or_else(|| panic!("{label} is set")); + assert!( + Path::new(&value).starts_with(&probe.cwd), + "{label} is {value}, which is outside the invocation directory" + ); + } + + for (region, writable) in &probe.inputs_writable { + assert!( + !writable, + "the adapter was able to rewrite its own input handle {region}" + ); + } + + assert_eq!(probe.stdin_read, "", "stdin is not empty"); + + if let Some(fds) = &probe.fds { + for target in fds { + assert!( + !target.contains("host-only-descriptor-sentinel"), + "the adapter inherited an unrelated host descriptor: {target}" + ); + } + assert!( + fds.len() <= 8, + "the adapter started with {} descriptors open: {fds:?}", + fds.len() + ); + } +} + +#[test] +fn the_invocation_directory_is_removed_after_a_failure_and_after_a_timeout() { + let failing = Rig::new(&format!("{PROBE}\nraise SystemExit(4)\n")); + let err = failing.fail(brisk()); + assert!( + matches!(err, HostError::NoResult { .. }), + "unexpected: {err}" + ); + let probe = probe_of(&failing); + assert!( + !Path::new(&probe.cwd).exists(), + "the invocation directory {} survived a failed run", + probe.cwd + ); + + let hanging = Rig::new(&format!("{PROBE}\nimport time\ntime.sleep(600)\n")); + let err = hanging.fail(Limits { + wall_clock: Duration::from_millis(600), + ..Limits::default() + }); + assert!( + matches!(err, HostError::Timeout { .. }), + "unexpected: {err}" + ); + let probe = probe_of(&hanging); + assert!( + !Path::new(&probe.cwd).exists(), + "the invocation directory {} survived a timed-out run", + probe.cwd + ); +} + +/// An adapter that makes its own workspace unremovable must not be able to leak +/// it onto the host. +#[test] +fn a_workspace_the_adapter_sealed_is_still_removed() { + let rig = Rig::new( + r#"import os, pathlib +d = pathlib.Path("sealed") +d.mkdir() +(d / "inside").write_text("x") +os.chmod(d, 0o500) +sidecar("probe").write_text('{"cwd": %s, "cwd_mode": "0o700", "env": {}, "fds": null, "inputs_writable": {}, "home": null, "tmpdir": null, "stdin_read": ""}' % __import__("json").dumps(os.getcwd())) +succeed() +"#, + ); + rig.run(brisk()).expect("the adapter succeeds"); + let probe = probe_of(&rig); + assert!( + !Path::new(&probe.cwd).exists(), + "an adapter kept its workspace alive by making a directory unwritable: {}", + probe.cwd + ); +} + +// -- VAL-HOST-006: what the child actually is --------------------------------- + +/// A `#!` adapter is the awkward case and the one this project actually ships: +/// the kernel cannot hand an interpreter a descriptor, so it hands it a name for +/// one, and a host that got this wrong would either fail to start scripts at all +/// or quietly fall back to a file anyone could re-point. +/// +/// The adapter reports what it is: the name it was started under, the digest of +/// the bytes behind that name, every executable file it can find in its own +/// workspace, and what happened when it tried to change its own image through +/// the only name it has. The last of those is the portable half of the property. +/// Where the image is an anonymous descriptor the name is `/dev/fd/N` and the +/// seals refuse the write; where the platform cannot execute a descriptor the +/// name is a real path and the kernel refuses the write, the rename and the +/// unlink because the host froze it. Either way the answer to "can the bytes +/// behind this name change while they run" is no, and it is answered by the +/// kernel rather than by a mode bit. +/// +/// Then it goes one step further, where the platform allows it: the freeze is a +/// *user* flag, this process is the owner, so it clears the flag and replaces +/// its own image behind its own back. That is the ceiling the frozen-pathname +/// platform has, it is exercised here rather than described in a comment, and +/// what the host is required to do about it is report it — `image_integrity` is +/// `Applied` only for the sealed anonymous inode, and names `UF_IMMUTABLE` and +/// who can clear it everywhere else. +#[test] +fn a_shebang_adapter_runs_from_an_image_that_cannot_be_repointed() { + let rig = Rig::new( + r#"import hashlib + +executables = [] +for base, _dirs, files in os.walk(os.getcwd()): + for name in files: + found = os.path.join(base, name) + try: + if os.stat(found).st_mode & 0o111: + executables.append(found) + except OSError: + pass + +image = sys.argv[0] +# Read before anything below touches the image: the point of the digest is what +# the interpreter was handed, and the last thing this adapter does is replace +# that file behind its own back. +digest = hashlib.sha256(pathlib.Path(image).read_bytes()).hexdigest() + +def attempt(action): + try: + action() + return "succeeded" + except OSError as exc: + return exc.strerror or type(exc).__name__ + +def overwrite(): + with open(image, "r+b") as handle: + handle.write(b"\x00") + handle.flush() + +mutations = { + "overwrite": attempt(overwrite), + "rename": attempt(lambda: os.rename(image, image + ".moved")), + "unlink": attempt(lambda: os.unlink(image)), +} + + +def thaw(): + chflags = getattr(os, "chflags", None) + if chflags is None: + raise OSError("this platform has no chflags") + chflags(image, 0) + + +# Unlink and recreate rather than write in place: the interpreter is still +# reading this file through the descriptor it opened, and rewriting the bytes +# underneath it would be a test of the interpreter. +def replace(): + os.unlink(image) + with open(image, "wb") as handle: + handle.write(b'#!/bin/sh\nexit 9\n') + os.chmod(image, 0o755) + + +mutations["chflags"] = attempt(thaw) +mutations["replace_after_chflags"] = ( + attempt(replace) if mutations["chflags"] == "succeeded" else "not attempted" +) + +sidecar("image").write_text(json.dumps({ + "argv0": image, + "sha256": digest, + "workspace_executables": executables, + "cwd": os.getcwd(), + "mutations": mutations, +})) +succeed() +"#, + ); + + let run = rig.run(brisk()).expect("the shebang adapter succeeds"); + assert!(run.model.functions.is_empty()); + + #[derive(serde::Deserialize)] + struct Image { + argv0: String, + sha256: String, + workspace_executables: Vec, + #[allow(dead_code)] + cwd: String, + mutations: std::collections::BTreeMap, + } + let image: Image = + serde_json::from_slice(&fs::read(rig.sidecar("image")).expect("the adapter wrote a probe")) + .expect("the probe is JSON"); + + let authorized = fs::read(&rig.installed.exec).expect("read the authorized artifact"); + assert_eq!( + image.sha256, + support::hex_digest(&authorized), + "the interpreter read something other than the verified artifact" + ); + + // The portable half: whatever the name is, it is not a way to change what + // is running. + for step in ["overwrite", "rename", "unlink"] { + let outcome = image + .mutations + .get(step) + .unwrap_or_else(|| panic!("the adapter did not report {step}: {:?}", image.mutations)); + assert_ne!( + outcome, "succeeded", + "the running adapter could {step} its own image through {}", + image.argv0 + ); + } + + // The Linux half, which is stronger because the platform allows it: the + // image is not on any filesystem at all. + #[cfg(target_os = "linux")] + { + assert!( + image.workspace_executables.is_empty(), + "the invocation workspace holds an executable pathname: {:?}", + image.workspace_executables + ); + assert!( + !image.argv0.starts_with(&image.cwd), + "the adapter was started from a path inside its own workspace: {}", + image.argv0 + ); + assert!( + image.argv0.starts_with("/dev/fd/") || image.argv0.starts_with("/proc/self/fd/"), + "the adapter was started from a filesystem pathname: {}", + image.argv0 + ); + assert!( + run.containment.image_integrity.is_applied(), + "an image the host sealed and read the seals back off was not reported as one: {:?}", + run.containment.image_integrity + ); + } + + // Elsewhere a descriptor cannot be executed, so the image is a path — and + // then it has to be the *only* executable path the invocation exposes. The + // two spellings can differ by the platform's own symlinks (`/var` against + // `/private/var`), so the final component is what is compared. + #[cfg(not(target_os = "linux"))] + { + let image_name = image + .argv0 + .rsplit('/') + .next() + .expect("argv[0] has a final component"); + assert_eq!( + image.workspace_executables.len(), + 1, + "the invocation workspace exposes an executable other than the frozen image: {:?}", + image.workspace_executables + ); + assert!( + image.workspace_executables[0].ends_with(image_name), + "the executable in the workspace is not the image the adapter is running: {:?} against {}", + image.workspace_executables, + image.argv0 + ); + + // The observed ceiling. The owner of the image cleared the host's flag + // and put other bytes at the name, which is exactly the attack the + // report has to stay honest about. + let outcome = |step: &str| -> String { + image + .mutations + .get(step) + .unwrap_or_else(|| { + panic!("the adapter did not report {step}: {:?}", image.mutations) + }) + .clone() + }; + assert_eq!( + outcome("chflags"), + "succeeded", + "the image's freeze could not be cleared by its own owner, so this platform is stronger than the host reports: {:?}", + image.mutations + ); + assert_eq!( + outcome("replace_after_chflags"), + "succeeded", + "the image could not be replaced once unfrozen, so this platform is stronger than the host reports: {:?}", + image.mutations + ); + + // So the host must not have claimed the guarantee it does not have. + let flutterdec_adapter::ControlState::Unavailable { reason } = + &run.containment.image_integrity + else { + panic!( + "the host reported a sealed image on a platform where the owner just replaced it: {:?}", + run.containment.image_integrity + ); + }; + assert!( + reason.contains("UF_IMMUTABLE"), + "the reported image integrity does not name the flag it rests on: {reason}" + ); + assert!( + reason.contains("clear"), + "the reported image integrity does not say the flag is the owner's to clear: {reason}" + ); + } +} + +// -- VAL-HOST-008: containment must not cost the ability to execute ----------- + +/// The two halves of this host can be made to fight, and one kernel in ordinary +/// use makes them fight. +/// +/// Asking for an empty route table without privileges means asking for a user +/// namespace, and some kernels answer that by placing the caller under a +/// mandatory access control profile rather than by refusing. Such a profile +/// decides what may be executed by *pathname*, and the image deliberately has +/// none — so a child that took the namespace could no longer start the adapter +/// at all, and every run on that host failed before a process existed. +/// +/// The rule is that the run wins. An unavailable network control is a reported, +/// bounded loss; a host that cannot execute a verified image is not a host. +#[test] +fn network_isolation_is_never_bought_with_the_ability_to_execute() { + let rig = Rig::new( + "succeed() +", + ); + let run = rig + .run(brisk()) + .expect("the adapter runs whatever the host could or could not isolate"); + assert!(run.model.functions.is_empty()); + + #[cfg(target_os = "linux")] + if fs::read_to_string("/proc/sys/kernel/apparmor_restrict_unprivileged_userns") + .map(|value| value.trim() != "0") + .unwrap_or(false) + { + assert!( + matches!( + run.containment.network, + flutterdec_adapter::ControlState::Unavailable { .. } + ), + "the host took a user namespace on a kernel that confines one, which is paid for with the ability to execute a pathless image: {:?}", + run.containment.network + ); + } +} + +// -- VAL-HOST-004: platform containment claims -------------------------------- + +fn successful_report(limits: Limits) -> (ContainmentReport, Rig) { + let rig = Rig::new("succeed()\n"); + let run = rig.run(limits).expect("the adapter succeeds"); + (run.containment, rig) +} + +#[test] +fn every_named_control_is_reported_as_applied_or_unavailable() { + let (report, _rig) = successful_report(brisk()); + for (name, state) in report.controls() { + match state { + flutterdec_adapter::ControlState::Applied { .. } => {} + flutterdec_adapter::ControlState::Unavailable { reason } => assert!( + !reason.trim().is_empty(), + "{name} is unavailable without saying why" + ), + } + } + assert!( + !report.process_tree_terminated, + "a run that finished on its own was reported as one the host had to end" + ); + // These need nothing the host cannot do for itself. + assert!(report.wall_clock_deadline.is_applied()); + assert!(report.stdout_bytes.is_applied()); + assert!(report.stderr_bytes.is_applied()); + assert!(report.model_bytes.is_applied()); + // These are POSIX and must hold on every platform this crate builds for. + assert!( + report.process_group.is_applied(), + "{:?}", + report.process_group + ); + assert!( + report.descriptor_isolation.is_applied(), + "{:?}", + report.descriptor_isolation + ); + assert!(report.cpu_seconds.is_applied(), "{:?}", report.cpu_seconds); + assert!(report.file_size.is_applied(), "{:?}", report.file_size); + assert!(report.descriptors.is_applied(), "{:?}", report.descriptors); +} + +/// The Darwin kernel does not enforce `RLIMIT_AS`, offers no network namespace, +/// and gives no way to observe the per-user task count. Setting the first and +/// pretending about the other two is exactly the false claim the contract bans. +#[test] +fn a_platform_that_cannot_establish_a_control_never_claims_it() { + let (report, _rig) = successful_report(brisk()); + if cfg!(target_os = "linux") { + assert!( + report.address_space.is_applied(), + "linux can bound address space: {:?}", + report.address_space + ); + assert!( + report.process_count.is_applied(), + "linux can observe its task count: {:?}", + report.process_count + ); + } else { + assert!( + !report.address_space.is_applied(), + "{:?}", + report.address_space + ); + assert!( + !report.process_count.is_applied(), + "{:?}", + report.process_count + ); + assert!(!report.network.is_applied(), "{:?}", report.network); + } +} + +#[test] +fn the_cpu_limit_stops_an_adapter_that_only_spins() { + let rig = Rig::new("while True:\n pass\n"); + let started = Instant::now(); + let err = rig.fail(Limits { + cpu_seconds: 1, + // Long enough that the deadline cannot be what stopped it. + wall_clock: Duration::from_secs(60), + ..Limits::default() + }); + assert!( + matches!(err, HostError::Crashed { signal, .. } if signal == libc::SIGXCPU), + "wrong failure: {err}" + ); + assert!( + started.elapsed() < Duration::from_secs(30), + "the CPU limit did not stop the spin" + ); +} + +#[test] +fn the_file_size_limit_stops_an_adapter_that_writes_too_much() { + // The interpreter ignores `SIGXFSZ`, so the limit shows up as a failed + // write rather than as a dead process. Either way the bound is the claim, + // and the bound is what the probe measures. + let body = r#"import json, os +written = 0 +try: + with open("hog", "wb") as fp: + for _ in range(64): + fp.write(b"z" * 65536) + fp.flush() + written = os.path.getsize("hog") +except OSError: + pass +sidecar("filesize").write_text(json.dumps(os.path.getsize("hog"))) +succeed() +"#; + + let control = Rig::new(body); + control + .run(Limits { + max_file_bytes: 16 * 1024 * 1024, + ..brisk() + }) + .expect("the control adapter answers"); + let control_size: u64 = + serde_json::from_slice(&fs::read(control.sidecar("filesize")).expect("probe")) + .expect("a size"); + assert_eq!( + control_size, + 64 * 65536, + "the control could not write the whole file, so the limited case proves nothing" + ); + + let rig = Rig::new(body); + let run = rig + .run(Limits { + max_file_bytes: 64 * 1024, + ..brisk() + }) + .expect("the adapter still answers"); + assert!(run.containment.file_size.is_applied()); + let size: u64 = + serde_json::from_slice(&fs::read(rig.sidecar("filesize")).expect("probe")).expect("a size"); + assert!( + size <= 64 * 1024, + "the adapter wrote a {size} byte file under a 64 KiB limit" + ); +} + +#[test] +fn the_descriptor_limit_stops_an_adapter_that_opens_too_many() { + let body = r#"import json +held = [] +opened = 0 +try: + while opened < 200: + held.append(open(ARGS.request, "rb")) + opened += 1 +except OSError: + pass +for fp in held: + fp.close() +sidecar("descriptors").write_text(json.dumps(opened)) +succeed() +"#; + + let control = Rig::new(body); + control + .run(Limits { + // Comfortably above the target and below the smallest per-process + // ceiling this crate builds for; Darwin's inherited soft limit is + // 256, so the control has to raise it to open anything. + max_descriptors: 1024, + ..brisk() + }) + .expect("the control adapter answers"); + let control_opened: u32 = + serde_json::from_slice(&fs::read(control.sidecar("descriptors")).expect("probe")) + .expect("a count"); + assert_eq!( + control_opened, 200, + "the control could not open 200 descriptors, so the limited case proves nothing" + ); + + let rig = Rig::new(body); + let run = rig + .run(Limits { + max_descriptors: 64, + ..brisk() + }) + .expect("the adapter still answers"); + assert!(run.containment.descriptors.is_applied()); + let opened: u32 = serde_json::from_slice(&fs::read(rig.sidecar("descriptors")).expect("probe")) + .expect("a count"); + assert!( + opened < 64, + "the adapter opened {opened} descriptors under a limit of 64" + ); +} + +/// `RLIMIT_AS` is Linux-only here, so the probe is too. +#[test] +#[cfg(target_os = "linux")] +fn the_address_space_limit_stops_an_adapter_that_allocates_too_much() { + let rig = Rig::new( + r#"import json +try: + hog = bytearray(3 * 1024 * 1024 * 1024) + outcome = "allocated" +except MemoryError: + outcome = "refused" +sidecar("allocation").write_text(json.dumps(outcome)) +succeed() +"#, + ); + let run = rig + .run(Limits { + max_address_space_bytes: Some(1024 * 1024 * 1024), + ..brisk() + }) + .expect("the adapter still answers"); + assert!(run.containment.address_space.is_applied()); + let outcome: String = + serde_json::from_slice(&fs::read(rig.sidecar("allocation")).expect("probe")) + .expect("an outcome"); + assert_eq!( + outcome, "refused", + "a 3 GiB allocation succeeded under a 1 GiB address space limit" + ); +} + +/// The process budget, shown as a difference rather than as an absolute. +/// +/// An absolute assertion would be a guess about how busy the host is. Running +/// the same forking adapter twice, once with a budget and once without, is not. +#[test] +#[cfg(target_os = "linux")] +fn the_process_budget_stops_an_adapter_that_forks() { + const ATTEMPTS: u32 = 64; + let body = format!( + r#"import json, os, time +started = 0 +children = [] +for _ in range({ATTEMPTS}): + try: + pid = os.fork() + except OSError: + break + if pid == 0: + time.sleep(0.5) + os._exit(0) + children.append(pid) + started += 1 +sidecar("forks").write_text(json.dumps(started)) +for pid in children: + try: + os.waitpid(pid, 0) + except OSError: + pass +succeed() +"# + ); + + let unbudgeted = Rig::new(&body); + unbudgeted + .run(Limits { + extra_processes: None, + wall_clock: Duration::from_secs(30), + ..Limits::default() + }) + .expect("the control adapter answers"); + let control: u32 = + serde_json::from_slice(&fs::read(unbudgeted.sidecar("forks")).expect("probe")) + .expect("a count"); + assert_eq!( + control, ATTEMPTS, + "the control could not fork {ATTEMPTS} times, so the budgeted case proves nothing" + ); + + let budgeted = Rig::new(&body); + let run = budgeted + .run(Limits { + extra_processes: Some(0), + wall_clock: Duration::from_secs(30), + ..Limits::default() + }) + .expect("the budgeted adapter answers"); + assert!( + run.containment.process_count.is_applied(), + "{:?}", + run.containment.process_count + ); + let budgeted_forks: u32 = + serde_json::from_slice(&fs::read(budgeted.sidecar("forks")).expect("probe")) + .expect("a count"); + assert!( + budgeted_forks < ATTEMPTS, + "a zero process budget still allowed all {ATTEMPTS} forks" + ); +} + +/// Network isolation is conditional on what the host permits, so the assertion +/// is conditional on what the host reported. What is not conditional is that the +/// two agree. +#[test] +fn network_isolation_is_enforced_exactly_when_it_is_claimed() { + let rig = Rig::new( + r#"import json, socket +try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0.5) + s.connect(("192.0.2.1", 53)) + s.send(b"probe") + outcome = "reachable" +except OSError as exc: + outcome = "unreachable" +sidecar("network").write_text(json.dumps(outcome)) +succeed() +"#, + ); + let run = rig.run(brisk()).expect("the adapter answers"); + let outcome: String = serde_json::from_slice(&fs::read(rig.sidecar("network")).expect("probe")) + .expect("an outcome"); + + if run.containment.network.is_applied() { + assert_eq!( + outcome, "unreachable", + "the host claimed network isolation and the adapter reached the network" + ); + } else { + // Nothing to assert about the network itself; the claim is the point, + // and the host made none. + assert!(matches!( + run.containment.network, + flutterdec_adapter::ControlState::Unavailable { .. } + )); + } +} + +// -- VAL-HOST-007: the image is sealed or nothing runs ------------------------ + +/// A native adapter, so the sealed descriptor is proved on the path where the +/// kernel executes the image itself rather than handing its name to an +/// interpreter. +/// +/// It reports where the kernel thinks it came from. On Linux that is the +/// anonymous file the host created, which has no directory entry anywhere, and +/// the run still has to succeed end to end: a host that could only start scripts +/// would be a host nobody could ship a compiled adapter to. +const NATIVE_ADAPTER: &str = r#" +#include +#include +#include + +/* Reads the prepared model beside --input-path and answers the request. The + output handle is the one the host always issues, so no JSON parser is needed + to be a correct adapter. */ +int main(int argc, char **argv) { + const char *result = 0, *input = 0; + for (int i = 1; i + 1 < argc; i++) { + if (!strcmp(argv[i], "--result")) result = argv[++i]; + else if (!strcmp(argv[i], "--input-path")) input = argv[++i]; + } + if (!result || !input) return 2; + + char link[4096]; + ssize_t linked = readlink("/proc/self/exe", link, sizeof link - 1); + if (linked < 0) linked = 0; + link[linked] = 0; + + char path[4096]; + snprintf(path, sizeof path, "%s.image", input); + FILE *probe = fopen(path, "w"); + if (!probe) return 3; + fprintf(probe, "{\"argv0\":\"%s\",\"exe\":\"%s\"}", argv[0], link); + fclose(probe); + + snprintf(path, sizeof path, "%s.model", input); + FILE *prepared = fopen(path, "r"); + if (!prepared) return 4; + FILE *model = fopen("out/model.json", "w"); + if (!model) return 5; + char buffer[8192]; + size_t got; + while ((got = fread(buffer, 1, sizeof buffer, prepared)) > 0) + fwrite(buffer, 1, got, model); + fclose(prepared); + fclose(model); + + FILE *answer = fopen(result, "w"); + if (!answer) return 6; + fputs("{\"protocol_major\":1,\"model_major\":4,\"status\":\"ok\"," + "\"model\":\"out/model.json\",\"error\":null," + "\"resolved_backend\":\"internal\",\"fallback_reason\":null," + "\"diagnostics\":[]}", answer); + fclose(answer); + return 0; +} +"#; + +#[test] +fn a_native_adapter_runs_from_the_sealed_descriptor() { + let dir = TempDir::new().expect("tempdir"); + let source = dir.path().join("native_adapter.c"); + let built = dir.path().join("native_adapter"); + fs::write(&source, NATIVE_ADAPTER).expect("write the native adapter"); + // `cc` is what linked this test binary, so a host that can build the suite + // can build a native adapter for it. + let compiler = std::env::var("CC").unwrap_or_else(|_| "cc".to_string()); + let compiled = std::process::Command::new(&compiler) + .arg("-O0") + .arg("-o") + .arg(&built) + .arg(&source) + .output() + .unwrap_or_else(|err| panic!("run {compiler}: {err}")); + assert!( + compiled.status.success(), + "{compiler} refused the native adapter: {}", + String::from_utf8_lossy(&compiled.stderr) + ); + + let rig = Rig::built(dir, &built); + let run = rig.run(brisk()).expect("the native adapter succeeds"); + assert!(run.model.functions.is_empty()); + + #[derive(serde::Deserialize)] + struct Image { + argv0: String, + exe: String, + } + let image: Image = + serde_json::from_slice(&fs::read(rig.sidecar("image")).expect("the adapter wrote a probe")) + .expect("the probe is JSON"); + assert_eq!( + image.argv0, "flutterdec-local-python", + "the adapter was not started under the name the registry authorized" + ); + if cfg!(target_os = "linux") { + assert!( + image.exe.starts_with("/memfd:flutterdec-adapter:"), + "a native adapter ran from something other than the anonymous image: {}", + image.exe + ); + } +} + +/// The kernel refusing to provide a sealed anonymous image is the end of the +/// run, not the start of a fallback. +/// +/// `memfd_create` will not accept a name longer than it can store, and the label +/// is derived from the authorized artifact's file name, so publishing under a +/// long enough name makes the real syscall fail on every kernel without a hook +/// anywhere in the product. What follows has to be a typed pre-spawn refusal: +/// the adapter records that it ran, and that record must not exist. +#[test] +#[cfg(target_os = "linux")] +fn an_image_the_host_cannot_seal_refuses_the_run_instead_of_naming_a_file() { + let unnameable = "a".repeat(250); + let rig = Rig::named( + "sidecar(\"ran\").write_text(\"the adapter started\")\nsucceed()\n", + Some(&unnameable), + ); + + let err = rig + .run(brisk()) + .expect_err("an image that cannot be sealed must not be executed"); + + let HostError::ImageNotSealed(ref detail) = err else { + panic!("wrong failure: {err}"); + }; + assert!( + detail.contains("create an anonymous executable image"), + "the refusal does not say what failed: {detail}" + ); + assert!( + err.is_pre_spawn(), + "a refusal that started no child was not classified as pre-spawn: {err}" + ); + assert!( + !rig.sidecar("ran").exists(), + "a child ran after the host refused to seal its image" + ); + // Nothing was written next to the artifact either: the store holds what it + // held before the run. + let published: Vec<_> = fs::read_dir(rig.installed.store_root.join("artifacts")) + .expect("read the store") + .map(|entry| entry.expect("entry").file_name()) + .collect(); + assert_eq!( + published, + vec![std::ffi::OsString::from(&unnameable)], + "the refused run left something in the store" + ); +} diff --git a/crates/flutterdec-adapter/tests/host_gates.rs b/crates/flutterdec-adapter/tests/host_gates.rs new file mode 100644 index 00000000..a5c45d02 --- /dev/null +++ b/crates/flutterdec-adapter/tests/host_gates.rs @@ -0,0 +1,675 @@ +//! Every pre-spawn gate, against a real executable that would leave evidence. +//! +//! Asserting that `run_adapter` returned an error would not distinguish a run +//! refused before anything happened from one that started, failed, and cleaned +//! up after itself. So the authorized artifact in every case here is a real +//! executable whose first line creates a marker file outside the workspace. A +//! gate case then has to show three things at once: the exact typed refusal, +//! that the refusal is classified as pre-spawn, and that the marker is absent. +//! +//! The control at the end runs the same rig with nothing wrong and requires the +//! marker to appear. Without it, every assertion above would also pass against a +//! rig that could never spawn anything at all. + +mod support; + +use flutterdec_adapter::model::{InputRegionName, ProducerTrust}; +use flutterdec_adapter::primitives::Sha256Digest; +use flutterdec_adapter::protocol::RequestedBackend; +use flutterdec_adapter::registry::{ + canonical_feature_fingerprint, CompatibilityRecord, HostArtifactVariant, +}; +use flutterdec_adapter::store::EntryState; +use flutterdec_adapter::{ + run_adapter, AdapterInput, AdapterRegionInput, HostAuthorization, HostError, LibappSource, + Limits, +}; +use flutterdec_loader::identity::{SnapshotIdentity, SnapshotKind, TargetArch}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +const RET: [u8; 4] = 0xD65F_03C0u32.to_le_bytes(); + +/// The other snapshot the shipped registry has a record for. Its record names +/// the same artifact path and the same digest as this rig's. +const OTHER_HASH: &str = "ace654289f5abc240509fc941453ebc5"; + +/// A rig whose adapter would run, loudly, if a gate ever let it. +struct Rig { + _dir: TempDir, + marker: PathBuf, + /// The spy before it was published, which is a real executable outside the + /// adapter store. + unpublished: PathBuf, + installed: support::Authorized, + identity: SnapshotIdentity, + regions: Vec>, +} + +impl Rig { + fn new() -> Self { + let dir = TempDir::new().expect("tempdir"); + let marker = dir.path().join("adapter_ran.marker"); + let unpublished = dir.path().join("spy_adapter"); + fs::write( + &unpublished, + format!("#!/bin/sh\ntouch '{}'\nexit 1\n", marker.display()), + ) + .expect("write spy"); + fs::set_permissions(&unpublished, fs::Permissions::from_mode(0o755)).expect("chmod spy"); + + let identity = support::identity(); + let installed = support::Authorized::install(&unpublished, &identity); + Self { + _dir: dir, + marker, + unpublished, + installed, + identity, + // Four non-empty regions, so nothing except the gate under test can + // decide the outcome. + regions: vec![vec![0u8; 64], vec![0u8; 64], RET.to_vec(), RET.repeat(4)], + } + } + + fn spawned(&self) -> bool { + self.marker.exists() + } + + fn region_inputs(&self) -> Vec> { + vec![ + AdapterRegionInput { + region: InputRegionName::VmData, + bytes: &self.regions[0], + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::IsolateData, + bytes: &self.regions[1], + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::VmInstructions, + bytes: &self.regions[2], + virtual_address: Some(0x1000), + }, + AdapterRegionInput { + region: InputRegionName::IsolateInstructions, + bytes: &self.regions[3], + virtual_address: Some(0x2000), + }, + ] + } + + /// Everything correct. Each case below changes exactly one thing. + fn input<'a>(&'a self, record: &'a CompatibilityRecord) -> AdapterInput<'a> { + AdapterInput { + identity: &self.identity, + authorization: self.installed.authorization_for(record), + producer: self.installed.producer_for(record), + compatibility: self.installed.binding_for(record), + regions: self.region_inputs(), + input_path: None, + libapp: None, + requested_backend: RequestedBackend::Auto, + limits: Limits::default(), + } + } + + fn run(&self, input: &AdapterInput<'_>) -> Result<(), HostError> { + run_adapter(&self.installed.exec, input).map(|_| ()) + } + + /// Run and require a refusal that happened before any process existed. + fn refuse(&self, input: &AdapterInput<'_>) -> HostError { + let err = self + .run(input) + .expect_err("the gate under test must refuse this input"); + assert!( + err.is_pre_spawn(), + "{err} is not classified as a pre-spawn refusal" + ); + assert!( + !self.spawned(), + "a gate refused after the adapter had already run: {err}" + ); + err + } +} + +fn other_record(rig: &Rig, edit: impl FnOnce(&mut CompatibilityRecord)) -> CompatibilityRecord { + let mut record = rig.installed.record.clone(); + edit(&mut record); + record +} + +#[test] +fn a_snapshot_that_is_not_full_aot_never_reaches_the_registry() { + let mut rig = Rig::new(); + rig.identity = SnapshotIdentity::from_header( + TargetArch::Arm64, + support::HASH, + SnapshotKind::FullJit, + support::FEATURES, + ); + let record = rig.installed.record.clone(); + let err = rig.refuse(&rig.input(&record)); + assert!( + matches!(err, HostError::IdentityRejected(_)), + "wrong refusal: {err}" + ); +} + +#[test] +fn a_record_that_breaks_its_own_invariants_is_refused() { + let rig = Rig::new(); + // Features out of order. The registry's own validator rejects it, and the + // host does not get to accept a record the registry would not. + let record = other_record(&rig, |record| record.features.reverse()); + let err = rig.refuse(&rig.input(&record)); + assert!( + matches!(err, HostError::RecordInvalid(_)), + "wrong refusal: {err}" + ); +} + +#[test] +fn a_binding_that_names_another_record_is_refused() { + let rig = Rig::new(); + let record = rig.installed.record.clone(); + let mut input = rig.input(&record); + input.compatibility.record_sha256 = Sha256Digest::of(b"some other record"); + let err = rig.refuse(&input); + assert!( + matches!(err, HostError::RecordDigestMismatch { .. }), + "wrong refusal: {err}" + ); +} + +#[test] +fn a_record_written_for_another_protocol_or_model_major_is_refused() { + let rig = Rig::new(); + let record = other_record(&rig, |record| record.protocol_major = 2); + let err = rig.refuse(&rig.input(&record)); + assert!( + matches!( + err, + HostError::UnsupportedMajors { + record_protocol: 2, + record_model: 4 + } + ), + "wrong refusal: {err}" + ); +} + +#[test] +fn a_record_for_another_snapshot_is_refused() { + let rig = Rig::new(); + let record = other_record(&rig, |record| { + record.snapshot_hash = "0123456789abcdef0123456789abcdef".to_string() + }); + let err = rig.refuse(&rig.input(&record)); + assert!( + matches!(err, HostError::IdentityRecordMismatch { .. }), + "wrong refusal: {err}" + ); +} + +#[test] +fn a_record_for_another_target_architecture_is_refused() { + let rig = Rig::new(); + let record = other_record(&rig, |record| { + record.target_arch = TargetArch::Unsupported("x64".to_string()) + }); + let err = rig.refuse(&rig.input(&record)); + assert!( + matches!(err, HostError::TargetMismatch { .. }), + "wrong refusal: {err}" + ); +} + +#[test] +fn a_record_for_another_feature_tuple_is_refused() { + let rig = Rig::new(); + let record = other_record(&rig, |record| { + record.features.retain(|feature| feature != "product"); + record.feature_fingerprint = canonical_feature_fingerprint(&record.features); + }); + let err = rig.refuse(&rig.input(&record)); + assert!( + matches!(err, HostError::FeatureMismatch { .. }), + "wrong refusal: {err}" + ); +} + +#[test] +fn a_variant_the_record_does_not_declare_is_refused() { + let rig = Rig::new(); + let record = rig.installed.record.clone(); + let mut smuggled: HostArtifactVariant = record.artifact.variants[0].clone(); + smuggled.provenance = "not the variant the record declares".to_string(); + let mut input = rig.input(&record); + input.authorization = HostAuthorization { + variant: &smuggled, + ..input.authorization + }; + let err = rig.refuse(&input); + assert!( + matches!(err, HostError::VariantNotInRecord { .. }), + "wrong refusal: {err}" + ); +} + +#[test] +fn an_artifact_variant_for_another_host_is_refused() { + let rig = Rig::new(); + // The host architecture and the target architecture are different facts. A + // record can be right about the snapshot and still name an executable this + // machine cannot run. + let record = other_record(&rig, |record| { + record.artifact.variants[0].host_os = "plan9".to_string() + }); + let err = rig.refuse(&rig.input(&record)); + assert!( + matches!(err, HostError::HostVariantMismatch { .. }), + "wrong refusal: {err}" + ); +} + +#[test] +fn an_executable_outside_the_adapter_store_is_refused() { + let rig = Rig::new(); + let record = rig.installed.record.clone(); + // Byte-identical to the authorized artifact, and in the wrong place. + let err = run_adapter(&rig.unpublished, &rig.input(&record)) + .expect_err("an executable outside the store cannot run"); + assert!(err.is_pre_spawn(), "{err} is not a pre-spawn refusal"); + assert!(!rig.spawned(), "the out-of-store executable ran: {err}"); + assert!( + matches!(err, HostError::ArtifactPathRejected(_)), + "wrong refusal: {err}" + ); +} + +/// Containment is not implied by "the record named this path". +/// +/// A symlink published inside the store resolves to a file the registry never +/// saw, and both the caller and the record agree on the link, so only the +/// containment check can tell. +#[test] +fn an_artifact_that_links_out_of_the_store_is_refused() { + let rig = Rig::new(); + fs::remove_file(&rig.installed.exec).expect("remove the published artifact"); + std::os::unix::fs::symlink(&rig.unpublished, &rig.installed.exec).expect("publish a symlink"); + + let record = rig.installed.record.clone(); + let err = rig.refuse(&rig.input(&record)); + let HostError::ArtifactPathRejected(ref detail) = err else { + panic!("wrong refusal: {err}"); + }; + assert!( + detail.contains("outside the adapter store"), + "the refusal does not name containment: {detail}" + ); +} + +#[test] +fn an_artifact_that_is_not_executable_is_refused() { + let rig = Rig::new(); + fs::set_permissions(&rig.installed.exec, fs::Permissions::from_mode(0o644)) + .expect("drop the execute bit"); + let record = rig.installed.record.clone(); + let err = rig.refuse(&rig.input(&record)); + assert!( + matches!(err, HostError::ArtifactNotExecutable(_)), + "wrong refusal: {err}" + ); +} + +/// A path can be contained, be the one the record names, and carry execute +/// bits, and still not be a file. +/// +/// The two shapes here are the ones that matter. A directory is what a broken +/// install leaves behind; a FIFO is what someone puts there on purpose, because +/// reading it blocks forever and would hang the host inside its own verification +/// step. Both must be answered by the file-type gate specifically: any later +/// check would mean the host had already committed to opening it. +#[test] +fn an_artifact_that_is_not_a_regular_file_is_refused() { + for shape in ["directory", "fifo"] { + let rig = Rig::new(); + fs::remove_file(&rig.installed.exec).expect("remove the published artifact"); + match shape { + "directory" => { + fs::create_dir(&rig.installed.exec).expect("publish a directory"); + fs::set_permissions(&rig.installed.exec, fs::Permissions::from_mode(0o755)) + .expect("chmod the directory"); + } + _ => mkfifo(&rig.installed.exec), + } + + let record = rig.installed.record.clone(); + let err = rig.refuse(&rig.input(&record)); + let HostError::ArtifactNotExecutable(ref detail) = err else { + panic!("{shape}: wrong refusal: {err}"); + }; + assert!( + detail.contains("is not a regular file"), + "{shape}: the refusal does not name the file type: {detail}" + ); + } +} + +/// A named pipe with execute bits set, published where the record points. +fn mkfifo(path: &Path) { + let raw = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).expect("path bytes"); + let created = unsafe { libc::mkfifo(raw.as_ptr(), 0o755) }; + assert_eq!( + created, + 0, + "mkfifo {}: {}", + path.display(), + std::io::Error::last_os_error() + ); + // `mkfifo` applies the umask, and the gate under test comes before the + // execute-bit check only if the execute bits are actually there. + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("chmod the fifo"); +} + +#[test] +fn an_artifact_that_changed_since_it_was_registered_is_refused() { + let rig = Rig::new(); + let mut bytes = fs::read(&rig.installed.exec).expect("read artifact"); + bytes.extend_from_slice(b"\n# appended after the registry verified it\n"); + fs::write(&rig.installed.exec, &bytes).expect("rewrite artifact"); + let record = rig.installed.record.clone(); + let err = rig.refuse(&rig.input(&record)); + assert!( + matches!(err, HostError::ArtifactDigestMismatch { .. }), + "wrong refusal: {err}" + ); +} + +/// An install for one record does not authorize a different one, even when the +/// two name the same artifact path with the same bytes. +/// +/// The shipped registry has exactly that pair, so this is not a hypothetical: +/// every check that reads the file — containment, the declared path, the mode, +/// the digest — passes here, because the file *is* the file both records +/// declare. What differs is who installed it, and the store ledger is the only +/// thing that records that. +#[test] +fn an_install_for_another_record_does_not_authorize_this_one() { + let rig = Rig::new(); + // Same artifact, same digest, same path: only the snapshot hash differs, so + // the store ends up holding an install that belongs to the other record. + let sibling = other_record(&rig, |record| { + record.snapshot_hash = OTHER_HASH.to_string(); + }); + support::write_ledger(&rig.installed.store_root, &sibling); + + let record = rig.installed.record.clone(); + let err = rig.refuse(&rig.input(&record)); + let HostError::NotInstalled { state, ref detail } = err else { + panic!("wrong refusal: {err}"); + }; + assert_eq!( + state, + EntryState::Unavailable, + "the refusal does not report the state `adapter list` would: {detail}" + ); + assert_eq!( + detail, "not installed in the local adapter store", + "the refusal does not name the condition" + ); + + // The other direction: the same rig, the same bytes, the same everything, + // with the ledger recording *this* record — and it runs. Without this the + // assertion above would also hold for a host that refused every record. + support::write_ledger(&rig.installed.store_root, &record); + let err = rig + .run(&rig.input(&record)) + .expect_err("the spy adapter exits without a result"); + assert!( + matches!(err, HostError::NoResult { .. }), + "an installed record was refused before it ran: {err}" + ); + assert!(rig.spawned(), "an installed record did not reach the spy"); +} + +/// A ledger entry that contradicts the record it claims is a broken store, and +/// it is reported as the same `corrupt` an operator sees in `adapter list`. +#[test] +fn a_ledger_entry_that_contradicts_the_record_is_refused_as_corrupt() { + let rig = Rig::new(); + let stale = other_record(&rig, |record| { + record.artifact.variants[0].path = "artifacts/some-other-name".to_string(); + }); + support::write_ledger(&rig.installed.store_root, &stale); + + let record = rig.installed.record.clone(); + let err = rig.refuse(&rig.input(&record)); + let HostError::NotInstalled { state, .. } = err else { + panic!("wrong refusal: {err}"); + }; + assert_eq!(state, EntryState::Corrupt, "wrong reported state"); +} + +#[test] +fn a_producer_record_that_does_not_follow_from_the_registry_is_refused() { + let rig = Rig::new(); + let record = rig.installed.record.clone(); + + for (label, edit) in [ + ( + "a digest for bytes that are not the ones being executed", + Box::new(|input: &mut AdapterInput<'_>| { + input.producer.artifact_sha256 = Sha256Digest::of(b"some other artifact") + }) as Box)>, + ), + ( + "a parser family the record does not name", + Box::new(|input: &mut AdapterInput<'_>| input.producer.id = "someone-else".to_string()), + ), + ( + "a trust level a registry-authorized run cannot carry", + Box::new(|input: &mut AdapterInput<'_>| input.producer.trust = ProducerTrust::Local), + ), + ] { + let mut input = rig.input(&record); + edit(&mut input); + let err = rig.refuse(&input); + assert!( + matches!(err, HostError::ProducerMismatch(_)), + "{label}: wrong refusal: {err}" + ); + } +} + +#[test] +fn a_profile_that_does_not_match_the_records_digest_is_refused() { + let rig = Rig::new(); + fs::write( + &rig.installed.profile_path, + br#"{"profiles":{"swapped":{}}}"#, + ) + .expect("swap the profile"); + let record = rig.installed.record.clone(); + let err = rig.refuse(&rig.input(&record)); + assert!( + matches!(err, HostError::ProfileRejected(_)), + "wrong refusal: {err}" + ); +} + +#[test] +fn a_binding_that_does_not_follow_from_the_record_is_refused() { + let rig = Rig::new(); + let record = rig.installed.record.clone(); + let mut input = rig.input(&record); + input.compatibility.profile_id = "a profile the record does not name".to_string(); + let err = rig.refuse(&input); + assert!( + matches!(err, HostError::BindingMismatch(_)), + "wrong refusal: {err}" + ); +} + +#[test] +fn an_unusable_snapshot_region_is_refused() { + let rig = Rig::new(); + let record = rig.installed.record.clone(); + + let empty: Vec = Vec::new(); + let mut input = rig.input(&record); + input.regions[0].bytes = ∅ + let err = rig.refuse(&input); + assert!( + matches!(err, HostError::InputRejected(_)), + "an empty region: wrong refusal: {err}" + ); + + let mut input = rig.input(&record); + input.limits.max_region_bytes = 4; + let err = rig.refuse(&input); + assert!( + matches!(err, HostError::InputRejected(_)), + "an oversized region: wrong refusal: {err}" + ); +} + +#[test] +fn a_request_the_host_would_not_answer_is_never_asked() { + let rig = Rig::new(); + let record = rig.installed.record.clone(); + let mut input = rig.input(&record); + input.regions.truncate(3); + let err = rig.refuse(&input); + assert!( + matches!(err, HostError::RequestRejected(_)), + "wrong refusal: {err}" + ); +} + +/// The control. +/// +/// Everything above asserts that a marker did not appear. That claim is worth +/// nothing unless the same rig, unmodified, does produce the marker. +#[test] +fn the_same_rig_with_nothing_wrong_reaches_the_executable() { + let rig = Rig::new(); + let record = rig.installed.record.clone(); + let err = rig + .run(&rig.input(&record)) + .expect_err("the spy exits 1 without writing a result"); + assert!( + !err.is_pre_spawn(), + "an authorized run must fail as a run, not as a refusal: {err}" + ); + assert!( + matches!(err, HostError::NoResult { .. }), + "wrong failure: {err}" + ); + assert!( + rig.spawned(), + "the control did not reach the executable, so no gate case proves anything" + ); +} + +/// An APK member is not a path, and a backend that opens `--libapp-path` needs +/// one. The host writes the member into the private invocation directory. +#[test] +fn an_archive_member_is_materialized_into_a_real_path() { + let dir = TempDir::new().expect("tempdir"); + let recorder = dir.path().join("recorder"); + let input_path = dir.path().join("app.apk"); + fs::write(&input_path, b"not really a zip").expect("write input"); + // The adapter records what it was handed, next to the input path, which is + // the one location outside its private workspace that it knows about. + fs::write( + &recorder, + r#"#!/usr/bin/env python3 +import argparse, pathlib + +p = argparse.ArgumentParser() +p.add_argument("--request", required=True) +p.add_argument("--result", required=True) +p.add_argument("--input-path") +p.add_argument("--libapp-path") +args = p.parse_args() +pathlib.Path(args.input_path + ".libapp").write_text(args.libapp_path or "") +raise SystemExit(1) +"#, + ) + .expect("write recorder"); + fs::set_permissions(&recorder, fs::Permissions::from_mode(0o755)).expect("chmod"); + + let identity = support::identity(); + let installed = support::Authorized::install(&recorder, &identity); + let regions = [vec![0u8; 64], vec![0u8; 64], RET.to_vec(), RET.repeat(4)]; + let member = b"\x7fELF pretend shared object".to_vec(); + + let err = run_adapter( + &installed.exec, + &AdapterInput { + identity: &identity, + authorization: installed.authorization(), + producer: installed.producer(), + compatibility: installed.binding(), + regions: vec![ + AdapterRegionInput { + region: InputRegionName::VmData, + bytes: ®ions[0], + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::IsolateData, + bytes: ®ions[1], + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::VmInstructions, + bytes: ®ions[2], + virtual_address: Some(0x1000), + }, + AdapterRegionInput { + region: InputRegionName::IsolateInstructions, + bytes: ®ions[3], + virtual_address: Some(0x2000), + }, + ], + input_path: Some(&input_path), + libapp: Some(LibappSource::Member { + name: "lib/arm64-v8a/libapp.so", + bytes: &member, + }), + requested_backend: RequestedBackend::Auto, + limits: Limits::default(), + }, + ) + .expect_err("the recorder writes no result"); + assert!( + matches!(err, HostError::NoResult { .. }), + "unexpected: {err}" + ); + + let handed = fs::read_to_string(dir.path().join("app.apk.libapp")) + .expect("the adapter recorded its --libapp-path"); + let handed = Path::new(handed.trim()); + assert!( + handed.is_absolute(), + "the adapter was handed {handed:?}, which it cannot open from anywhere" + ); + assert_eq!( + handed.file_name().and_then(|name| name.to_str()), + Some("libapp.so"), + "the materialized member kept its file name" + ); + // The workspace is private and torn down, so the path is gone by now; what + // matters is that it was a real path under the invocation directory rather + // than the zip entry name. + assert_ne!(handed, Path::new("lib/arm64-v8a/libapp.so")); +} diff --git a/crates/flutterdec-adapter/tests/host_image_inplace_rewrite.rs b/crates/flutterdec-adapter/tests/host_image_inplace_rewrite.rs new file mode 100644 index 00000000..f41ff1cb --- /dev/null +++ b/crates/flutterdec-adapter/tests/host_image_inplace_rewrite.rs @@ -0,0 +1,498 @@ +//! The one attack the frozen-pathname image cannot see, exercised rather than +//! described. +//! +//! Where a descriptor cannot be executed the image has to be a name, and what +//! keeps that name welded to its bytes is `UF_IMMUTABLE` plus a check the child +//! makes immediately before `execve`: the pathname must still resolve to the +//! descriptor the host verified (same device, same inode) and must still be +//! frozen. That catches everything which puts a *different* object at the name, +//! and `host_workspace_race.rs` is where the unlink-and-replace shape of it is +//! held to that outcome. +//! +//! It does not catch this one. The flag is a *user* flag, so its owner can clear +//! it, rewrite the bytes through the same inode with `O_TRUNC`, and set the flag +//! again. Afterwards the device matches, the inode matches, and the flag is +//! present, so the check passes and the rewritten bytes are what executes. Only +//! re-reading the whole image between the check and the `execve` would see it, +//! and the platform cannot make those two atomic. +//! +//! So this file asserts what actually happens, not what would be nice: on Darwin +//! the attacker's bytes run, and the host must not have called that a verified +//! image — `image_integrity` is `unavailable` and says best effort. That is the +//! documented ceiling, and a test is the only thing that keeps the documentation +//! honest about it. On Linux the same attacker finds nothing to rewrite, because +//! the image is an anonymous sealed inode with no name at all. +//! +//! This lives beside `host_workspace_race.rs` rather than inside it because the +//! rendezvous the attacker synchronizes on is selected by a process-wide +//! environment variable: a second test in the same binary would block on a +//! rendezvous it never asked for. Both files therefore hold exactly one test. + +mod support; + +use flutterdec_adapter::host::PRESPAWN_RENDEZVOUS_VAR; +use flutterdec_adapter::model::{InputRegion, InputRegionName, ObservedInput}; +use flutterdec_adapter::primitives::Sha256Digest; +use flutterdec_adapter::protocol::RequestedBackend; +#[cfg(not(target_os = "linux"))] +use flutterdec_adapter::ControlState; +use flutterdec_adapter::{ + run_adapter, AdapterInput, AdapterRegionInput, AdapterRun, HostError, Limits, +}; +use flutterdec_loader::identity::SnapshotIdentity; +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use tempfile::TempDir; + +const RET: [u8; 4] = 0xD65F_03C0u32.to_le_bytes(); + +/// `UF_IMMUTABLE`, which the attacker reports as a number rather than a name. +#[cfg(not(target_os = "linux"))] +const UF_IMMUTABLE: u64 = 0x0000_0002; + +/// An adapter that records which image it is running and then answers correctly. +/// +/// The name and the digest come from `argv[0]`, the only name it has, so "which +/// bytes ran" is an equality between two digests. Answering correctly is what +/// makes the containment report observable: the host drops it on every failure +/// path, so a run that has to be *inspected* has to be a run that succeeded. +/// +/// The prepared answer is read from a path baked in here rather than embedded, +/// because the answer names the digest of these very bytes and embedding it +/// would not converge. +fn adapter_source(log: &Path, model: &Path) -> String { + ADAPTER + .replace("@LOG@", &log.display().to_string()) + .replace("@MODEL@", &model.display().to_string()) +} + +const ADAPTER: &str = r#"#!/usr/bin/env python3 +import argparse, hashlib, json, pathlib, sys + +P = argparse.ArgumentParser() +P.add_argument("--request", required=True) +P.add_argument("--result", required=True) +P.add_argument("--input-path") +P.add_argument("--libapp-path") +ARGS = P.parse_args() +REQUEST = json.loads(pathlib.Path(ARGS.request).read_text()) +OUTPUT = REQUEST["output"] + +image = pathlib.Path(sys.argv[0]).read_bytes() +with open("@LOG@", "a") as fp: + fp.write(sys.argv[0] + " " + hashlib.sha256(image).hexdigest() + "\n") + +pathlib.Path(OUTPUT).write_text(pathlib.Path("@MODEL@").read_text()) +pathlib.Path(ARGS.result).write_text(json.dumps({ + "protocol_major": 1, + "model_major": 4, + "status": "ok", + "model": OUTPUT, + "error": None, + "resolved_backend": "internal", + "fallback_reason": None, + "diagnostics": [], +})) +"#; + +/// The attacker: one shape only, and deliberately the gentlest one. +/// +/// It never unlinks, renames, or creates a file, so the inode the pre-exec check +/// compares against cannot move. It takes the host's flag off, writes other +/// bytes through the same inode, and puts the flag back, which is the whole +/// residue in five syscalls. Every step's outcome and the inode, device and +/// flags on either side of it are reported, so the Rust side reads what the +/// platform did instead of assuming it. +const ATTACKER: &str = r#"#!/usr/bin/env python3 +import errno, json, os, pathlib, stat, sys, time + +ready, go, report, impostor, tmp_root = sys.argv[1:6] + +deadline = time.monotonic() + 60 +while not os.path.exists(ready): + if time.monotonic() > deadline: + raise SystemExit("the host never reached the rendezvous") + time.sleep(0.002) + +impostor_bytes = pathlib.Path(impostor).read_bytes() +roots = sorted(pathlib.Path(tmp_root).glob("flutterdec-adapter-*")) + +seen, candidates = [], [] +for root in roots: + for base, _dirs, files in os.walk(root): + for name in files: + path = pathlib.Path(base) / name + seen.append(str(path)) + try: + before = path.lstat() + except OSError: + continue + if not stat.S_ISREG(before.st_mode) or not before.st_mode & 0o111: + continue + + entry = { + "path": str(path), + "device_before": str(before.st_dev), + "inode_before": str(before.st_ino), + "flags_before": str(getattr(before, "st_flags", 0)), + } + + def attempt(key, action): + try: + action() + entry[key] = "ok" + except OSError as err: + entry[key] = err.strerror or type(err).__name__ + + def chflags(value): + call = getattr(os, "chflags", None) + if call is None: + raise OSError(errno.ENOSYS, "this platform has no chflags") + call(path, value) + + def rewrite_in_place(): + fd = os.open(path, os.O_WRONLY | os.O_TRUNC) + try: + os.write(fd, impostor_bytes) + finally: + os.close(fd) + + # Take the flag off, put other bytes through the inode the host is + # holding open, hand the flag back. The mode has to move too: 0500 + # refuses a write even to its owner, and it is put back so the image + # is still executable afterwards. + attempt("chflags_clear", lambda: chflags(0)) + attempt("chmod_writable", lambda: os.chmod(path, 0o700)) + attempt("rewrite_in_place", rewrite_in_place) + attempt("chmod_back", lambda: os.chmod(path, 0o500)) + attempt("chflags_set", lambda: chflags(stat.UF_IMMUTABLE)) + + after = path.lstat() + entry["device_after"] = str(after.st_dev) + entry["inode_after"] = str(after.st_ino) + entry["flags_after"] = str(getattr(after, "st_flags", 0)) + candidates.append(entry) + +pathlib.Path(report).write_text( + json.dumps({"roots": [str(root) for root in roots], "seen": seen, "candidates": candidates}) +) +pathlib.Path(go).write_text("go") +"#; + +#[derive(Debug, serde::Deserialize)] +struct Report { + roots: Vec, + seen: Vec, + candidates: Vec>, +} + +fn write_executable(path: &Path, body: &str) { + fs::write(path, body).expect("write adapter"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("chmod adapter"); +} + +fn regions() -> Vec> { + vec![vec![0u8; 64], vec![0u8; 64], RET.to_vec(), RET.repeat(4)] +} + +fn region_inputs(regions: &[Vec]) -> Vec> { + vec![ + AdapterRegionInput { + region: InputRegionName::VmData, + bytes: ®ions[0], + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::IsolateData, + bytes: ®ions[1], + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::VmInstructions, + bytes: ®ions[2], + virtual_address: Some(0x1000), + }, + AdapterRegionInput { + region: InputRegionName::IsolateInstructions, + bytes: ®ions[3], + virtual_address: Some(0x2000), + }, + ] +} + +/// The answer a correct adapter gives for this invocation. +/// +/// It carries the host's own facts, including the digest of the artifact this +/// install published, which is why it can only be built after the install. +fn prepared_model( + installed: &support::Authorized, + identity: &SnapshotIdentity, + regions: &[Vec], +) -> Vec { + let mut model = support::unavailable_model(); + model.producer = installed.producer(); + model.compatibility = Some(installed.binding()); + model.input = ObservedInput { + identity: identity.clone(), + regions: region_inputs(regions) + .into_iter() + .map(|region| InputRegion { + region: region.region, + size: region.bytes.len() as u64, + sha256: Sha256Digest::of(region.bytes), + virtual_address: region.virtual_address, + executable: region.region.is_executable(), + }) + .collect(), + }; + model.to_canonical_json() +} + +/// The ` ` lines one adapter left behind. +fn lines(log: &Path) -> Vec<(String, String)> { + fs::read_to_string(log) + .unwrap_or_default() + .lines() + .filter(|line| !line.is_empty()) + .map(|line| { + let (name, digest) = line + .rsplit_once(' ') + .expect("the adapter writes two fields"); + (name.to_string(), digest.to_string()) + }) + .collect() +} + +fn run_once( + installed: &support::Authorized, + identity: &SnapshotIdentity, +) -> Result { + let regions = regions(); + let record = installed.record.clone(); + let input = AdapterInput { + identity, + authorization: installed.authorization_for(&record), + producer: installed.producer_for(&record), + compatibility: installed.binding_for(&record), + regions: region_inputs(®ions), + input_path: None, + libapp: None, + requested_backend: RequestedBackend::Auto, + limits: Limits::default(), + }; + run_adapter(&installed.exec, &input) +} + +#[test] +fn an_owner_who_rewrites_the_frozen_image_in_place_is_not_caught_by_the_identity_check() { + let dir = TempDir::new().expect("tempdir"); + let authorized_log = dir.path().join("authorized.log"); + let impostor_log = dir.path().join("impostor.log"); + let model_path = dir.path().join("model.json"); + + let authorized_source = dir.path().join("authorized_adapter"); + write_executable( + &authorized_source, + &adapter_source(&authorized_log, &model_path), + ); + let impostor_source = dir.path().join("impostor_adapter"); + write_executable( + &impostor_source, + &adapter_source(&impostor_log, &model_path), + ); + let authorized_digest = support::hex_digest(&fs::read(&authorized_source).expect("read")); + let impostor_digest = support::hex_digest(&fs::read(&impostor_source).expect("read")); + + let identity = support::identity(); + let regions = regions(); + + // The control: the impostor is a complete, correct adapter. Without this, + // "the impostor never ran" would be evidence about the impostor rather than + // about the host. + let control = support::Authorized::install(&impostor_source, &identity); + fs::write(&model_path, prepared_model(&control, &identity, ®ions)) + .expect("write the control's prepared answer"); + let run = run_once(&control, &identity).expect("the control adapter answers"); + assert!(run.model.functions.is_empty()); + assert_eq!( + lines(&impostor_log).len(), + 1, + "the impostor cannot run at all, so this test proves nothing" + ); + fs::remove_file(&impostor_log).expect("reset the impostor log"); + + // The run under attack, with the answer re-prepared for the artifact this + // install published. + let installed = support::Authorized::install(&authorized_source, &identity); + fs::write(&model_path, prepared_model(&installed, &identity, ®ions)) + .expect("write the prepared answer"); + + let attacker_source = dir.path().join("attacker.py"); + write_executable(&attacker_source, ATTACKER); + let report_path = dir.path().join("attack.json"); + let rendezvous = TempDir::new().expect("tempdir"); + + let mut attacker = std::process::Command::new(&attacker_source) + .arg(rendezvous.path().join("ready")) + .arg(rendezvous.path().join("go")) + .arg(&report_path) + .arg(&impostor_source) + .arg(std::env::temp_dir()) + .spawn() + .expect("start the attacker"); + + let previous = std::env::var_os(PRESPAWN_RENDEZVOUS_VAR); + std::env::set_var(PRESPAWN_RENDEZVOUS_VAR, rendezvous.path()); + let outcome = run_once(&installed, &identity); + match previous { + Some(value) => std::env::set_var(PRESPAWN_RENDEZVOUS_VAR, value), + None => std::env::remove_var(PRESPAWN_RENDEZVOUS_VAR), + } + let status = attacker.wait().expect("wait for the attacker"); + assert!(status.success(), "the attacker failed: {status}"); + + let report: Report = serde_json::from_slice(&fs::read(&report_path).expect("attack report")) + .expect("the attack report is JSON"); + + // The attacker really did walk a live invocation: it saw the request + // document the host had just written there. + assert!( + report + .seen + .iter() + .any(|path| path.contains("flutterdec-adapter-") && path.ends_with("/request.json")), + "the attacker never found the invocation workspace: roots {:?}, saw {:?}", + report.roots, + report.seen + ); + + let workspace_candidates: Vec<&BTreeMap> = report + .candidates + .iter() + .filter(|entry| entry["path"].contains("flutterdec-adapter-")) + .collect(); + + // Linux has no name to rewrite: the image is an anonymous sealed inode, so + // the attacker walks past it, the verified bytes run, and the host is + // entitled to say so. + #[cfg(target_os = "linux")] + { + assert!( + workspace_candidates.is_empty(), + "the invocation workspace exposed an executable pathname: {workspace_candidates:?}" + ); + let run = outcome.expect("the authorized adapter answers"); + let ran = lines(&authorized_log); + assert_eq!(ran.len(), 1, "the verified adapter did not run: {ran:?}"); + assert_eq!( + ran[0].1, authorized_digest, + "the executed image was not the verified artifact" + ); + assert_eq!( + lines(&impostor_log), + Vec::new(), + "the attacker's bytes executed" + ); + assert!( + run.containment.image_integrity.is_applied(), + "a sealed anonymous image was not reported as one: {:?}", + run.containment.image_integrity + ); + let _ = impostor_digest; + } + + // Darwin has a name, and this attacker is its owner. What follows is read + // out of what the platform actually did, and then the run is held to the + // outcome that really follows from it — including the one this platform + // cannot prevent. + #[cfg(not(target_os = "linux"))] + { + assert_eq!( + workspace_candidates.len(), + 1, + "the invocation workspace exposed something other than the frozen image: {workspace_candidates:?}" + ); + let entry = workspace_candidates[0]; + let flag = |key: &str| -> u64 { + entry[key] + .parse() + .unwrap_or_else(|_| panic!("the attacker reports {key} as a number: {entry:?}")) + }; + assert_ne!( + flag("flags_before") & UF_IMMUTABLE, + 0, + "the host did not freeze the image it was about to execute: {entry:?}" + ); + + let rewritten = entry["chflags_clear"] == "ok" + && entry["rewrite_in_place"] == "ok" + && entry["chflags_set"] == "ok"; + if !rewritten { + // This platform refused some part of it, which is stronger than the + // host claims. Then the attacker's bytes must not have run. + assert_eq!( + lines(&impostor_log), + Vec::new(), + "the attack was refused and the attacker's bytes executed anyway: {entry:?}" + ); + return; + } + + // The attack left the pre-exec check nothing to see: same device, same + // inode, flag back on. + assert_eq!( + entry["device_before"], entry["device_after"], + "the rewrite moved the image to another device, so this is not the in-place case: {entry:?}" + ); + assert_eq!( + entry["inode_before"], entry["inode_after"], + "the rewrite moved the inode, so this is not the in-place case: {entry:?}" + ); + assert_ne!( + flag("flags_after") & UF_IMMUTABLE, + 0, + "the attacker did not put the flag back, so this is not the in-place case: {entry:?}" + ); + + // And so the attacker's bytes are what ran. This is the documented + // ceiling, asserted as the outcome it is: nothing here claims the attack + // was prevented, because it was not. + let run = outcome.unwrap_or_else(|err| { + panic!("the rewritten image did not run to an answer: {err}"); + }); + let ran = lines(&impostor_log); + assert_eq!( + ran.len(), + 1, + "the in-place rewrite did not reach execution: {ran:?}" + ); + assert_eq!( + ran[0].1, impostor_digest, + "the bytes that ran are not the ones the attacker wrote" + ); + assert_eq!( + lines(&authorized_log), + Vec::new(), + "the verified bytes ran as well as the attacker's" + ); + let _ = authorized_digest; + + // What the host is required to do about it: not call that integrity. + let ControlState::Unavailable { reason } = &run.containment.image_integrity else { + panic!( + "the host reported a sealed image for a run whose bytes an owner rewrote: {:?}", + run.containment.image_integrity + ); + }; + assert!( + reason.contains("UF_IMMUTABLE"), + "the reported image integrity does not name the flag it rests on: {reason}" + ); + assert!( + reason.contains("best effort"), + "the reported image integrity does not say it is best effort: {reason}" + ); + } +} diff --git a/crates/flutterdec-adapter/tests/host_race.rs b/crates/flutterdec-adapter/tests/host_race.rs new file mode 100644 index 00000000..65c9f2b1 --- /dev/null +++ b/crates/flutterdec-adapter/tests/host_race.rs @@ -0,0 +1,208 @@ +//! Replacing the store artifact after it is verified cannot change what runs. +//! +//! A digest taken from a path says nothing about the file that path names a +//! moment later, so the property worth proving is not "the digest matched" but +//! "the process that started was the bytes that matched". The two adapters here +//! are distinguishable by side effect alone: the authorized one appends to one +//! log, the impostor to another, and exactly one of those files may exist when +//! the run is over. +//! +//! The swap is synchronized rather than timed. The host stops between holding +//! the verified bytes as a nameless executable descriptor and creating the +//! child, this test replaces the store file while it is stopped, and only then +//! releases it. So the swap is +//! provably inside the window an attacker would need, instead of being a race +//! this test might lose. +//! +//! This file holds one test on purpose: the rendezvous is selected by an +//! environment variable, and a second test running beside it in the same binary +//! would block on a rendezvous it never asked for. + +mod support; + +use flutterdec_adapter::host::PRESPAWN_RENDEZVOUS_VAR; +use flutterdec_adapter::model::InputRegionName; +use flutterdec_adapter::protocol::RequestedBackend; +use flutterdec_adapter::{run_adapter, AdapterInput, AdapterRegionInput, HostError, Limits}; +use flutterdec_loader::identity::SnapshotIdentity; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::time::{Duration, Instant}; +use tempfile::TempDir; + +const RET: [u8; 4] = 0xD65F_03C0u32.to_le_bytes(); + +/// An adapter that records that it ran and then fails, so the run ends in a +/// typed `NoResult` and the only interesting output is the log line. +fn adapter_source(log: &Path) -> String { + format!("#!/bin/sh\necho ran >> '{}'\nexit 7\n", log.display()) +} + +fn write_executable(path: &Path, body: &str) { + fs::write(path, body).expect("write adapter"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("chmod adapter"); +} + +fn regions() -> Vec> { + vec![vec![0u8; 64], vec![0u8; 64], RET.to_vec(), RET.repeat(4)] +} + +fn region_inputs(regions: &[Vec]) -> Vec> { + vec![ + AdapterRegionInput { + region: InputRegionName::VmData, + bytes: ®ions[0], + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::IsolateData, + bytes: ®ions[1], + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::VmInstructions, + bytes: ®ions[2], + virtual_address: Some(0x1000), + }, + AdapterRegionInput { + region: InputRegionName::IsolateInstructions, + bytes: ®ions[3], + virtual_address: Some(0x2000), + }, + ] +} + +fn ran(log: &Path) -> usize { + fs::read_to_string(log) + .map(|text| text.lines().filter(|line| !line.is_empty()).count()) + .unwrap_or(0) +} + +fn wait_for(path: &Path, what: &str) { + let deadline = Instant::now() + Duration::from_secs(60); + while !path.exists() { + assert!( + Instant::now() < deadline, + "{what} never appeared at {}", + path.display() + ); + std::thread::sleep(Duration::from_millis(2)); + } +} + +/// Run the rig once and return the refusal, with an optional swap performed +/// while the host is stopped before spawning. +fn run_once( + installed: &support::Authorized, + identity: &SnapshotIdentity, + rendezvous: Option<(&Path, &[u8])>, +) -> HostError { + let regions = regions(); + let record = installed.record.clone(); + let input = AdapterInput { + identity, + authorization: installed.authorization_for(&record), + producer: installed.producer_for(&record), + compatibility: installed.binding_for(&record), + regions: region_inputs(®ions), + input_path: None, + libapp: None, + requested_backend: RequestedBackend::Auto, + limits: Limits::default(), + }; + + let Some((dir, impostor)) = rendezvous else { + return run_adapter(&installed.exec, &input) + .map(|_| ()) + .expect_err("the fixture adapter writes no result"); + }; + + std::thread::scope(|scope| { + let runner = scope.spawn(|| { + run_adapter(&installed.exec, &input) + .map(|_| ()) + .expect_err("the fixture adapter writes no result") + }); + + // The host has verified the artifact and is holding it as a descriptor + // by the time this file appears, and has not created a child yet. + wait_for(&dir.join("ready"), "the pre-spawn rendezvous"); + fs::write(&installed.exec, impostor).expect("replace the store artifact"); + assert_eq!( + fs::read(&installed.exec).expect("read the store artifact"), + impostor, + "the store artifact was not actually replaced" + ); + fs::write(dir.join("go"), b"go").expect("release the rendezvous"); + + runner.join().expect("the run thread panicked") + }) +} + +#[test] +fn replacing_the_store_artifact_after_verification_cannot_change_what_executes() { + let dir = TempDir::new().expect("tempdir"); + let authorized_log = dir.path().join("authorized.log"); + let impostor_log = dir.path().join("impostor.log"); + + let authorized_source = dir.path().join("authorized_adapter"); + write_executable(&authorized_source, &adapter_source(&authorized_log)); + let impostor_source = dir.path().join("impostor_adapter"); + write_executable(&impostor_source, &adapter_source(&impostor_log)); + let impostor_bytes = fs::read(&impostor_source).expect("read impostor"); + + let identity = support::identity(); + + // The control comes first: the impostor is a real, working adapter, so a + // later run that produces no impostor line is evidence about which bytes + // executed rather than evidence that the impostor could never run at all. + let control = support::Authorized::install(&impostor_source, &identity); + let err = run_once(&control, &identity, None); + assert!( + matches!(err, HostError::NoResult { .. }), + "the control adapter did not run: {err}" + ); + assert_eq!(ran(&impostor_log), 1, "the impostor cannot execute at all"); + fs::remove_file(&impostor_log).expect("reset the impostor log"); + + // The race. The registry authorizes the first adapter; the store file is + // replaced with the second one after verification and before the spawn. + let installed = support::Authorized::install(&authorized_source, &identity); + let rendezvous = TempDir::new().expect("tempdir"); + let previous = std::env::var_os(PRESPAWN_RENDEZVOUS_VAR); + std::env::set_var(PRESPAWN_RENDEZVOUS_VAR, rendezvous.path()); + let err = run_once( + &installed, + &identity, + Some((rendezvous.path(), &impostor_bytes)), + ); + match previous { + Some(value) => std::env::set_var(PRESPAWN_RENDEZVOUS_VAR, value), + None => std::env::remove_var(PRESPAWN_RENDEZVOUS_VAR), + } + + assert!( + matches!(err, HostError::NoResult { .. }), + "the authorized adapter did not run: {err}" + ); + assert_eq!( + ran(&authorized_log), + 1, + "the verified adapter did not execute" + ); + assert_eq!( + ran(&impostor_log), + 0, + "the swapped store bytes executed: {} impostor run(s)", + ran(&impostor_log) + ); + + // And the swap really did land: the store still holds the impostor, so the + // run above executed bytes the store path no longer names. + assert_eq!( + fs::read(&installed.exec).expect("read the store artifact"), + impostor_bytes, + "the store artifact was restored, so nothing was raced" + ); +} diff --git a/crates/flutterdec-adapter/tests/host_workspace_race.rs b/crates/flutterdec-adapter/tests/host_workspace_race.rs new file mode 100644 index 00000000..04e11c84 --- /dev/null +++ b/crates/flutterdec-adapter/tests/host_workspace_race.rs @@ -0,0 +1,439 @@ +//! A same-user attacker who finds the invocation workspace finds nothing to attack. +//! +//! The store artifact is not the only path an attacker could aim at. A host that +//! verifies bytes and then materializes them somewhere to execute them has moved +//! the target rather than removed it: the copy is discoverable, it is owned by +//! the same user, and `0500` stops nothing, because the owner of a file may +//! `chmod` it back, rename it away, unlink it, or drop a different file in its +//! place. So the property under test is not "the copy is hard to write to" but +//! "there is no copy to find". +//! +//! The attacker here is a real process, not a thread pretending to be one. It +//! waits for the host to reach the point where verification is complete and no +//! child exists yet, walks the invocation workspace from the outside, and for +//! every executable file it finds tries to clear the file flags, overwrite, +//! `chmod` and overwrite again, rename, unlink, and replace. What it may do is +//! proved rather than assumed: a decoy executable is planted in a directory +//! scanned in the same pass, and the run is only evidence if the decoy was found +//! and destroyed. +//! +//! Clearing the flags is the whole difference between the two platforms. Where +//! the image is an anonymous descriptor there is nothing in the workspace to +//! clear flags on. Where it is a frozen pathname the flag is a *user* flag and +//! this attacker is the owner, so it really does get the name — and the property +//! under test becomes the next one: the host notices, in the child and before +//! `execve`, that the name is no longer the descriptor it verified, and executes +//! nothing at all. +//! +//! This file holds one test on purpose: the rendezvous is selected by an +//! environment variable, and a second test running beside it in the same binary +//! would block on a rendezvous it never asked for. + +mod support; + +use flutterdec_adapter::host::PRESPAWN_RENDEZVOUS_VAR; +use flutterdec_adapter::model::InputRegionName; +use flutterdec_adapter::protocol::RequestedBackend; +use flutterdec_adapter::{run_adapter, AdapterInput, AdapterRegionInput, HostError, Limits}; +use flutterdec_loader::identity::SnapshotIdentity; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use tempfile::TempDir; + +const RET: [u8; 4] = 0xD65F_03C0u32.to_le_bytes(); + +/// An adapter that reports which image it is actually running. +/// +/// It reads itself through `argv[0]`, which is the only name it has, and records +/// that name together with the digest of the bytes behind it. That turns "the +/// right adapter ran" into an equality between two digests rather than an +/// inference from which log file grew. +fn adapter_source(log: &Path) -> String { + format!( + r#"#!/usr/bin/env python3 +import hashlib, pathlib, sys + +image = pathlib.Path(sys.argv[0]).read_bytes() +with open({log:?}, "a") as fp: + fp.write(sys.argv[0] + " " + hashlib.sha256(image).hexdigest() + "\n") +raise SystemExit(7) +"#, + log = log.display().to_string() + ) +} + +/// The attacker, which is deliberately not in a hurry and not clever: it waits +/// for a synchronization point the host publishes, then walks directories. +const ATTACKER: &str = r#"#!/usr/bin/env python3 +import errno, json, os, pathlib, stat, sys, time + +ready, go, report, impostor, tmp_root, decoy = sys.argv[1:7] + +deadline = time.monotonic() + 60 +while not os.path.exists(ready): + if time.monotonic() > deadline: + raise SystemExit("the host never reached the rendezvous") + time.sleep(0.002) + +impostor_bytes = pathlib.Path(impostor).read_bytes() +roots = sorted(pathlib.Path(tmp_root).glob("flutterdec-adapter-*")) + [pathlib.Path(decoy)] + +seen, candidates = [], [] +for root in roots: + for base, _dirs, files in os.walk(root): + for name in files: + path = pathlib.Path(base) / name + seen.append(str(path)) + try: + mode = path.lstat().st_mode + except OSError: + continue + if not stat.S_ISREG(mode) or not mode & 0o111: + continue + + entry = {"path": str(path)} + + def attempt(key, action): + try: + action() + entry[key] = "ok" + except OSError as err: + entry[key] = err.strerror + + # First, because every refusal below is only interesting once the + # owner has taken back whatever the host welded to this name. + def clear_flags(): + chflags = getattr(os, "chflags", None) + if chflags is None: + raise OSError(errno.ENOSYS, "this platform has no chflags") + chflags(path, 0) + + attempt("chflags_clear", clear_flags) + attempt("overwrite", lambda: path.write_bytes(impostor_bytes)) + attempt("chmod", lambda: os.chmod(path, 0o777)) + # The point of the second write: an unwritable mode is not a + # defence against the user who owns the file. + attempt("overwrite_after_chmod", lambda: path.write_bytes(impostor_bytes)) + moved = path.with_name(path.name + ".moved") + attempt("rename", lambda: os.rename(path, moved)) + target = moved if entry.get("rename") == "ok" else path + attempt("unlink", lambda: os.unlink(target)) + # And replacing the name outright, which even unwritable bytes + # cannot survive. + def replace(): + path.write_bytes(impostor_bytes) + os.chmod(path, 0o755) + + attempt("replace", replace) + candidates.append(entry) + +pathlib.Path(report).write_text( + json.dumps({"roots": [str(root) for root in roots], "seen": seen, "candidates": candidates}) +) +pathlib.Path(go).write_text("go") +"#; + +#[derive(Debug, serde::Deserialize)] +struct Report { + roots: Vec, + seen: Vec, + candidates: Vec>, +} + +fn write_executable(path: &Path, body: &str) { + fs::write(path, body).expect("write adapter"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("chmod adapter"); +} + +fn regions() -> Vec> { + vec![vec![0u8; 64], vec![0u8; 64], RET.to_vec(), RET.repeat(4)] +} + +fn region_inputs(regions: &[Vec]) -> Vec> { + vec![ + AdapterRegionInput { + region: InputRegionName::VmData, + bytes: ®ions[0], + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::IsolateData, + bytes: ®ions[1], + virtual_address: None, + }, + AdapterRegionInput { + region: InputRegionName::VmInstructions, + bytes: ®ions[2], + virtual_address: Some(0x1000), + }, + AdapterRegionInput { + region: InputRegionName::IsolateInstructions, + bytes: ®ions[3], + virtual_address: Some(0x2000), + }, + ] +} + +/// The ` ` lines one adapter left behind. +fn lines(log: &Path) -> Vec<(String, String)> { + fs::read_to_string(log) + .unwrap_or_default() + .lines() + .filter(|line| !line.is_empty()) + .map(|line| { + let (name, digest) = line + .rsplit_once(' ') + .expect("the adapter writes two fields"); + (name.to_string(), digest.to_string()) + }) + .collect() +} + +fn run_once(installed: &support::Authorized, identity: &SnapshotIdentity) -> HostError { + let regions = regions(); + let record = installed.record.clone(); + let input = AdapterInput { + identity, + authorization: installed.authorization_for(&record), + producer: installed.producer_for(&record), + compatibility: installed.binding_for(&record), + regions: region_inputs(®ions), + input_path: None, + libapp: None, + requested_backend: RequestedBackend::Auto, + limits: Limits::default(), + }; + run_adapter(&installed.exec, &input) + .map(|_| ()) + .expect_err("the fixture adapter writes no result") +} + +#[test] +fn an_attacker_who_walks_the_invocation_workspace_finds_no_executable_to_subvert() { + let dir = TempDir::new().expect("tempdir"); + let authorized_log = dir.path().join("authorized.log"); + let impostor_log = dir.path().join("impostor.log"); + + let authorized_source = dir.path().join("authorized_adapter"); + write_executable(&authorized_source, &adapter_source(&authorized_log)); + let impostor_source = dir.path().join("impostor_adapter"); + write_executable(&impostor_source, &adapter_source(&impostor_log)); + let authorized_digest = support::hex_digest(&fs::read(&authorized_source).expect("read")); + + let identity = support::identity(); + + // The control: the impostor is a working adapter, so "no impostor line" + // later is evidence about which bytes ran rather than evidence that these + // bytes could never run at all. + let control = support::Authorized::install(&impostor_source, &identity); + let err = run_once(&control, &identity); + assert!( + matches!(err, HostError::NoResult { .. }), + "the control adapter did not run: {err}" + ); + assert_eq!( + lines(&impostor_log).len(), + 1, + "the impostor cannot execute at all, so this test proves nothing" + ); + fs::remove_file(&impostor_log).expect("reset the impostor log"); + + // A file the attacker is allowed to destroy, in a directory it scans in the + // same pass as the workspace. Everything it reports about the workspace is + // only meaningful next to what it did here. + let decoy_dir = dir.path().join("decoy"); + fs::create_dir(&decoy_dir).expect("mkdir decoy"); + let decoy = decoy_dir.join("planted_adapter"); + write_executable(&decoy, "#!/bin/sh\nexit 0\n"); + + let attacker_source = dir.path().join("attacker.py"); + write_executable(&attacker_source, ATTACKER); + let report_path = dir.path().join("attack.json"); + let rendezvous = TempDir::new().expect("tempdir"); + + let mut attacker = std::process::Command::new(&attacker_source) + .arg(rendezvous.path().join("ready")) + .arg(rendezvous.path().join("go")) + .arg(&report_path) + .arg(&impostor_source) + .arg(std::env::temp_dir()) + .arg(&decoy_dir) + .spawn() + .expect("start the attacker"); + + let installed = support::Authorized::install(&authorized_source, &identity); + let previous = std::env::var_os(PRESPAWN_RENDEZVOUS_VAR); + std::env::set_var(PRESPAWN_RENDEZVOUS_VAR, rendezvous.path()); + let err = run_once(&installed, &identity); + match previous { + Some(value) => std::env::set_var(PRESPAWN_RENDEZVOUS_VAR, value), + None => std::env::remove_var(PRESPAWN_RENDEZVOUS_VAR), + } + let status = attacker.wait().expect("wait for the attacker"); + assert!(status.success(), "the attacker failed: {status}"); + + let report: Report = serde_json::from_slice(&fs::read(&report_path).expect("attack report")) + .expect("the attack report is JSON"); + + // The attacker really did reach a live invocation: it saw the request + // document the host had just written into the workspace it walked. + let workspace_files: Vec<&String> = report + .seen + .iter() + .filter(|path| path.contains("flutterdec-adapter-")) + .collect(); + assert!( + workspace_files + .iter() + .any(|path| path.ends_with("/request.json")), + "the attacker never found the invocation workspace: roots {:?}, saw {:?}", + report.roots, + report.seen + ); + + // And it really can destroy what it finds: the decoy is the proof, and it is + // the only thing it found. + let decoy_entry = report + .candidates + .iter() + .find(|entry| entry["path"] == decoy.display().to_string()) + .unwrap_or_else(|| panic!("the attacker did not find the planted decoy: {report:?}")); + for step in [ + "overwrite", + "chmod", + "overwrite_after_chmod", + "rename", + "unlink", + "replace", + ] { + assert_eq!( + decoy_entry[step], "ok", + "the attacker could not {step} the planted decoy: {decoy_entry:?}" + ); + } + let workspace_candidates: Vec<&std::collections::BTreeMap> = report + .candidates + .iter() + .filter(|entry| entry["path"].contains("flutterdec-adapter-")) + .collect(); + + // On Linux the image is an anonymous descriptor, so there is nothing in the + // workspace for the attacker to aim at in the first place. + #[cfg(target_os = "linux")] + assert!( + workspace_candidates.is_empty(), + "the invocation workspace exposed an executable pathname: {workspace_candidates:?}" + ); + + // Where a descriptor cannot be executed the image has to be a path, so the + // attacker found a name — and the flag welded to that name is its owner's to + // clear. Whether it got the image is not assumed either way: it is read back + // out of what the attacker itself reported, and the run is then held to the + // outcome that follows. + #[cfg(not(target_os = "linux"))] + let subverted = { + assert_eq!( + workspace_candidates.len(), + 1, + "the invocation workspace exposed something other than the frozen image: {workspace_candidates:?}" + ); + let entry = workspace_candidates[0]; + let taken = entry["chflags_clear"] == "ok" && entry["replace"] == "ok"; + if !taken { + // The freeze held, which is the stronger outcome. Then nothing the + // attacker tried may have worked. + for step in [ + "overwrite", + "chmod", + "overwrite_after_chmod", + "rename", + "unlink", + "replace", + ] { + assert_ne!( + entry[step], "ok", + "a same-user attacker could {step} the running image: {entry:?}" + ); + } + } + taken + }; + #[cfg(target_os = "linux")] + let subverted = false; + + // Whatever else happened, the one thing that must never happen did not. + assert_eq!( + lines(&impostor_log), + Vec::new(), + "the attacker's bytes executed" + ); + + // The attacker got the name. Then the run must have ended before a process + // existed, with the host saying why, and nothing may have executed at all. + if subverted { + assert!( + matches!(err, HostError::ImageNotSealed(_)), + "the image was replaced under the host and the run was not refused as an image failure: {err}" + ); + assert!( + err.is_pre_spawn(), + "a refusal that executed nothing was not classified as pre-spawn: {err}" + ); + assert_eq!( + lines(&authorized_log), + Vec::new(), + "the host refused the run and something still executed" + ); + return; + } + + // The run itself: the authorized bytes executed, under a name that is not a + // path in the workspace the attacker just walked. + assert!( + matches!(err, HostError::NoResult { .. }), + "the authorized adapter did not run: {err}" + ); + let ran = lines(&authorized_log); + assert_eq!( + ran.len(), + 1, + "the verified adapter did not execute: {ran:?}" + ); + let (argv0, digest) = &ran[0]; + assert_eq!( + digest, &authorized_digest, + "the executed image was not the verified artifact" + ); + #[cfg(target_os = "linux")] + { + assert!( + !argv0.contains("flutterdec-adapter-"), + "the adapter was executed under a workspace pathname: {argv0}" + ); + // What the kernel hands a `#!` interpreter when the image is a + // descriptor: a name for the descriptor itself, which exists only + // inside the child. + assert!( + argv0.starts_with("/dev/fd/") || argv0.starts_with("/proc/self/fd/"), + "the adapter was executed under a filesystem pathname: {argv0}" + ); + } + + // Where the image has to be a path, the loop closes the other way: the one + // thing the attacker found and could not touch is the one that ran. The two + // spellings can differ by the platform's own symlinks, so the final + // component is what is compared. + #[cfg(not(target_os = "linux"))] + { + let name = argv0 + .rsplit('/') + .next() + .expect("argv[0] has a final component"); + let found = &workspace_candidates[0]["path"]; + assert!( + found.ends_with(name), + "the adapter ran from something other than the image the attacker failed to subvert: {argv0} against {found}" + ); + } +} diff --git a/crates/flutterdec-adapter/tests/model_v4.rs b/crates/flutterdec-adapter/tests/model_v4.rs index abac8279..64318ad4 100644 --- a/crates/flutterdec-adapter/tests/model_v4.rs +++ b/crates/flutterdec-adapter/tests/model_v4.rs @@ -354,6 +354,13 @@ fn a_model_cannot_change_a_host_selected_fact() { v["compatibility"]["profile_sha256"] = json!(support::digest("other").to_string()) }), ), + // `null` is a legal binding for a model the host recovered itself, and + // it is exactly the wrong answer to an adapter request: the record that + // authorized the run is the one thing the model may not drop. + ( + "compatibility binding presence", + Box::new(|v: &mut Value| v["compatibility"] = Value::Null), + ), ]; for (field, mutate) in cases { @@ -664,15 +671,37 @@ fn placeholder_strings_cannot_stand_in_for_unrecovered_names() { ); } -/// `main` is a real Dart function name. The placeholder rule rejects admissions -/// of ignorance, not ordinary identifiers that happen to look suspicious. +/// `main` and `Null` are real Dart identifiers. The placeholder rule rejects +/// admissions of ignorance, not ordinary identifiers that happen to look +/// suspicious or share a root with sentinels. In particular `Null` (the Dart core +/// class) and `None`/`Nil` (common in functional packages) are accepted, whereas +/// lowercase `null`/`none`/`nil` emitted by naive adapter scripts are rejected. #[test] fn ordinary_names_that_resemble_defaults_are_still_accepted() { - for name in ["main", "Global", "build", "Object"] { + for name in ["main", "Global", "build", "Object", "Null", "None", "Nil"] { let bytes = mutated(model_json(&maximal_model()), |v| { v["functions"][0]["name"]["text"] = json!(name) }); - parse_and_validate(&bytes).unwrap_or_else(|err| panic!("{name} rejected: {err}")); + parse_and_validate(&bytes).unwrap_or_else(|err| panic!("function {name} rejected: {err}")); + + let bytes = mutated(model_json(&maximal_model()), |v| { + v["classes"][0]["name"] = json!(name) + }); + parse_and_validate(&bytes).unwrap_or_else(|err| panic!("class {name} rejected: {err}")); + } + + // Lowercase sentinels remain rejected. + for sentinel in ["null", "none", "nil"] { + let bytes = mutated(model_json(&maximal_model()), |v| { + v["classes"][0]["name"] = json!(sentinel) + }); + assert_eq!( + expect_validation_error(&bytes), + ValidationError::PlaceholderName { + field: "class name", + value: sentinel.to_string(), + } + ); } } @@ -1176,7 +1205,7 @@ fn the_schema_pins_the_model_version() { /// did: file every such class under an invented library URI. #[test] fn a_class_with_no_recovered_library_is_valid() { - let mut model = support::maximal_model(); + let mut model = maximal_model(); model.libraries.clear(); model.capabilities.libraries = CapabilityLevel::Unavailable; model.diagnostics.push(Diagnostic::unavailable( @@ -1190,3 +1219,34 @@ fn a_class_with_no_recovered_library_is_valid() { assert!(parsed.classes.iter().all(|c| c.library.is_none())); assert_eq!(parsed.class_library_uri(ClassId(2)), None); } + +/// A model with no compatibility binding is well-formed, and is only accepted +/// by a host that had no record to hand it. +/// +/// The two halves matter together: parsing has to allow `null` so a +/// core-recovered model is expressible at all, and validation has to keep the +/// binding mandatory for every run that had one. +#[test] +fn an_unbound_model_is_accepted_only_where_no_record_authorized_the_run() { + let mut model = maximal_model(); + model.compatibility = None; + let bytes = model.to_canonical_json(); + + let parsed = ProgramModel::from_json(&bytes).expect("null compatibility is a valid v4 model"); + assert_eq!(parsed.compatibility, None); + + let unbound_host = flutterdec_adapter::validate::HostSelectedContext { + compatibility: None, + ..host() + }; + validate(&parsed, &unbound_host) + .expect("a host with no record accepts a model with no binding"); + + // And the same model against a host that did select a record. + assert_eq!( + validate(&parsed, &host()), + Err(ValidationError::HostFactMismatch { + field: "compatibility binding presence" + }) + ); +} diff --git a/crates/flutterdec-adapter/tests/producer_v4.rs b/crates/flutterdec-adapter/tests/producer_v4.rs index 9f5e4581..37b85d03 100644 --- a/crates/flutterdec-adapter/tests/producer_v4.rs +++ b/crates/flutterdec-adapter/tests/producer_v4.rs @@ -15,14 +15,12 @@ mod support; use flutterdec_adapter::model::{ - CapabilityLevel, ClassId, Domain, PoolIndexSpace, Producer, ProducerTrust, ProgramModel, + CapabilityLevel, ClassId, Domain, InputRegionName, PoolIndexSpace, ProducerTrust, ProgramModel, Provenance, }; -use flutterdec_adapter::model::{CompatibilityBinding, InputRegionName}; -use flutterdec_adapter::primitives::Sha256Digest; use flutterdec_adapter::protocol::{BackendId, RequestedBackend}; use flutterdec_adapter::{ - install_adapter, run_adapter, AdapterInput, AdapterRegionInput, AdapterRun, + run_adapter, AdapterInput, AdapterRegionInput, AdapterRun, LibappSource, Limits, }; use flutterdec_loader::identity::SnapshotIdentity; use std::fs; @@ -45,109 +43,60 @@ fn repo_root() -> PathBuf { .expect("canonicalize repo root") } -/// A scratch repo with the real producer installed under it. -struct Installed { - _dir: TempDir, - exec: PathBuf, -} - -fn install(hash: &str) -> Installed { - install_named(hash, None) +/// The real producer, published as the artifact a registry record authorizes. +fn install(identity: &SnapshotIdentity) -> support::Authorized { + install_named(identity, None) } /// Install under an arbitrary adapter file name. /// /// Used to prove that a deliberately misleading filename changes nothing: the -/// resolved backend comes from the protocol result, not from the path. -fn install_named(hash: &str, file_name: Option<&str>) -> Installed { - let dir = TempDir::new().expect("tempdir"); - let root = dir.path(); - fs::create_dir_all(root.join("adapters/python")).expect("mkdir python"); - fs::create_dir_all(root.join("adapters/installed")).expect("mkdir installed"); - fs::copy( - repo_root().join("adapters/python/adapter_template.py"), - root.join("adapters/python/adapter_template.py"), +/// resolved backend comes from the protocol result, not from the path, and the +/// record authorizes the path rather than the path authorizing itself. +fn install_named(identity: &SnapshotIdentity, file_name: Option<&str>) -> support::Authorized { + support::Authorized::install_named( + &repo_root().join("adapters/python/adapter_template.py"), + identity, + file_name, ) - .expect("copy producer"); - - if let Some(name) = file_name { - let manifest = serde_json::json!({ - "entries": [{ "snapshot_hash": hash, "version": "unknown", "adapter": name }] - }); - fs::write( - root.join("adapters/manifest.json"), - serde_json::to_vec_pretty(&manifest).expect("manifest json"), - ) - .expect("write manifest"); - } - - let exec = install_adapter(root, hash).expect("install adapter"); - Installed { _dir: dir, exec } } -/// Install an adapter that cannot discover any external backend. +/// Install a producer that cannot discover any external backend. /// /// The producer resolves r2flutter and blutter from `FLUTTERDEC_*` variables and -/// then from `PATH`, and the installed wrapper inherits this test runner's -/// environment. A developer machine with either tool installed would otherwise -/// resolve a real backend and invalidate the assertion. The neutralization is -/// done inside the adapter's own process, as the blutter bridge test does, so -/// concurrent tests are unaffected. -fn install_without_external_backends(hash: &str) -> Installed { - let installed = install(hash); - let adapters = installed - .exec - .parent() - .and_then(Path::parent) - .expect("the installed adapter lives under /adapters/installed"); - fs::create_dir_all(adapters.join("no-tools")).expect("mkdir no-tools"); - fs::write( - &installed.exec, - r#"#!/usr/bin/env python3 -from pathlib import Path -import os -import sys -root = Path(__file__).resolve().parents[1] -for name in ( - "FLUTTERDEC_R2FLUTTER_CMD", - "FLUTTERDEC_R2FLUTTER_BIN", - "FLUTTERDEC_BLUTTER_CMD", - "FLUTTERDEC_BLUTTER_PY", -): - os.environ.pop(name, None) -os.environ["PATH"] = str(root / "no-tools") -sys.path.insert(0, str(root / "python")) -import adapter_template -if __name__ == "__main__": - raise SystemExit(adapter_template.entrypoint()) -"#, - ) - .expect("write adapter exec"); - set_executable(&installed.exec); - installed -} - -/// The host's own producer record. +/// then from `PATH`, and the spawned adapter inherits this test runner's +/// environment. A machine with either tool installed would otherwise resolve a +/// real backend, and the assertion would stop testing what it claims. /// -/// `Local` is not a judgement call here: `run_adapter` refuses any identity that -/// did not clear the exact-selection gate, so every run that happens at all is -/// one a locally installed adapter was authorized for. -fn producer(exec: &Path) -> Producer { - Producer { - id: "flutterdec-local-python".to_string(), - version: "unknown".to_string(), - artifact_sha256: Sha256Digest::of(&fs::read(exec).expect("read adapter artifact")), - trust: ProducerTrust::Local, - } -} +/// The neutralization is prepended to the producer source before it is +/// published, so the artifact the record authorizes is the artifact that runs, +/// and it only affects this adapter's own process rather than concurrent tests. +fn install_without_external_backends(identity: &SnapshotIdentity) -> support::Authorized { + const NEUTRALIZE: &str = concat!( + "import os as _os\n", + "for _name in (\n", + " \"FLUTTERDEC_R2FLUTTER_CMD\",\n", + " \"FLUTTERDEC_R2FLUTTER_BIN\",\n", + " \"FLUTTERDEC_BLUTTER_CMD\",\n", + " \"FLUTTERDEC_BLUTTER_PY\",\n", + "):\n", + " _os.environ.pop(_name, None)\n", + "_os.environ[\"PATH\"] = \"\"\n", + ); -fn compatibility() -> CompatibilityBinding { - CompatibilityBinding { - record_sha256: Sha256Digest::of(b"producer test record"), - parser_family_id: "flutterdec-local-python".to_string(), - profile_id: "unresolved".to_string(), - profile_sha256: Sha256Digest::of(b"producer test profile"), - } + // After the `__future__` import, which Python requires to come first. + const ANCHOR: &str = "from __future__ import annotations\n"; + + let source = repo_root().join("adapters/python/adapter_template.py"); + let producer = fs::read_to_string(&source).expect("read producer"); + let (head, rest) = producer + .split_once(ANCHOR) + .expect("the producer imports __future__ annotations"); + let dir = TempDir::new().expect("tempdir"); + let neutralized = dir.path().join("adapter_template.py"); + fs::write(&neutralized, format!("{head}{ANCHOR}{NEUTRALIZE}{rest}")) + .expect("write neutralized producer"); + support::Authorized::install_named(&neutralized, identity, None) } struct Snapshot { @@ -199,7 +148,7 @@ fn regions(snapshot: &Snapshot) -> Vec> { } fn run( - installed: &Installed, + installed: &support::Authorized, identity: &SnapshotIdentity, snapshot: &Snapshot, backend: RequestedBackend, @@ -208,15 +157,17 @@ fn run( &installed.exec, &AdapterInput { identity, - producer: producer(&installed.exec), - compatibility: compatibility(), + authorization: installed.authorization(), + producer: installed.producer(), + compatibility: installed.binding(), regions: regions(snapshot), input_path: None, - libapp_path: None, + libapp: None, requested_backend: backend, + limits: Limits::default(), }, ) - .map_err(|err| format!("{err:#}")) + .map_err(|err| err.to_string()) } /// Strings that would be fabrications if they appeared anywhere in the model. @@ -278,8 +229,8 @@ fn assert_unavailable_domains_are_explained(model: &ProgramModel) { #[test] fn a_snapshot_with_nothing_in_it_yields_unavailable_domains_and_no_invented_records() { - let installed = install("deadbeefdeadbeefdeadbeefdeadbeef"); let identity = support::identity(); + let installed = install(&identity); let run = run( &installed, &identity, @@ -326,8 +277,8 @@ fn a_snapshot_with_nothing_in_it_yields_unavailable_domains_and_no_invented_reco #[test] fn heuristic_code_ranges_are_labelled_heuristic_and_stay_unnamed() { - let installed = install("deadbeefdeadbeefdeadbeefdeadbeef"); let identity = support::identity(); + let installed = install(&identity); let mut snapshot = empty_snapshot(); // Three prologues in the isolate instruction image, so the scanner has // something to find and the domain comes back partial rather than empty. @@ -380,8 +331,8 @@ fn heuristic_code_ranges_are_labelled_heuristic_and_stay_unnamed() { #[test] fn carved_strings_become_ordinal_pool_entries_never_hardware_ones() { - let installed = install("deadbeefdeadbeefdeadbeefdeadbeef"); let identity = support::identity(); + let installed = install(&identity); let mut snapshot = empty_snapshot(); snapshot.isolate_data = b"package:sample/widgets/home.dart\0onPressed\0Scaffold\0".to_vec(); @@ -438,10 +389,10 @@ fn carved_strings_become_ordinal_pool_entries_never_hardware_ones() { #[test] fn a_pinned_backend_that_cannot_run_fails_instead_of_falling_back() { - let installed = install_without_external_backends("deadbeefdeadbeefdeadbeefdeadbeef"); let identity = support::identity(); - // The adapter cannot resolve r2flutter at all, so the backend cannot run. - // Pinned means it must fail rather than quietly answer as internal. + let installed = install_without_external_backends(&identity); + // The producer cannot resolve r2flutter at all, and the input path the + // backend needs is absent, so it cannot run. Pinned means it must fail. let err = run( &installed, &identity, @@ -457,8 +408,8 @@ fn a_pinned_backend_that_cannot_run_fails_instead_of_falling_back() { #[test] fn auto_falls_back_to_internal_and_says_why() { - let installed = install_without_external_backends("deadbeefdeadbeefdeadbeefdeadbeef"); let identity = support::identity(); + let installed = install_without_external_backends(&identity); let run = run( &installed, &identity, @@ -485,7 +436,7 @@ fn a_misleading_adapter_filename_cannot_change_the_resolved_backend() { "internal_but_actually_r2flutter", "snapshot_serwalker", ] { - let installed = install_named("deadbeefdeadbeefdeadbeefdeadbeef", Some(name)); + let installed = install_named(&identity, Some(name)); let run = run( &installed, &identity, @@ -500,8 +451,8 @@ fn a_misleading_adapter_filename_cannot_change_the_resolved_backend() { ); assert_eq!( run.model.producer.trust, - ProducerTrust::Local, - "trust is host-assigned; a filename cannot raise it" + ProducerTrust::Registered, + "trust is host-assigned; a filename cannot change it" ); } } @@ -513,8 +464,8 @@ fn a_misleading_adapter_filename_cannot_change_the_resolved_backend() { fn a_producer_that_emits_a_legacy_model_is_rejected() { let dir = TempDir::new().expect("tempdir"); let root = dir.path(); - fs::create_dir_all(root.join("adapters/installed")).expect("mkdir"); - let exec = root.join("adapters/installed/legacy_adapter"); + fs::create_dir_all(root.join("artifacts")).expect("mkdir"); + let exec = root.join("artifacts/legacy_adapter"); fs::write( &exec, r#"#!/usr/bin/env python3 @@ -556,10 +507,8 @@ pathlib.Path(args.result).write_text(json.dumps({ set_executable(&exec); let identity = support::identity(); - let installed = Installed { - _dir: dir, - exec: exec.clone(), - }; + let installed = support::Authorized::install_named(&exec, &identity, Some("legacy_adapter")); + drop(dir); let err = run( &installed, &identity, @@ -588,7 +537,7 @@ fn the_blutter_bridge_reports_what_the_dump_says_and_invents_nothing() { let dir = TempDir::new().expect("tempdir"); let root = dir.path().to_path_buf(); fs::create_dir_all(root.join("adapters/python")).expect("mkdir python"); - fs::create_dir_all(root.join("adapters/installed")).expect("mkdir installed"); + fs::create_dir_all(root.join("artifacts")).expect("mkdir artifacts"); fs::copy( repo_root().join("adapters/python/adapter_template.py"), root.join("adapters/python/adapter_template.py"), @@ -631,22 +580,33 @@ asm.mkdir(parents=True, exist_ok=True) // The runner is pointed at through the adapter's own environment rather than // this process's, so concurrent tests cannot see it. - let exec = root.join("adapters/installed/blutter_adapter"); + // The producer library is reached through an absolute path baked in here, + // not relative to the running file. The host executes the verified artifact + // from a descriptor with no pathname, so the running script has no directory + // at all on its import path, and a bridge that assumed otherwise would be + // reaching for bytes no record ever authorized. + let library = root.join("python"); + fs::create_dir_all(&library).expect("mkdir library"); + fs::copy( + repo_root().join("adapters/python/adapter_template.py"), + library.join("adapter_template.py"), + ) + .expect("stage producer library"); + + let exec = root.join("artifacts/blutter_adapter"); fs::write( &exec, format!( - "#!/usr/bin/env python3\nfrom pathlib import Path\nimport os\nimport sys\nroot = Path(__file__).resolve().parents[1]\nos.environ['FLUTTERDEC_BLUTTER_CMD'] = {:?}\nsys.path.insert(0, str(root / 'python'))\nimport adapter_template\nif __name__ == '__main__':\n raise SystemExit(adapter_template.entrypoint())\n", - fake.display().to_string() + "#!/usr/bin/env python3\nimport os\nimport sys\nos.environ['FLUTTERDEC_BLUTTER_CMD'] = {:?}\nsys.path.insert(0, {:?})\nimport adapter_template\nif __name__ == '__main__':\n raise SystemExit(adapter_template.entrypoint())\n", + fake.display().to_string(), + library.display().to_string() ), ) .expect("write adapter exec"); set_executable(&exec); - let installed = Installed { - _dir: dir, - exec: exec.clone(), - }; let identity = support::identity(); + let installed = support::Authorized::install_named(&exec, &identity, Some("blutter_adapter")); let mut snapshot = empty_snapshot(); snapshot.isolate_instr = RET.repeat(16); let input = root.join("app.apk"); @@ -656,15 +616,17 @@ asm.mkdir(parents=True, exist_ok=True) &installed.exec, &AdapterInput { identity: &identity, - producer: producer(&installed.exec), - compatibility: compatibility(), + authorization: installed.authorization(), + producer: installed.producer(), + compatibility: installed.binding(), regions: regions(&snapshot), input_path: Some(&input), - libapp_path: None, + libapp: None, requested_backend: RequestedBackend::Fixed(BackendId::Blutter), + limits: Limits::default(), }, ) - .map_err(|err| format!("{err:#}")) + .map_err(|err| err.to_string()) .expect("blutter bridge runs"); assert_eq!(run.resolved_backend, BackendId::Blutter); @@ -713,23 +675,24 @@ asm.mkdir(parents=True, exist_ok=True) .all(|e| e.target_va.is_none())); } -/// Install the real producer with a fake `r2flutter` wired in, plus a dummy -/// target file for it to be pointed at. +/// Publish the real producer with a fake `r2flutter` wired in, plus a dummy +/// target for it to be pointed at. /// /// `classes_json` is what the fake answers for `-jc`. Everything else answers /// the least the backend needs to build a model: one instruction-table entry -/// inside the declared isolate region, no strings, and a `-jp` failure so no -/// pool geometry is claimed. -fn install_with_fake_r2flutter(hash: &str, classes_json: &str) -> (Installed, PathBuf) { - let installed = install(hash); - let adapters = installed - .exec - .parent() - .and_then(Path::parent) - .expect("the installed adapter lives under /adapters/installed") - .to_path_buf(); - - let fake = adapters.join("fake_r2flutter"); +/// inside the declared isolate region, no strings, and a failure for `-jp` so +/// no pool geometry is claimed. +/// +/// The wiring is prepended to the producer source before publication, as the +/// no-backends install does, because the record content-addresses the artifact: +/// editing the published file afterwards would fail the digest gate instead of +/// running. +fn install_with_fake_r2flutter( + identity: &SnapshotIdentity, + classes_json: &str, +) -> (support::Authorized, TempDir, PathBuf) { + let dir = TempDir::new().expect("tempdir"); + let fake = dir.path().join("fake_r2flutter"); fs::write( &fake, format!( @@ -754,31 +717,30 @@ else: set_executable(&fake); // `FLUTTERDEC_R2FLUTTER_CMD` outranks `_BIN` and `PATH` in the producer's - // resolver, so a real r2flutter on the developer's machine cannot win. + // resolver, so a real r2flutter on this machine cannot win the lookup. + const ANCHOR: &str = "from __future__ import annotations\n"; + let source = repo_root().join("adapters/python/adapter_template.py"); + let producer = fs::read_to_string(&source).expect("read producer"); + let (head, rest) = producer + .split_once(ANCHOR) + .expect("the producer imports __future__ annotations"); + let wired = dir.path().join("adapter_template.py"); fs::write( - &installed.exec, - r#"#!/usr/bin/env python3 -from pathlib import Path -import os -import sys -root = Path(__file__).resolve().parents[1] -os.environ["FLUTTERDEC_R2FLUTTER_CMD"] = str(root / "fake_r2flutter") -sys.path.insert(0, str(root / "python")) -import adapter_template -if __name__ == "__main__": - raise SystemExit(adapter_template.entrypoint()) -"#, + &wired, + format!( + "{head}{ANCHOR}import os as _os\n_os.environ[\"FLUTTERDEC_R2FLUTTER_CMD\"] = {:?}\n{rest}", + fake.to_str().expect("utf-8 fake path"), + ), ) - .expect("write adapter exec"); - set_executable(&installed.exec); + .expect("write wired producer"); - let target = adapters.join("libapp.so"); + let target = dir.path().join("libapp.so"); fs::write(&target, b"dummy").expect("write dummy target"); - (installed, target) + (support::Authorized::install(&wired, identity), dir, target) } fn run_r2flutter( - installed: &Installed, + installed: &support::Authorized, identity: &SnapshotIdentity, snapshot: &Snapshot, target: &Path, @@ -787,15 +749,17 @@ fn run_r2flutter( &installed.exec, &AdapterInput { identity, - producer: producer(&installed.exec), - compatibility: compatibility(), + authorization: installed.authorization(), + producer: installed.producer(), + compatibility: installed.binding(), regions: regions(snapshot), input_path: None, - libapp_path: Some(target), + libapp: Some(LibappSource::File(target)), requested_backend: RequestedBackend::Fixed(BackendId::R2Flutter), + limits: Limits::default(), }, ) - .map_err(|err| format!("{err:#}")) + .map_err(|err| err.to_string()) } #[test] @@ -803,15 +767,15 @@ fn a_structured_superclass_links_when_r2flutter_resolved_it_and_stays_null_when_ // r2flutter emits `super` as an object. It fills in `name` itself when the // reference resolves, so `Child` here is a superclass it could not name and // `Widget` is one it could. - let (installed, target) = install_with_fake_r2flutter( - "deadbeefdeadbeefdeadbeefdeadbeef", + let identity = support::identity(); + let (installed, _dir, target) = install_with_fake_r2flutter( + &identity, r#"[ {"name": "Child", "super": {"type_ref": 35836}}, {"name": "Widget", "super": {"ref": 12, "name": "StatefulWidget"}}, {"name": "StatefulWidget"} ]"#, ); - let identity = support::identity(); let run = run_r2flutter(&installed, &identity, &empty_snapshot(), &target) .expect("the r2flutter backend runs against the fake"); @@ -845,14 +809,14 @@ fn a_structured_superclass_links_when_r2flutter_resolved_it_and_stays_null_when_ fn class_relationships_stay_unavailable_when_no_superclass_resolves() { // Both shapes r2flutter emits for a superclass it could not name. Neither // is an edge, so the domain has nothing partial about it. - let (installed, target) = install_with_fake_r2flutter( - "deadbeefdeadbeefdeadbeefdeadbeef", + let identity = support::identity(); + let (installed, _dir, target) = install_with_fake_r2flutter( + &identity, r#"[ {"name": "Child", "super": {"type_ref": 35836}}, {"name": "Widget", "super": {"ref": 987}} ]"#, ); - let identity = support::identity(); let run = run_r2flutter(&installed, &identity, &empty_snapshot(), &target) .expect("the r2flutter backend runs against the fake"); @@ -870,3 +834,45 @@ fn class_relationships_stay_unavailable_when_no_superclass_resolves() { CapabilityLevel::Unavailable ); } + +/// The registry content-addresses the producer that ships with it, so editing +/// one and not the other publishes a record no install can satisfy. +/// +/// Without this the drift only surfaces in the release layout smoke, which is +/// the last step of the local check and does not run at all in a plain +/// `cargo test`. +#[test] +fn the_registry_declares_the_digest_of_the_producer_that_ships_with_it() { + let root = repo_root(); + let bytes = fs::read(root.join("adapters/python/adapter_template.py")).expect("read producer"); + let expected = flutterdec_adapter::primitives::Sha256Digest::of(&bytes); + + let registry = flutterdec_adapter::registry::CompatibilityRegistry::load( + &root.join("adapters/registry.json"), + ) + .expect("the checked-in registry is valid"); + + let mut variants = 0; + for record in ®istry.records { + for variant in &record.artifact.variants { + variants += 1; + assert_eq!( + variant.sha256, + expected.as_str(), + "record {} variant {}/{} declares a digest the checked-in producer does not have", + record.snapshot_hash, + variant.host_os, + variant.host_arch + ); + assert_eq!( + variant.size, + bytes.len() as u64, + "record {} variant {}/{} declares the wrong producer size", + record.snapshot_hash, + variant.host_os, + variant.host_arch + ); + } + } + assert!(variants > 0, "the checked-in registry declares no artifact"); +} diff --git a/crates/flutterdec-adapter/tests/support/mod.rs b/crates/flutterdec-adapter/tests/support/mod.rs index 51eadc02..aad0bec5 100644 --- a/crates/flutterdec-adapter/tests/support/mod.rs +++ b/crates/flutterdec-adapter/tests/support/mod.rs @@ -90,7 +90,7 @@ pub fn host() -> HostSelectedContext { HostSelectedContext { identity: identity(), producer: producer(), - compatibility: compatibility(), + compatibility: Some(compatibility()), regions: regions(), } } @@ -108,7 +108,7 @@ pub fn maximal_model() -> ProgramModel { identity: identity(), regions: regions(), }, - compatibility: compatibility(), + compatibility: Some(compatibility()), capabilities: Capabilities { libraries: CapabilityLevel::Complete, classes: CapabilityLevel::Complete, @@ -251,3 +251,251 @@ pub fn unavailable_model() -> ProgramModel { model.extensions.clear(); model } + +/// Record `record` as installed for this host, as `adapter install` leaves the +/// store ledger. +/// +/// Publishing bytes into the store is not installing them: the host authorizes +/// against the ledger, so a rig that only copies a file describes a state the +/// product never produces. Written directly rather than through +/// `store::install` because these fixtures include artifact names the real +/// installer cannot stage — a 250-character name leaves no room for its +/// temporary suffix — and the sealing cases need exactly those. That the +/// installer writes the entry the host accepts is proven where a full layout +/// exists: `flutterdec-core`'s pipeline gate tests and the CLI store suite both +/// install for real. +/// +/// Overwrites whatever the ledger held, so a case can hand in a *different* +/// record and get a store where the artifact is present and this record was +/// never installed. +pub fn write_ledger( + store_root: &std::path::Path, + record: &flutterdec_loader::registry::CompatibilityRecord, +) { + use flutterdec_adapter::store::{InstalledAdapter, StoreState, STATE_FILE, STORE_VERSION}; + + let variant = record + .artifact + .variants + .iter() + .find(|variant| { + variant.host_os == std::env::consts::OS && variant.host_arch == std::env::consts::ARCH + }) + .expect("the fixture record declares a variant for this host"); + let state = StoreState { + version: STORE_VERSION, + adapters: vec![InstalledAdapter { + snapshot_hash: record.snapshot_hash.clone(), + target_arch: record.target_arch.as_str().to_string(), + host_os: variant.host_os.clone(), + host_arch: variant.host_arch.clone(), + artifact_id: record.artifact.id.clone(), + artifact_path: variant.path.clone(), + size: variant.size, + sha256: variant.sha256.clone(), + parser_family_id: record.parser_family.id.clone(), + profile_id: record.profile.id.clone(), + profile_sha256: record.profile.sha256.clone(), + compatibility_record_sha256: record.sha256().expect("record digest"), + protocol_major: record.protocol_major, + model_major: record.model_major, + source: "fixture".to_string(), + }], + }; + let mut bytes = serde_json::to_vec_pretty(&state).expect("serialize store state"); + bytes.push(b'\n'); + std::fs::write(store_root.join(STATE_FILE), bytes).expect("write store ledger"); +} + +/// A registry-authorized adapter install. +/// +/// Everything the host checks before it spawns lives here and is consistent by +/// construction: the executable is published where the record's host variant +/// says, the profile the record pins is on disk with the digest the record +/// declares, and the compatibility binding carries the record's own digest. A +/// negative case is then one field changed, which is what makes "the host +/// refused because of *this*" a claim a test can make. +pub struct Authorized { + _dir: tempfile::TempDir, + pub exec: std::path::PathBuf, + pub store_root: std::path::PathBuf, + pub profile_path: std::path::PathBuf, + pub record: flutterdec_loader::registry::CompatibilityRecord, +} + +pub const PROFILE_FILE: &str = "dart-profiles.json"; +pub const PROFILE_BODY: &[u8] = br#"{"profiles":{}}"#; +pub const PARSER_FAMILY: &str = "flutterdec-local-python"; + +impl Authorized { + /// Publish `source` as the adapter this identity's record authorizes. + pub fn install(source: &std::path::Path, identity: &SnapshotIdentity) -> Self { + Self::install_named(source, identity, None) + } + + /// Publish under an arbitrary file name, so a deliberately misleading name + /// can be shown to change nothing. + pub fn install_named( + source: &std::path::Path, + identity: &SnapshotIdentity, + file_name: Option<&str>, + ) -> Self { + use flutterdec_loader::registry::*; + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let key = identity + .exact_selection_key() + .expect("a fixture identity must clear the gate"); + let dir = tempfile::TempDir::new().expect("tempdir"); + let store_root = dir.path().join("store"); + let data_root = dir.path().join("data"); + fs::create_dir_all(store_root.join("artifacts")).expect("mkdir store"); + fs::create_dir_all(&data_root).expect("mkdir data"); + + // Named after the parser family, not after a snapshot hash: one + // producer serves every record that names it, and a hash-derived file + // name is the retired one-wrapper-per-snapshot layout. + let name = file_name.unwrap_or("flutterdec-local-python").to_string(); + let relative = format!("artifacts/{name}"); + let exec = store_root.join(&relative); + fs::copy(source, &exec).expect("publish adapter artifact"); + fs::set_permissions(&exec, fs::Permissions::from_mode(0o755)).expect("chmod"); + let bytes = fs::read(&exec).expect("read adapter artifact"); + + let profile_path = data_root.join(PROFILE_FILE); + fs::write(&profile_path, PROFILE_BODY).expect("write profile"); + + let record = CompatibilityRecord { + snapshot_hash: key.hash.clone(), + snapshot_kind: SnapshotKind::FullAot, + target_arch: key.target_arch.clone(), + feature_fingerprint: canonical_feature_fingerprint(&key.features), + features: key.features.clone(), + known_features: Vec::new(), + forbidden_features: Vec::new(), + sdk_aliases: Vec::new(), + parser_family: ParserFamilyReference { + id: PARSER_FAMILY.to_string(), + version: Some("fixture".to_string()), + sha256: None, + }, + profile: ProfileReference { + id: "fixture-profile".to_string(), + path: PROFILE_FILE.to_string(), + sha256: hex_digest(PROFILE_BODY), + }, + artifact: ArtifactReference { + id: "fixture-artifact".to_string(), + variants: vec![HostArtifactVariant { + host_os: std::env::consts::OS.to_string(), + host_arch: std::env::consts::ARCH.to_string(), + path: relative, + size: bytes.len() as u64, + sha256: hex_digest(&bytes), + provenance: "fixture".to_string(), + }], + }, + evidence: CompatibilityEvidence { + source: "fixture".to_string(), + provenance: "test".to_string(), + references: Vec::new(), + }, + trust_tier: TrustTier::Verified, + protocol_major: 1, + model_major: 4, + }; + record.validate().expect("the fixture record is valid"); + write_ledger(&store_root, &record); + + Self { + _dir: dir, + exec, + store_root, + profile_path, + record, + } + } + + /// The authorization for a record, so a negative case can hand in a tweaked + /// copy and keep the variant pointing into it. + pub fn authorization_for<'a>( + &'a self, + record: &'a flutterdec_loader::registry::CompatibilityRecord, + ) -> flutterdec_adapter::HostAuthorization<'a> { + flutterdec_adapter::HostAuthorization { + record, + variant: record + .artifact + .variants + .first() + .expect("the fixture record declares one variant"), + store_root: &self.store_root, + profile_path: &self.profile_path, + } + } + + pub fn authorization(&self) -> flutterdec_adapter::HostAuthorization<'_> { + self.authorization_for(&self.record) + } + + /// The producer record that follows from a record and the published bytes. + pub fn producer_for( + &self, + record: &flutterdec_loader::registry::CompatibilityRecord, + ) -> Producer { + Producer { + id: record.parser_family.id.clone(), + version: record + .parser_family + .version + .clone() + .unwrap_or_else(|| "unknown".to_string()), + // The bytes on disk when they are readable, so a case that rewrites + // the artifact still hands in a producer record that matches it. + // When the published path is not a readable file at all, fall back + // to what the record declares: the file-type gate is what such a + // case is about, and it runs long before the producer record. + // The file type is checked before the read because opening a FIFO + // blocks until someone writes to it, and one of the gate cases + // publishes exactly that. + artifact_sha256: match std::fs::symlink_metadata(&self.exec) + .ok() + .filter(|meta| meta.is_file()) + .and_then(|_| std::fs::read(&self.exec).ok()) + { + Some(bytes) => Sha256Digest::of(&bytes), + None => Sha256Digest::parse(&record.artifact.variants[0].sha256) + .expect("the record declares a hex digest"), + }, + trust: ProducerTrust::Registered, + } + } + + pub fn producer(&self) -> Producer { + self.producer_for(&self.record) + } + + /// The compatibility binding that follows from a record. + pub fn binding_for( + &self, + record: &flutterdec_loader::registry::CompatibilityRecord, + ) -> CompatibilityBinding { + CompatibilityBinding { + record_sha256: Sha256Digest::parse(&record.sha256().expect("record digest")) + .expect("record digest is hex"), + parser_family_id: record.parser_family.id.clone(), + profile_id: record.profile.id.clone(), + profile_sha256: Sha256Digest::parse(&record.profile.sha256) + .expect("profile digest is hex"), + } + } + + pub fn binding(&self) -> CompatibilityBinding { + self.binding_for(&self.record) + } +} + +pub fn hex_digest(bytes: &[u8]) -> String { + Sha256Digest::of(bytes).as_str().to_string() +} diff --git a/crates/flutterdec-cli/Cargo.toml b/crates/flutterdec-cli/Cargo.toml index 826cb5a0..ebb96131 100644 --- a/crates/flutterdec-cli/Cargo.toml +++ b/crates/flutterdec-cli/Cargo.toml @@ -14,3 +14,10 @@ clap.workspace = true serde_json.workspace = true flutterdec-core = { path = "../flutterdec-core" } flutterdec-adapter = { path = "../flutterdec-adapter" } +flutterdec-loader = { path = "../flutterdec-loader" } + +[dev-dependencies] +libc.workspace = true +tempfile.workspace = true +serde_json.workspace = true +sha2.workspace = true diff --git a/crates/flutterdec-cli/src/main.rs b/crates/flutterdec-cli/src/main.rs index 1f663df7..096b112a 100644 --- a/crates/flutterdec-cli/src/main.rs +++ b/crates/flutterdec-cli/src/main.rs @@ -1,13 +1,16 @@ use anyhow::{bail, Context, Result}; use clap::{Args, Parser, Subcommand, ValueEnum}; -use flutterdec_adapter::install_adapter; +use flutterdec_adapter::store::{self, EntryState}; use flutterdec_core::{ - available_adapters, run_decompile, run_diff, run_engine_fingerprint, run_info, run_symbol_map, - AdapterBackend, DecompileAnalysisProfile, DecompileEngineOptionOverrides, + available_adapters, error_category, run_decompile, run_diff, run_engine_fingerprint, run_info, + run_symbol_map, AdapterBackend, DecompileAnalysisProfile, DecompileEngineOptionOverrides, DecompileEngineOptions, DecompileOptions, DiffOptions, EngineFingerprintOptions, FunctionScope, - FunctionTarget, SymbolMapOptions, + FunctionTarget, ProviderReport, SymbolMapOptions, }; -use std::path::{Path, PathBuf}; +use flutterdec_loader::layout::Layout; +use flutterdec_loader::registry::CompatibilityRegistry; +use std::path::PathBuf; +use std::process::ExitCode; #[derive(Parser, Debug)] #[command(name = "flutterdec")] @@ -65,6 +68,9 @@ struct InfoCmd { /// Which snapshot adapter backend to use #[arg(long, value_enum, default_value_t = AdapterBackendArg::Auto, value_name = "BACKEND")] adapter_backend: AdapterBackendArg, + /// Wall-clock deadline for one adapter invocation, in seconds + #[arg(long, value_name = "SECONDS")] + adapter_timeout: Option, } #[derive(Args, Debug)] @@ -183,6 +189,9 @@ FLUTTERDEC_R2FLUTTER_CMD for r2flutter, and FLUTTERDEC_BLUTTER_CMD or FLUTTERDEC_BLUTTER_PY for the Blutter bridge." )] adapter_backend: AdapterBackendArg, + /// Wall-clock deadline for one adapter invocation, in seconds + #[arg(long, value_name = "SECONDS")] + adapter_timeout: Option, /// Fail if the adapter and loader disagree on the snapshot hash #[arg(long, help_heading = "Analysis engine")] require_snapshot_hash_match: bool, @@ -316,6 +325,9 @@ struct DiffCmd { /// Which snapshot adapter backend to use #[arg(long, value_enum, default_value_t = AdapterBackendArg::Auto, value_name = "BACKEND")] adapter_backend: AdapterBackendArg, + /// Wall-clock deadline for one adapter invocation, in seconds + #[arg(long, value_name = "SECONDS")] + adapter_timeout: Option, /// Fail if either side has an adapter/loader snapshot hash mismatch #[arg(long)] require_snapshot_hash_match: bool, @@ -436,10 +448,10 @@ struct MapSymbolsCmd { #[derive(Subcommand, Debug)] enum AdapterSubcommand { - /// Build and install the adapter for a given Dart snapshot hash + /// Install the adapter a compatibility record authorizes for a snapshot hash Install(AdapterInstallCmd), - /// List known adapters and whether each is installed - List, + /// Report the verified state of every authorized adapter + List(AdapterListCmd), } #[derive(Args, Debug)] @@ -447,6 +459,23 @@ struct AdapterInstallCmd { /// Dart snapshot hash, as reported by `flutterdec info` #[arg(long = "dart-hash", value_name = "HASH")] dart_hash: String, + /// Target architecture, when one hash has records for more than one + #[arg(long = "target-arch", value_name = "ARCH")] + target_arch: Option, + /// Artifact to publish instead of the packaged producer. Must match the + /// digest and size the compatibility record declares. + #[arg(long = "from", value_name = "PATH")] + from: Option, + /// Print the installation record as JSON on stdout + #[arg(long)] + json: bool, +} + +#[derive(Args, Debug)] +struct AdapterListCmd { + /// Print the store report as JSON on stdout + #[arg(long)] + json: bool, } #[derive(Args, Debug)] @@ -455,39 +484,50 @@ struct AdapterCmd { subcommand: AdapterSubcommand, } -fn find_repo_root(start: &Path) -> PathBuf { - let mut p = start.to_path_buf(); - loop { - let marker1 = p.join("Cargo.toml"); - let marker2 = p.join("adapters/manifest.json"); - if marker1.exists() && marker2.exists() { - return p; - } - if !p.pop() { - return start.to_path_buf(); +/// Exit status for a store whose content is broken rather than merely absent. +const STORE_STATE_FAILURE: u8 = 2; + +fn main() -> ExitCode { + match run() { + Ok(code) => code, + // Every failure carries a stable category alongside the message. The + // message is for a human and may be reworded; the category is what a + // script matches on to tell a timeout from a corrupt store. + Err(err) if err.downcast_ref::().is_some() => ExitCode::FAILURE, + Err(err) => { + eprintln!("error: {err:#}"); + eprintln!("error category: {}", error_category(&err)); + ExitCode::FAILURE } } } -fn main() -> Result<()> { +fn run() -> Result { let cli = Cli::parse(); - let cwd = std::env::current_dir().context("resolve current dir")?; - let repo_root = find_repo_root(&cwd); + // Resolved once, from the executable and the environment. Nothing below + // this line may consult the current directory for a repository root: that + // is what made a packaged binary behave differently per working directory. + let layout = Layout::resolve().context("resolve flutterdec data and store locations")?; match cli.command { - Command::Info(cmd) => handle_info(&repo_root, cmd)?, - Command::Decompile(cmd) => handle_decompile(&repo_root, cmd)?, - Command::Diff(cmd) => handle_diff(&repo_root, cmd)?, + Command::Info(cmd) => handle_info(&layout, cmd)?, + Command::Decompile(cmd) => handle_decompile(&layout, cmd)?, + Command::Diff(cmd) => handle_diff(&layout, cmd)?, Command::EngineFingerprint(cmd) => handle_engine_fingerprint(cmd)?, - Command::MapSymbols(cmd) => handle_map_symbols(&repo_root, cmd)?, - Command::Adapter(cmd) => handle_adapter(&repo_root, cmd)?, + Command::MapSymbols(cmd) => handle_map_symbols(&layout, cmd)?, + Command::Adapter(cmd) => return handle_adapter(&layout, cmd), } - Ok(()) + Ok(ExitCode::SUCCESS) } -fn handle_info(repo_root: &Path, cmd: InfoCmd) -> Result<()> { - let out = run_info(repo_root, &cmd.input, cmd.adapter_backend.to_core())?; +fn handle_info(layout: &Layout, cmd: InfoCmd) -> Result<()> { + let out = run_info( + layout, + &cmd.input, + cmd.adapter_backend.to_core(), + cmd.adapter_timeout, + )?; if cmd.json { println!("{}", serde_json::to_string_pretty(&out)?); } else { @@ -495,8 +535,12 @@ fn handle_info(repo_root: &Path, cmd: InfoCmd) -> Result<()> { println!("libapp: {}", out.libapp_path); println!("arch: {}", out.arch); println!("snapshot hash: {}", out.snapshot_hash); - if let Some(version) = out.dart_version.as_deref() { - println!("dart version: {}", version); + if let Some(aliases) = out + .dart_aliases + .as_ref() + .filter(|aliases| !aliases.is_empty()) + { + println!("dart aliases: {}", serde_json::to_string(aliases)?); } if let Some(tag_style) = out.dart_tag_style.as_deref() { println!("dart tag style: {}", tag_style); @@ -526,8 +570,8 @@ fn handle_info(repo_root: &Path, cmd: InfoCmd) -> Result<()> { if let Some(digest) = out.compatibility_record_sha256.as_deref() { println!("compatibility record: {}", digest); } - if let Some(present) = out.manifest_entry_present { - println!("manifest entry present: {}", present); + if let Some(present) = out.registry_record_present { + println!("registry record present: {}", present); } if let Some(exact) = out.snapshot_identity_is_exact { println!("snapshot identity header-derived: {}", exact); @@ -572,30 +616,107 @@ fn handle_info(repo_root: &Path, cmd: InfoCmd) -> Result<()> { } } } + if let Some(provider) = out.provider.as_ref() { + print_provider(provider); + } + if let Some(error) = out.adapter_error.as_deref() { + eprintln!("error: {}", error); + } + } + // An `info` whose adapter ran and failed reports the failure and exits + // nonzero. Printing the surrounding facts and exiting 0 would make a broken + // adapter look like an empty snapshot. + if let Some(category) = out.adapter_error_category.as_deref() { + eprintln!("error category: {}", category); + return Err(AlreadyReported.into()); } Ok(()) } -fn handle_decompile(repo_root: &Path, cmd: DecompileCmd) -> Result<()> { +/// The provider block, in the same order every surface reports it. +fn print_provider(provider: &ProviderReport) { + println!("provider:"); + println!(" adapter executed: {}", provider.adapter_executed); + println!( + " host: {}/{} target: {}", + provider.host_os, provider.host_arch, provider.target_arch + ); + if let Some(reason) = provider.core_fallback_reason.as_deref() { + println!(" core fallback reason: {}", reason); + if let Some(detail) = provider.core_fallback_detail.as_deref() { + println!(" core fallback detail: {}", detail); + } + if let Some(effect) = provider.core_fallback_effect.as_deref() { + println!(" core fallback effect: {}", effect); + } + } + if let Some(path) = provider.adapter_exec_path.as_deref() { + println!(" adapter executable: {}", path); + } + println!( + " producer: {} {} ({})", + provider.producer_id, provider.producer_version, provider.producer_trust + ); + println!(" producer artifact: {}", provider.producer_artifact_sha256); + if let Some(id) = provider.parser_family_id.as_deref() { + println!(" parser family: {}", id); + } + if let Some(id) = provider.profile_id.as_deref() { + println!(" profile: {}", id); + } + if let Some(digest) = provider.profile_sha256.as_deref() { + println!(" profile digest: {}", digest); + } + if let Some(id) = provider.artifact_id.as_deref() { + println!(" artifact: {}", id); + } + if let Some(digest) = provider.artifact_sha256.as_deref() { + println!(" artifact digest: {}", digest); + } + if let Some(containment) = provider.containment.as_ref() { + if let Ok(rendered) = serde_json::to_string(containment) { + println!(" containment: {}", rendered); + } + } +} + +/// A failure whose message the caller already printed. +/// +/// `main` prints `error: {err:#}` for anything it gets back, and `info` has to +/// print its report before it can report the failure inside it. Without this the +/// same sentence appears twice. +#[derive(Debug)] +struct AlreadyReported; + +impl std::fmt::Display for AlreadyReported { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("reported above") + } +} + +impl std::error::Error for AlreadyReported {} + +fn handle_decompile(layout: &Layout, cmd: DecompileCmd) -> Result<()> { let input = cmd.input.clone(); let opt = build_decompile_options(cmd)?; - let quality = run_decompile(repo_root, &input, &opt)?; + let quality = run_decompile(layout, &input, &opt)?; println!("{}", serde_json::to_string_pretty(&quality)?); Ok(()) } -fn handle_diff(repo_root: &Path, cmd: DiffCmd) -> Result<()> { +fn handle_diff(layout: &Layout, cmd: DiffCmd) -> Result<()> { let old_input = cmd.old_input.clone(); let new_input = cmd.new_input.clone(); let json = cmd.json; let opt = DiffOptions { out_dir: cmd.out_dir, adapter_backend: cmd.adapter_backend.to_core(), + adapter_timeout_seconds: cmd.adapter_timeout, function_scope: cmd.function_scope.to_core(), app_packages: cmd.app_packages, require_snapshot_hash_match: cmd.require_snapshot_hash_match, }; - let report = run_diff(repo_root, &old_input, &new_input, &opt)?; + let report = run_diff(layout, &old_input, &new_input, &opt)?; if json { println!("{}", serde_json::to_string_pretty(&report)?); } else { @@ -612,8 +733,9 @@ fn handle_diff(repo_root: &Path, cmd: DiffCmd) -> Result<()> { report.require_snapshot_hash_match ); println!( - "dart version: old={} new={}", - report.old_dart_version, report.new_dart_version + "dart aliases: old={} new={}", + serde_json::to_string(&report.old_dart_aliases)?, + serde_json::to_string(&report.new_dart_aliases)? ); println!( "functions: old={} new={} common={} added={} removed={}", @@ -623,6 +745,18 @@ fn handle_diff(repo_root: &Path, cmd: DiffCmd) -> Result<()> { report.added_function_count, report.removed_function_count ); + // Counted separately because they are not in the sets above: a + // heuristic candidate with no name, owner or library has no identity + // that survives a rebuild. + println!( + "uncomparable functions: old={} new={}", + report.old_uncomparable_function_count, report.new_uncomparable_function_count + ); + println!("provider mismatch: {}", report.provider_mismatch); + println!("old side:"); + print_provider(&report.old_provider); + println!("new side:"); + print_provider(&report.new_provider); if !report.added_packages_top.is_empty() { println!("top added packages:"); for item in report.added_packages_top.iter().take(5) { @@ -673,6 +807,7 @@ fn build_decompile_options(cmd: DecompileCmd) -> Result { function_scope: cmd.function_scope.to_core(), app_packages: cmd.app_packages, adapter_backend: cmd.adapter_backend.to_core(), + adapter_timeout_seconds: cmd.adapter_timeout, require_snapshot_hash_match: cmd.require_snapshot_hash_match, analysis_profile: profile, engine_options, @@ -786,13 +921,15 @@ fn handle_engine_fingerprint(cmd: EngineFingerprintCmd) -> Result<()> { Ok(()) } -fn handle_map_symbols(repo_root: &Path, cmd: MapSymbolsCmd) -> Result<()> { +fn handle_map_symbols(layout: &Layout, cmd: MapSymbolsCmd) -> Result<()> { let opt = SymbolMapOptions { out_dir: cmd.out_dir, include_branches: cmd.include_branches, nearest_max_distance: cmd.nearest_max_distance, require_exec_match: cmd.require_exec_match, - local_cache_root: cmd.register_local_cache.then(|| repo_root.join("symbols")), + local_cache_root: cmd + .register_local_cache + .then(|| layout.symbols_dir().to_path_buf()), }; let report = run_symbol_map(&cmd.stripped_path, &cmd.unstripped_path, &opt)?; if cmd.json { @@ -841,27 +978,99 @@ fn handle_map_symbols(repo_root: &Path, cmd: MapSymbolsCmd) -> Result<()> { Ok(()) } -fn handle_adapter(repo_root: &Path, cmd: AdapterCmd) -> Result<()> { +fn handle_adapter(layout: &Layout, cmd: AdapterCmd) -> Result { match cmd.subcommand { AdapterSubcommand::Install(cmd) => { - let path = install_adapter(repo_root, &cmd.dart_hash)?; - println!("installed adapter: {}", path.display()); + // `anyhow::Error::new(..).context(..)` rather than a formatted + // message: the rendering is the same and the `RegistryError` stays + // downcastable, so the failure still reports its own category. + let registry = CompatibilityRegistry::load(&layout.registry_path()) + .map_err(|err| anyhow::Error::new(err).context("read compatibility registry"))?; + let installation = store::install( + layout, + ®istry, + &cmd.dart_hash, + cmd.target_arch.as_deref(), + cmd.from.as_deref(), + ) + .map_err(anyhow::Error::new)?; + if cmd.json { + println!("{}", serde_json::to_string_pretty(&installation)?); + } else { + let record = &installation.record; + println!( + "result: {}", + if installation.idempotent { + "already-installed" + } else { + "installed" + } + ); + println!("store: {}", installation.store_dir.display()); + println!("artifact: {}", installation.artifact_path.display()); + println!("snapshot hash: {}", record.snapshot_hash); + println!("target: {}", record.target_arch); + println!("host: {}/{}", record.host_os, record.host_arch); + println!("artifact digest: {} ({} bytes)", record.sha256, record.size); + println!("artifact id: {}", record.artifact_id); + println!("artifact source: {}", record.source); + println!("profile: {} {}", record.profile_id, record.profile_sha256); + println!("profile path: {}", installation.profile_path.display()); + println!( + "compatibility record: {}", + record.compatibility_record_sha256 + ); + println!( + "protocol/model majors: {}/{}", + record.protocol_major, record.model_major + ); + } + Ok(ExitCode::SUCCESS) } - AdapterSubcommand::List => { - let rows = available_adapters(repo_root)?; - if rows.is_empty() { - println!("no manifest entries"); + AdapterSubcommand::List(cmd) => { + let rows = available_adapters(layout)?; + if cmd.json { + println!("{}", serde_json::to_string_pretty(&rows)?); + } else if rows.is_empty() { + println!("no compatibility records"); } else { - for (hash, version, adapter, installed) in rows { - println!( - "hash={} version={} adapter={} installed={}", - hash, version, adapter, installed + for row in &rows { + print!( + "hash={} state={} adapter={} target={} host={}/{}", + row.snapshot_hash, + row.state, + row.artifact_id, + row.target_arch, + row.host_os, + row.host_arch ); + if let Some(path) = row.artifact_path.as_deref() { + print!(" artifact={}", path); + } + if let Some(digest) = row.expected_sha256.as_deref() { + print!(" sha256={}", digest); + } + if let Some(detail) = row.detail.as_deref() { + print!(" detail={:?}", detail); + } + println!(); } } + // A store that claims installs it cannot back is a failure, not a + // report: exiting 0 here is how "installed" came to mean "a file + // with the right name exists". + let broken = rows.iter().filter(|row| row.state.is_failure()).count(); + if broken > 0 { + eprintln!( + "error: {broken} adapter store entries are {} or {}", + EntryState::Missing, + EntryState::Corrupt + ); + return Ok(ExitCode::from(STORE_STATE_FAILURE)); + } + Ok(ExitCode::SUCCESS) } } - Ok(()) } fn resolve_toggle(with: bool, without: bool, name: &str) -> Result> { diff --git a/crates/flutterdec-cli/tests/adapter_store.rs b/crates/flutterdec-cli/tests/adapter_store.rs new file mode 100644 index 00000000..c0d9d6cf --- /dev/null +++ b/crates/flutterdec-cli/tests/adapter_store.rs @@ -0,0 +1,1264 @@ +//! The adapter store, driven through the real CLI. +//! +//! Install, listing, rollback, containment and store containment, each proven +//! through the packaged binary rather than through the library. The rig itself +//! lives in `support`, which every CLI integration test shares. + +mod support; + +use serde_json::Value; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use support::*; + +#[test] +fn installs_and_lists_from_a_packaged_prefix_with_no_checkout_in_sight() { + let prefix = Prefix::new(); + let checkout = checkout_root(); + let checkout_before = tree_digests(&checkout.join("adapters")); + let data_before = tree_digests(&prefix.share()); + + let install = prefix.install(); + assert_eq!(code(&install), 0, "install failed: {}", stderr(&install)); + let report = json(&install); + assert_eq!(report["idempotent"], Value::Bool(false)); + assert_eq!( + report["store_dir"].as_str().map(PathBuf::from), + Some(prefix.store()), + "the store is not the documented default under HOME" + ); + assert_eq!( + report["artifact_path"].as_str().map(PathBuf::from), + Some(prefix.artifact()) + ); + assert_eq!(report["record"]["snapshot_hash"], text(HASH)); + assert_eq!(report["record"]["target_arch"], text("arm64")); + assert_eq!(report["record"]["host_os"], text(std::env::consts::OS)); + assert_eq!(report["record"]["protocol_major"], Value::from(1)); + assert_eq!(report["record"]["model_major"], Value::from(4)); + assert_eq!(report["record"]["profile_id"], text("fixture-profile")); + assert_eq!( + report["profile_path"].as_str().map(PathBuf::from), + Some(prefix.share().join("data/fixture-profile.json")), + "the profile was not resolved inside the package prefix" + ); + + let artifact = prefix.artifact(); + let bytes = fs::read(&artifact).expect("read installed artifact"); + assert_eq!( + report["record"]["sha256"], + text(&digest(&bytes)), + "the reported digest is not the digest of the installed bytes" + ); + assert_eq!(report["record"]["size"], Value::from(bytes.len())); + let mode = fs::metadata(&artifact) + .expect("metadata") + .permissions() + .mode(); + assert_eq!( + mode & 0o777, + 0o755, + "the installed artifact is not executable" + ); + + let list = prefix.list(); + assert_eq!(code(&list), 0, "list failed: {}", stderr(&list)); + let rows = json(&list); + assert_eq!(rows.as_array().map(Vec::len), Some(1)); + assert_eq!(rows[0]["state"], text("verified")); + assert_eq!(rows[0]["snapshot_hash"], text(HASH)); + assert_eq!(rows[0]["detail"], Value::Null); + + // Read-only package data is never mutated, and the source checkout is + // neither required nor written: the fixture profile id does not exist in the + // repository registry, so a run that reached the checkout could not have + // produced this report. + assert_eq!(data_before, tree_digests(&prefix.share())); + assert_eq!(checkout_before, tree_digests(&checkout.join("adapters"))); + assert!( + !checkout.join("adapters/installed").exists(), + "the install wrote into the source checkout" + ); + assert!( + !checkout.join("adapters/manifest.json").exists(), + "the source checkout still carries an adapter manifest" + ); +} + +#[test] +fn a_repeated_install_changes_nothing() { + let prefix = Prefix::new(); + assert_eq!(code(&prefix.install()), 0); + let state = prefix.store().join("store.json"); + let before = fs::read(&state).expect("read state"); + let files_before = store_files(&prefix.store()); + + let second = prefix.install(); + assert_eq!(code(&second), 0, "{}", stderr(&second)); + assert_eq!( + json(&second)["idempotent"], + Value::Bool(true), + "a repeated install did not report an idempotent result" + ); + assert_eq!(before, fs::read(&state).expect("read state")); + assert_eq!(files_before, store_files(&prefix.store())); +} + +/// Eight real processes racing on one store. The lock is what makes exactly one +/// of them the installer; without it two readers can both decide the store is +/// empty and both write it. +#[test] +fn concurrent_installs_produce_exactly_one_install() { + let prefix = Prefix::new(); + let mut children = Vec::new(); + for _ in 0..8 { + loop { + let spawned = prefix + .cmd() + .args(["adapter", "install", "--dart-hash", HASH, "--json"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn(); + match spawned { + Ok(child) => { + children.push(child); + break; + } + Err(err) if err.raw_os_error() == Some(26) => { + std::thread::sleep(std::time::Duration::from_millis(20)) + } + Err(err) => panic!("spawn adapter install: {err}"), + } + } + } + let outputs = children + .into_iter() + .map(|child| child.wait_with_output().expect("wait for install")) + .collect::>(); + + let mut installed = 0; + for output in &outputs { + assert_eq!( + code(output), + 0, + "a concurrent install failed: {}", + stderr(output) + ); + if json(output)["idempotent"] == Value::Bool(false) { + installed += 1; + } + } + assert_eq!( + installed, 1, + "{installed} of 8 concurrent runs claimed to be the install" + ); + + let list = prefix.list(); + assert_eq!(code(&list), 0, "{}", stderr(&list)); + let rows = json(&list); + assert_eq!(rows.as_array().map(Vec::len), Some(1)); + assert_eq!(rows[0]["state"], text("verified")); + + let state: Value = + serde_json::from_slice(&fs::read(prefix.store().join("store.json")).expect("read state")) + .expect("parse state"); + assert_eq!( + state["adapters"].as_array().map(Vec::len), + Some(1), + "concurrent installs left more than one record" + ); + assert_eq!( + store_files(&prefix.store()), + prefix.settled_store_files(), + "concurrent installs left temporary files behind" + ); +} + +/// The race above can pass by luck: if the first process finishes before the +/// last one starts, timing serialized the work and the lock proved nothing. So +/// hold the store lock here and require a real install to wait for it. +#[test] +fn an_install_waits_for_the_store_lock() { + let prefix = Prefix::new(); + fs::create_dir_all(prefix.store()).expect("mkdir store"); + let lock_path = prefix.store().join(".lock"); + let lock = fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&lock_path) + .expect("open store lock"); + assert_eq!( + unsafe { libc::flock(std::os::unix::io::AsRawFd::as_raw_fd(&lock), libc::LOCK_EX) }, + 0, + "could not take the store lock" + ); + + let mut child = loop { + match prefix + .cmd() + .args(["adapter", "install", "--dart-hash", HASH, "--json"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(child) => break child, + Err(err) if err.raw_os_error() == Some(26) => { + std::thread::sleep(std::time::Duration::from_millis(20)) + } + Err(err) => panic!("spawn adapter install: {err}"), + } + }; + + // Long enough for an unlocked install to have finished several times over. + for _ in 0..25 { + std::thread::sleep(std::time::Duration::from_millis(80)); + assert!( + child.try_wait().expect("poll install").is_none(), + "the install did not wait for the store lock" + ); + } + assert!( + store_files(&prefix.store()).is_empty(), + "the install published while the store was locked: {:?}", + store_files(&prefix.store()) + ); + + assert_eq!( + unsafe { libc::flock(std::os::unix::io::AsRawFd::as_raw_fd(&lock), libc::LOCK_UN) }, + 0 + ); + let output = child.wait_with_output().expect("wait for install"); + assert_eq!(code(&output), 0, "{}", stderr(&output)); + assert_eq!(json(&output)["idempotent"], Value::Bool(false)); + assert_eq!(store_files(&prefix.store()), prefix.settled_store_files()); +} + +#[test] +fn an_injected_failure_before_any_publish_step_leaves_no_partial_state() { + for step in ["lock", "stage", "publish_artifact", "publish_state"] { + let prefix = Prefix::new(); + let output = prefix.run_with( + &[("FLUTTERDEC_INSTALL_FAIL_BEFORE", step)], + &["adapter", "install", "--dart-hash", HASH], + ); + assert_ne!(code(&output), 0, "the injected failure at {step} succeeded"); + assert!( + stderr(&output).contains(step), + "the error does not name the failed step {step}: {}", + stderr(&output) + ); + assert!( + !prefix.artifact().exists(), + "{step} left a published artifact behind" + ); + assert!( + !prefix.store().join("store.json").exists(), + "{step} left a state file behind" + ); + assert!( + store_files(&prefix.store()).is_empty(), + "{step} left {:?} behind", + store_files(&prefix.store()) + ); + + // The store is still usable afterwards, so the failure did not poison it. + let recovered = prefix.install(); + assert_eq!(code(&recovered), 0, "{}", stderr(&recovered)); + assert_eq!(json(&recovered)["idempotent"], Value::Bool(false)); + } +} + +/// A failure after the artifact is live must put the artifact back rather than +/// merely stop. Checked separately because the interesting case is an existing +/// install being replaced. +#[test] +fn a_failed_state_publish_restores_the_previous_artifact() { + let prefix = Prefix::new(); + assert_eq!(code(&prefix.install()), 0); + let before = fs::read(prefix.artifact()).expect("read artifact"); + + // Make the install non-idempotent so it has to republish, then fail it + // after the artifact rename. + let state_path = prefix.store().join("store.json"); + let mut state: Value = + serde_json::from_slice(&fs::read(&state_path).expect("read state")).expect("parse state"); + state["adapters"][0]["source"] = text("stale"); + fs::write( + &state_path, + serde_json::to_vec_pretty(&state).expect("serialize state"), + ) + .expect("write state"); + let stale = fs::read(&state_path).expect("read state"); + + let output = prefix.run_with( + &[("FLUTTERDEC_INSTALL_FAIL_BEFORE", "publish_state")], + &["adapter", "install", "--dart-hash", HASH], + ); + assert_ne!(code(&output), 0); + assert_eq!( + fs::read(prefix.artifact()).expect("read artifact"), + before, + "the artifact was not restored" + ); + assert_eq!( + fs::read(&state_path).expect("read state"), + stale, + "the state file changed despite the failure" + ); + assert_eq!(store_files(&prefix.store()), prefix.settled_store_files()); +} + +#[test] +fn a_record_path_that_escapes_the_store_is_refused() { + for relative in [ + "../escape", + "artifacts/../../escape", + "/tmp/flutterdec-escape-must-not-exist", + "./escape", + ] { + let prefix = Prefix::with_variant(relative, std::env::consts::OS, std::env::consts::ARCH); + let output = prefix.run(&["adapter", "install", "--dart-hash", HASH]); + assert_ne!(code(&output), 0, "{relative} was installed"); + assert!( + stderr(&output).contains("not a contained relative path"), + "{relative}: {}", + stderr(&output) + ); + assert!( + store_files(&prefix.store()).is_empty(), + "{relative} wrote to the store" + ); + assert!( + !prefix.root().join("escape").exists() + && !Path::new("/tmp/flutterdec-escape-must-not-exist").exists(), + "{relative} wrote outside the store" + ); + } +} + +#[test] +fn a_store_directory_that_is_a_symlink_out_of_the_store_is_refused() { + let prefix = Prefix::new(); + let outside = prefix.root().join("outside"); + fs::create_dir_all(&outside).expect("mkdir outside"); + fs::create_dir_all(prefix.store()).expect("mkdir store"); + std::os::unix::fs::symlink(&outside, prefix.store().join("artifacts")) + .expect("symlink artifacts"); + + let output = prefix.run(&["adapter", "install", "--dart-hash", HASH]); + assert_ne!(code(&output), 0); + assert!( + stderr(&output).contains("escapes the adapter store"), + "{}", + stderr(&output) + ); + assert!( + fs::read_dir(&outside) + .expect("read outside") + .next() + .is_none(), + "the install wrote through the symbolic link" + ); +} + +#[test] +fn an_artifact_source_that_is_not_the_declared_artifact_is_refused() { + let prefix = Prefix::new(); + let wrong = prefix.root().join("wrong.sh"); + fs::write(&wrong, "#!/bin/sh\nexit 0\n").expect("write wrong"); + let a_directory = prefix.root().join("a_directory"); + fs::create_dir_all(&a_directory).expect("mkdir"); + let a_link = prefix.root().join("a_link"); + std::os::unix::fs::symlink(prefix.producer(), &a_link).expect("symlink"); + + for (source, expected) in [ + (&wrong, "does not match the compatibility record"), + (&a_directory, "is not a regular file"), + (&a_link, "is a symbolic link"), + ] { + let output = prefix.run(&[ + "adapter", + "install", + "--dart-hash", + HASH, + "--from", + source.to_str().expect("path"), + ]); + assert_ne!(code(&output), 0, "{} was accepted", source.display()); + assert!( + stderr(&output).contains(expected), + "{}: {}", + source.display(), + stderr(&output) + ); + assert!(store_files(&prefix.store()).is_empty()); + } + + // The declared artifact itself, passed explicitly, is accepted and recorded + // as operator supplied. + let output = prefix.run(&[ + "adapter", + "install", + "--dart-hash", + HASH, + "--from", + prefix.producer().to_str().expect("path"), + "--json", + ]); + assert_eq!(code(&output), 0, "{}", stderr(&output)); + assert!(json(&output)["record"]["source"] + .as_str() + .expect("source") + .starts_with("operator:")); +} + +#[test] +fn a_wrong_host_or_target_is_refused() { + let foreign = Prefix::with_variant(ARTIFACT_RELATIVE, "plan9", "vax"); + let output = foreign.run(&["adapter", "install", "--dart-hash", HASH]); + assert_ne!(code(&output), 0); + assert!( + stderr(&output).contains("no artifact variant for host"), + "{}", + stderr(&output) + ); + assert!(store_files(&foreign.store()).is_empty()); + + let list = foreign.list(); + assert_eq!(code(&list), 0, "{}", stderr(&list)); + assert_eq!(json(&list)[0]["state"], text("incompatible")); + + let prefix = Prefix::new(); + let output = prefix.run(&[ + "adapter", + "install", + "--dart-hash", + HASH, + "--target-arch", + "x64", + ]); + assert_ne!(code(&output), 0); + assert!( + stderr(&output).contains("targets x64"), + "{}", + stderr(&output) + ); + assert!(store_files(&prefix.store()).is_empty()); +} + +#[test] +fn invalid_and_unregistered_input_fails_deterministically() { + let prefix = Prefix::new(); + for (hash, expected) in [ + ("", "is not 32 lowercase hexadecimal characters"), + ("80a49c71", "is not 32 lowercase hexadecimal characters"), + ( + "80A49C7111088100A233B2AE788E1F48", + "is not 32 lowercase hexadecimal characters", + ), + ( + "../../etc/passwd", + "is not 32 lowercase hexadecimal characters", + ), + (OTHER_HASH, "no compatibility record for snapshot hash"), + ] { + let first = prefix.run(&["adapter", "install", "--dart-hash", hash]); + let second = prefix.run(&["adapter", "install", "--dart-hash", hash]); + assert_ne!(code(&first), 0, "{hash:?} was accepted"); + assert_eq!( + code(&first), + code(&second), + "{hash:?} exit code is not stable" + ); + assert_eq!( + stderr(&first), + stderr(&second), + "{hash:?} message is not stable" + ); + assert!( + stderr(&first).contains(expected), + "{hash:?}: {}", + stderr(&first) + ); + assert!(store_files(&prefix.store()).is_empty()); + } +} + +#[test] +fn list_reports_missing_corrupt_and_unavailable_states() { + // A file with the right name and the right bytes, never installed, is not + // an install. This is the case an existence check gets wrong. + let imposter = Prefix::new(); + let artifact = imposter.artifact(); + fs::create_dir_all(artifact.parent().expect("parent")).expect("mkdir"); + fs::copy(imposter.producer(), &artifact).expect("copy imposter"); + let list = imposter.list(); + assert_eq!(code(&list), 0, "{}", stderr(&list)); + assert_eq!(json(&list)[0]["state"], text("unavailable")); + + let prefix = Prefix::new(); + assert_eq!(code(&prefix.install()), 0); + + // Same size, different bytes: a length check alone would call this fine. + let mut bytes = fs::read(prefix.artifact()).expect("read artifact"); + let last = bytes.len() - 2; + bytes[last] = b'9'; + fs::write(prefix.artifact(), &bytes).expect("corrupt artifact"); + let list = prefix.list(); + assert_eq!( + code(&list), + 2, + "a corrupt store exited 0: {}", + stdout(&list) + ); + let rows = json(&list); + assert_eq!(rows[0]["state"], text("corrupt")); + assert!(rows[0]["detail"] + .as_str() + .expect("detail") + .contains("SHA-256")); + + fs::remove_file(prefix.artifact()).expect("remove artifact"); + let list = prefix.list(); + assert_eq!(code(&list), 2, "a missing artifact exited 0"); + assert_eq!(json(&list)[0]["state"], text("missing")); + + // Text output carries the same states, and a broken store is still an error. + let plain = prefix.run(&["adapter", "list"]); + assert_eq!(code(&plain), 2); + assert!( + stdout(&plain).contains("state=missing"), + "{}", + stdout(&plain) + ); + + fs::write(prefix.store().join("store.json"), "{ not json").expect("write state"); + let list = prefix.list(); + assert_ne!(code(&list), 0, "a malformed state file exited 0"); + assert!( + stderr(&list).contains("adapter store state is unusable"), + "{}", + stderr(&list) + ); +} + +#[test] +fn the_store_override_is_explicit_and_deterministic() { + let prefix = Prefix::new(); + let alternate = prefix.root().join("alternate-store"); + let alternate_env = alternate.to_str().expect("path").to_string(); + + let output = prefix.run_with( + &[("FLUTTERDEC_ADAPTER_STORE", &alternate_env)], + &["adapter", "install", "--dart-hash", HASH, "--json"], + ); + assert_eq!(code(&output), 0, "{}", stderr(&output)); + assert_eq!( + json(&output)["store_dir"].as_str().map(PathBuf::from), + Some(alternate.clone()) + ); + assert!(alternate.join(ARTIFACT_RELATIVE).is_file()); + assert!( + !prefix.store().exists(), + "the override did not replace the default store" + ); + + // The default store cannot see the override's install, and the override can. + let list = prefix.list(); + assert_eq!(code(&list), 0, "{}", stderr(&list)); + assert_eq!(json(&list)[0]["state"], text("unavailable")); + + let list = prefix.run_with( + &[("FLUTTERDEC_ADAPTER_STORE", &alternate_env)], + &["adapter", "list", "--json"], + ); + assert_eq!(code(&list), 0, "{}", stderr(&list)); + assert_eq!(json(&list)[0]["state"], text("verified")); + + // XDG_DATA_HOME moves the default store without an explicit override. + let xdg = prefix.root().join("xdg"); + let output = prefix.run_with( + &[("XDG_DATA_HOME", xdg.to_str().expect("path"))], + &["adapter", "install", "--dart-hash", HASH, "--json"], + ); + assert_eq!(code(&output), 0, "{}", stderr(&output)); + assert_eq!( + json(&output)["store_dir"].as_str().map(PathBuf::from), + Some(xdg.join("flutterdec/adapters")) + ); +} + +#[test] +fn package_data_that_cannot_be_written_still_serves_an_install() { + let prefix = Prefix::new(); + let before = tree_digests(&prefix.share()); + let mut modes = Vec::new(); + for dir in [ + prefix.share().join("adapters/python"), + prefix.share().join("adapters"), + prefix.share().join("data"), + prefix.share(), + ] { + let perms = fs::metadata(&dir).expect("metadata").permissions(); + modes.push((dir.clone(), perms.mode())); + let mut readonly = perms; + readonly.set_mode(0o555); + fs::set_permissions(&dir, readonly).expect("chmod read-only"); + } + + let install = prefix.install(); + let list = prefix.list(); + + for (dir, mode) in modes.into_iter().rev() { + let mut perms = fs::metadata(&dir).expect("metadata").permissions(); + perms.set_mode(mode); + fs::set_permissions(&dir, perms).expect("restore mode"); + } + + assert_eq!( + code(&install), + 0, + "a read-only package prefix broke install: {}", + stderr(&install) + ); + assert_eq!(code(&list), 0, "{}", stderr(&list)); + assert_eq!(json(&list)[0]["state"], text("verified")); + assert_eq!(before, tree_digests(&prefix.share())); +} + +#[test] +fn a_prefix_without_package_data_says_so_instead_of_guessing() { + let prefix = Prefix::new(); + fs::remove_file(prefix.share().join("adapters/registry.json")).expect("remove registry"); + let output = prefix.run(&["adapter", "list"]); + assert_ne!(code(&output), 0); + let message = stderr(&output); + assert!( + message.contains("no packaged data directory holds adapters/registry.json"), + "{message}" + ); + assert!( + message.contains("FLUTTERDEC_DATA_DIR"), + "the error does not name the override: {message}" + ); + + // An override that holds no registry fails rather than falling back to a + // directory that happens to have one. + let empty = prefix.root().join("empty"); + fs::create_dir_all(&empty).expect("mkdir empty"); + let output = prefix.run_with( + &[("FLUTTERDEC_DATA_DIR", empty.to_str().expect("path"))], + &["adapter", "list"], + ); + assert_ne!(code(&output), 0); + assert!( + stderr(&output).contains("FLUTTERDEC_DATA_DIR is set to"), + "{}", + stderr(&output) + ); + + // An override that does hold one is used instead of the prefix, which is + // what makes the override deterministic rather than advisory. + let checkout = checkout_root(); + let output = prefix.run_with( + &[("FLUTTERDEC_DATA_DIR", checkout.to_str().expect("path"))], + &["adapter", "list", "--json"], + ); + assert_eq!(code(&output), 0, "{}", stderr(&output)); + assert!( + json(&output) + .as_array() + .expect("rows") + .iter() + .all(|row| row["profile_id"] != text("fixture-profile")), + "the fixture registry was used despite the override" + ); +} + +/// `info` and `decompile` have to resolve the same registry, the same profile, +/// and the same store as `adapter install` and `adapter list`, or the store is +/// only a bookkeeping exercise. The proof is behavioral: the installed artifact +/// in *this* store is the file they execute. +#[test] +fn info_and_decompile_resolve_the_same_registry_profile_and_store() { + let prefix = Prefix::new(); + let libapp = prefix.root().join("libapp.so"); + fs::write(&libapp, synthetic_libapp(HASH, FEATURES)).expect("write libapp"); + let input = libapp.to_str().expect("path").to_string(); + + let before = prefix.run(&["info", &input, "--json"]); + assert_eq!(code(&before), 0, "{}", stderr(&before)); + let report = json(&before); + assert_eq!(report["snapshot_hash"], text(HASH)); + assert_eq!(report["registry_record_present"], Value::Bool(true)); + assert_eq!( + report["adapter_installed"], + Value::Bool(false), + "an empty store reported an installed adapter" + ); + assert!( + !prefix.marker.exists(), + "info ran an adapter that was never installed" + ); + + let install = prefix.install(); + assert_eq!(code(&install), 0, "{}", stderr(&install)); + // The fixture producer exits without writing a result, so this run fails. + // That is the point: the artifact in *this* store is the one that ran, and + // `info` reports the failure rather than printing a report that looks like + // a snapshot with nothing in it. + let after = prefix.run(&["info", &input, "--json"]); + assert_ne!(code(&after), 0, "a producer that answered nothing exited 0"); + assert!( + stderr(&after).contains("error category: adapter_no_result"), + "info did not name the failure category: {}", + stderr(&after) + ); + let report = json(&after); + assert_eq!( + report["adapter_installed"], + Value::Bool(true), + "info did not see the install: {}", + stdout(&after) + ); + assert_eq!( + report["adapter_error_category"], + text("adapter_no_result"), + "info swallowed the adapter failure: {}", + stdout(&after) + ); + assert_eq!( + report["compatibility_record_sha256"], + json(&install)["record"]["compatibility_record_sha256"], + "info and install disagree about the compatibility record" + ); + assert!( + prefix.marker.exists(), + "info did not execute the artifact from the resolved store" + ); + + // Point the same binary at an empty store: the install is invisible again, + // which is only true if `info` reads the resolved store rather than a path + // fixed at build time. + fs::remove_file(&prefix.marker).expect("remove marker"); + let elsewhere = prefix.run_with( + &[( + "FLUTTERDEC_ADAPTER_STORE", + prefix.root().join("nowhere").to_str().expect("path"), + )], + &["info", &input, "--json"], + ); + assert_eq!(code(&elsewhere), 0, "{}", stderr(&elsewhere)); + assert_eq!(json(&elsewhere)["adapter_installed"], Value::Bool(false)); + assert!( + !prefix.marker.exists(), + "info ran an artifact from another store" + ); + + // `decompile` resolves the same two locations. With no store there is + // nothing to execute, so core recovers the program and names the reason; + // with the install the artifact runs. The two arms differ in exactly one + // observable: whether the fixture producer's marker appears. + let out = prefix.root().join("out"); + let out_arg = out.to_str().expect("path").to_string(); + let empty = prefix.run_with( + &[( + "FLUTTERDEC_ADAPTER_STORE", + prefix.root().join("nowhere").to_str().expect("path"), + )], + &[ + "decompile", + &input, + "-o", + &out_arg, + "--function-scope", + "all", + "--min-disassembly-ratio", + "0.0", + ], + ); + assert_eq!(code(&empty), 0, "{}", stderr(&empty)); + let summary: Value = + serde_json::from_slice(&fs::read(out.join("report.json")).expect("read report")) + .expect("report JSON"); + assert_eq!( + summary["adapter_selection"]["provider"]["core_fallback_reason"], + text("adapter_not_installed"), + "decompile did not look for the artifact in the resolved store: {}", + stderr(&empty) + ); + assert!( + !prefix.marker.exists(), + "decompile ran an uninstalled artifact" + ); + + let installed = prefix.run(&["decompile", &input, "-o", &out_arg]); + assert_ne!(code(&installed), 0, "the fixture producer emits no model"); + assert!( + prefix.marker.exists(), + "decompile did not execute the artifact from the resolved store: {}", + stderr(&installed) + ); +} + +/// What the host says it established has to reach the operator, and it has to +/// say the same thing through both surfaces that report a run. +#[test] +fn info_and_the_decompile_report_state_which_containment_controls_were_established() { + let prefix = Prefix::answering(); + let libapp = prefix.root().join("libapp.so"); + fs::write(&libapp, synthetic_libapp(HASH, FEATURES)).expect("write libapp"); + let input = libapp.to_str().expect("path").to_string(); + + let install = prefix.install(); + assert_eq!(code(&install), 0, "{}", stderr(&install)); + + let info = prefix.run(&["info", &input, "--json"]); + assert_eq!(code(&info), 0, "{}", stderr(&info)); + let report = json(&info); + assert_eq!( + report["resolved_backend"], + text("internal"), + "the fixture producer did not run: {}", + stdout(&info) + ); + assert_controls_are_accurate(&report["adapter_containment"], "flutterdec info"); + + let out = prefix.root().join("out"); + let out_arg = out.to_str().expect("path").to_string(); + // The fixture producer recovers nothing, so the default app-only scope has + // nothing to emit. The scope is not what this test is about. + let decompile = prefix.run(&[ + "decompile", + &input, + "-o", + &out_arg, + "--function-scope", + "all", + ]); + let report_path = out.join("report.json"); + assert!( + report_path.is_file(), + "decompile wrote no report (exit {}): {}", + code(&decompile), + stderr(&decompile) + ); + let summary: Value = + serde_json::from_slice(&fs::read(&report_path).expect("read report")).expect("report JSON"); + assert_controls_are_accurate( + &summary["adapter_selection"]["provider"]["containment"], + "decompile report.json", + ); + assert_eq!( + comparable_across_runs(&summary["adapter_selection"]["provider"]["containment"]), + comparable_across_runs(&report["adapter_containment"]), + "info and report.json disagree about what was established" + ); +} + +/// The feature tuple both shipped records declare, as a snapshot header spells +/// it. Order is not significant: the selection key normalizes before comparing. +const SHIPPED_FEATURES: &str = "product no-code_comments compressed-pointers arm64 android"; + +/// The store ledger, as JSON, for an observation to be reported alongside. +fn ledger(store: &Path) -> Value { + match fs::read(store.join("store.json")) { + Ok(bytes) => serde_json::from_slice(&bytes).expect("store.json is JSON"), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Value::Null, + Err(err) => panic!("read store.json: {err}"), + } +} + +/// The `adapter list` row for one snapshot hash. +fn row_for<'a>(rows: &'a Value, hash: &str) -> &'a Value { + rows.as_array() + .expect("rows") + .iter() + .find(|row| row["snapshot_hash"] == text(hash)) + .unwrap_or_else(|| panic!("no row for {hash}: {rows}")) +} + +/// Installing for one record must not authorize a different record that names +/// identical artifact bytes, and what `adapter list` reports must be what +/// `info` does — in both directions. +/// +/// This drives the *shipped* registry rather than the fixture because that is +/// where the two records collide: both name `artifacts/flutterdec-local-python` +/// with the same digest, so the file on disk cannot tell them apart. Only the +/// store ledger records which record something was installed for, and it is the +/// ledger `adapter list` reports from. +#[test] +fn an_install_for_one_record_does_not_authorize_another_sharing_its_artifact() { + let prefix = Prefix::new(); + let checkout = checkout_root(); + let checkout_before = tree_digests(&checkout.join("adapters")); + let store = prefix.root().join("shipped-store"); + let env = [ + ("FLUTTERDEC_DATA_DIR", checkout.to_str().expect("path")), + ( + "FLUTTERDEC_ADAPTER_STORE", + store.to_str().expect("store path"), + ), + ]; + + let install = prefix.run_with( + &env, + &["adapter", "install", "--dart-hash", OTHER_HASH, "--json"], + ); + assert_eq!(code(&install), 0, "install failed: {}", stderr(&install)); + + // Exactly one record is installed, and the ledger says which one. + let claimed = ledger(&store); + assert_eq!( + claimed["adapters"].as_array().map(Vec::len), + Some(1), + "store.json: {claimed:#}" + ); + assert_eq!( + claimed["adapters"][0]["snapshot_hash"], + text(OTHER_HASH), + "store.json: {claimed:#}" + ); + + let rows = json(&prefix.run_with(&env, &["adapter", "list", "--json"])); + assert_eq!( + row_for(&rows, OTHER_HASH)["state"], + text("verified"), + "store.json: {claimed:#}\nrows: {rows:#}" + ); + assert_eq!( + row_for(&rows, HASH)["state"], + text("unavailable"), + "store.json: {claimed:#}\nrows: {rows:#}" + ); + // Both records really do name one file, which is what makes an existence + // check unable to answer this. + assert_eq!( + row_for(&rows, HASH)["artifact_path"], + row_for(&rows, OTHER_HASH)["artifact_path"], + "the shipped records no longer share an artifact path: {rows:#}" + ); + + let libapp = prefix.root().join("other-record-libapp.so"); + fs::write(&libapp, synthetic_libapp(HASH, SHIPPED_FEATURES)).expect("write libapp"); + let input = libapp.to_str().expect("path").to_string(); + + let info = prefix.run_with(&env, &["info", &input, "--json"]); + let report = json(&info); + assert_eq!( + report["snapshot_hash"], + text(HASH), + "the fixture snapshot does not match the uninstalled record: {report:#}" + ); + assert_eq!( + report["registry_record_present"], + Value::Bool(true), + "the uninstalled record was not selected at all: {report:#}" + ); + // The split: `adapter list` calls this record unavailable, so nothing may + // report it as a registered adapter that executed. + assert_eq!( + report["adapter_installed"], + Value::Bool(false), + "info claims an install the ledger does not hold\nstore.json: {claimed:#}\ninfo: {report:#}" + ); + assert_eq!( + report["provider"]["adapter_executed"], + Value::Bool(false), + "an adapter installed for {OTHER_HASH} executed for {HASH}\nstore.json: {claimed:#}\ninfo: {report:#}" + ); + assert_ne!( + report["provider"]["producer_trust"], + text("registered"), + "an unavailable record produced a registered producer\nstore.json: {claimed:#}\ninfo: {report:#}" + ); + + // The other direction: once the ledger does hold this record, `list` says + // verified and authorization does not refuse for want of an installation. + let install = prefix.run_with(&env, &["adapter", "install", "--dart-hash", HASH, "--json"]); + assert_eq!(code(&install), 0, "install failed: {}", stderr(&install)); + let claimed = ledger(&store); + assert_eq!( + claimed["adapters"].as_array().map(Vec::len), + Some(2), + "store.json: {claimed:#}" + ); + + let rows = json(&prefix.run_with(&env, &["adapter", "list", "--json"])); + assert_eq!( + row_for(&rows, HASH)["state"], + text("verified"), + "store.json: {claimed:#}\nrows: {rows:#}" + ); + + let info = prefix.run_with(&env, &["info", &input, "--json"]); + let report = json(&info); + assert_eq!( + report["adapter_installed"], + Value::Bool(true), + "info did not see the install\nstore.json: {claimed:#}\ninfo: {report:#}" + ); + assert_eq!( + report["provider"]["adapter_executed"], + Value::Bool(true), + "a verified record did not execute\nstore.json: {claimed:#}\ninfo: {report:#}\nstderr: {}", + stderr(&info) + ); + assert_eq!( + report["provider"]["producer_trust"], + text("registered"), + "store.json: {claimed:#}\ninfo: {report:#}" + ); + + assert_eq!( + checkout_before, + tree_digests(&checkout.join("adapters")), + "the run mutated read-only package data" + ); +} + +/// Rewrite one field of the fixture registry in place. +/// +/// A record that fails validation cannot be built through the rig's +/// `record_json`, because that function exists to produce a *valid* record. +fn edit_registry(prefix: &Prefix, edit: impl FnOnce(&mut Value)) { + let path = prefix.share().join("adapters/registry.json"); + let mut registry: Value = + serde_json::from_slice(&fs::read(&path).expect("read registry")).expect("registry is JSON"); + edit(&mut registry); + fs::write( + &path, + serde_json::to_vec_pretty(®istry).expect("serialize registry"), + ) + .expect("write registry"); +} + +/// Every store failure an operator can reach names what went wrong. +/// +/// `error category:` is the half of the failure a script matches on, so a +/// condition the code already models as a variant reporting `unclassified` is +/// the same as reporting nothing. Each case is driven through the packaged +/// binary because the category is only real if it survives the CLI's own error +/// plumbing. +struct Case<'a> { + /// What the operator did, for the assertion message to name. + what: &'a str, + args: Vec<&'a str>, + env: Vec<(&'a str, &'a str)>, + expected: &'a str, +} + +#[test] +fn every_store_failure_reports_its_own_category() { + let bytes_prefix = Prefix::new(); + let wrong_bytes = bytes_prefix.root().join("not-the-authorized-bytes"); + fs::write(&wrong_bytes, "#!/bin/sh\nexit 0\n").expect("write source"); + let wrong_bytes = wrong_bytes.to_str().expect("path").to_string(); + let absent = bytes_prefix.root().join("no-such-source"); + let absent = absent.to_str().expect("path").to_string(); + + let cases = vec![ + Case { + what: "a well-formed hash the registry does not carry", + args: vec!["adapter", "install", "--dart-hash", OTHER_HASH], + env: vec![], + expected: "store_no_record", + }, + Case { + what: "a hash that is not a hash", + args: vec!["adapter", "install", "--dart-hash", "not-a-hash"], + env: vec![], + expected: "store_invalid_input", + }, + Case { + what: "artifact bytes the record did not authorize", + args: vec![ + "adapter", + "install", + "--dart-hash", + HASH, + "--from", + &wrong_bytes, + ], + env: vec![], + expected: "store_artifact_digest_mismatch", + }, + Case { + what: "an artifact source that is not there", + args: vec!["adapter", "install", "--dart-hash", HASH, "--from", &absent], + env: vec![], + expected: "store_artifact_source_rejected", + }, + Case { + what: "a target the record does not serve", + args: vec![ + "adapter", + "install", + "--dart-hash", + HASH, + "--target-arch", + "x64", + ], + env: vec![], + expected: "store_incompatible", + }, + Case { + what: "an install cut short before the lock", + args: vec!["adapter", "install", "--dart-hash", HASH], + env: vec![("FLUTTERDEC_INSTALL_FAIL_BEFORE", "lock")], + expected: "store_install_interrupted", + }, + Case { + what: "an install cut short before staging", + args: vec!["adapter", "install", "--dart-hash", HASH], + env: vec![("FLUTTERDEC_INSTALL_FAIL_BEFORE", "stage")], + expected: "store_install_interrupted", + }, + Case { + what: "an install cut short before the artifact rename", + args: vec!["adapter", "install", "--dart-hash", HASH], + env: vec![("FLUTTERDEC_INSTALL_FAIL_BEFORE", "publish_artifact")], + expected: "store_install_interrupted", + }, + Case { + what: "an install cut short before the state rename", + args: vec!["adapter", "install", "--dart-hash", HASH], + env: vec![("FLUTTERDEC_INSTALL_FAIL_BEFORE", "publish_state")], + expected: "store_install_interrupted", + }, + Case { + what: "an injection point that is not a publish step", + args: vec!["adapter", "install", "--dart-hash", HASH], + env: vec![("FLUTTERDEC_INSTALL_FAIL_BEFORE", "not-a-step")], + expected: "store_invalid_input", + }, + ]; + + for Case { + what, + args, + env, + expected, + } in cases + { + // A fresh prefix per case: an injected failure that rolled back is only + // a rollback if nothing before it left state behind. + let prefix = Prefix::new(); + let output = prefix.run_with(&env, &args); + assert_ne!(code(&output), 0, "{what} succeeded"); + assert_eq!( + category(&output), + expected, + "{what}: {}", + stderr(&output).trim() + ); + } +} + +/// A record `adapter list` and `info` both have to read reports the same +/// category through both. +/// +/// The two commands take different routes to the same registry, and the +/// listing route used to flatten its typed error into a string, so the same +/// traversing record answered `registry_invalid_record` through `info` and +/// `unclassified` through the listing. The category is what a script keys on; +/// two answers for one condition means there is no key. +#[test] +fn adapter_list_and_info_report_one_category_for_a_record_that_fails_validation() { + let prefix = Prefix::new(); + edit_registry(&prefix, |registry| { + registry["records"][0]["profile"]["path"] = text("../../../etc/passwd"); + }); + let libapp = prefix.root().join("libapp.so"); + fs::write(&libapp, synthetic_libapp(HASH, FEATURES)).expect("write libapp"); + let input = libapp.to_str().expect("path").to_string(); + + let list = prefix.run(&["adapter", "list", "--json"]); + let info = prefix.run(&["info", &input, "--json"]); + assert_ne!(code(&list), 0, "a traversing record listed cleanly"); + assert_ne!(code(&info), 0, "a traversing record was accepted by info"); + assert_eq!( + category(&list), + "registry_invalid_record", + "adapter list: {}", + stderr(&list).trim() + ); + assert_eq!( + category(&list), + category(&info), + "one record, two categories\nadapter list: {}\ninfo: {}", + stderr(&list).trim(), + stderr(&info).trim() + ); +} + +/// A binary carried out of its packaged prefix says so with a category. +/// +/// Resolution happens before any subcommand runs, so this is the one failure an +/// operator can hit without the product having read anything at all — and the +/// one most likely to be hit by a broken install rather than by bad input. +#[test] +fn a_binary_outside_its_prefix_names_the_layout_failure() { + let prefix = Prefix::new(); + // Deep enough that neither `/../share/flutterdec`, ``, nor + // `/../..` can hold package data. + let lonely = prefix.root().join("carried/away/bin"); + fs::create_dir_all(&lonely).expect("mkdir"); + let binary = lonely.join("flutterdec"); + fs::copy(prefix.root().join("bin/flutterdec"), &binary).expect("copy binary"); + let mut perms = fs::metadata(&binary).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&binary, perms).expect("chmod"); + + let mut cmd = std::process::Command::new(&binary); + cmd.env_clear() + .env("PATH", "/usr/bin:/bin") + .env("HOME", prefix.root().join("home")) + .current_dir(prefix.root().join("cwd")) + .args(["adapter", "list"]); + let output = run(&mut cmd); + assert_ne!(code(&output), 0, "a binary with no package data listed"); + assert_eq!( + category(&output), + "layout_no_data_directory", + "{}", + stderr(&output).trim() + ); + + // An override that names a directory holding no registry is its own + // condition: it never falls back, so it must not report as "nothing found". + let mut cmd = std::process::Command::new(&binary); + cmd.env_clear() + .env("PATH", "/usr/bin:/bin") + .env("HOME", prefix.root().join("home")) + .env("FLUTTERDEC_DATA_DIR", prefix.root().join("cwd")) + .current_dir(prefix.root().join("cwd")) + .args(["adapter", "list"]); + let output = run(&mut cmd); + assert_ne!(code(&output), 0, "an empty override was accepted"); + assert_eq!( + category(&output), + "layout_data_dir_override_invalid", + "{}", + stderr(&output).trim() + ); + + // No home and no store override: the writable half cannot be placed. + let mut cmd = std::process::Command::new(&binary); + cmd.env_clear() + .env("PATH", "/usr/bin:/bin") + .env("FLUTTERDEC_DATA_DIR", prefix.share()) + .current_dir(prefix.root().join("cwd")) + .args(["adapter", "list"]); + let output = run(&mut cmd); + assert_ne!(code(&output), 0, "a run with no data home was accepted"); + assert_eq!( + category(&output), + "layout_no_data_home", + "{}", + stderr(&output).trim() + ); +} diff --git a/crates/flutterdec-cli/tests/cli_surfaces.rs b/crates/flutterdec-cli/tests/cli_surfaces.rs new file mode 100644 index 00000000..92e10ba7 --- /dev/null +++ b/crates/flutterdec-cli/tests/cli_surfaces.rs @@ -0,0 +1,628 @@ +//! What `info`, `decompile` and `diff` tell an operator, per scenario. +//! +//! Every case runs the packaged binary against a real snapshot fixture and a +//! real fixture producer, and reads the artifacts the command wrote. The +//! scenarios are the ones an operator actually hits: an exact match that runs, +//! an unknown snapshot that nothing is authorized to parse, an adapter that +//! answers about the wrong snapshot, one that never finishes, one that writes a +//! corrupt model, and a two-sided diff whose sides were not produced the same +//! way. +//! +//! The rig's producers all append to a spawn log, so "no adapter executed" is +//! backed by a count rather than by the absence of an assertion. Each +//! zero-spawn case has a sibling that shows the same rig producing spawns. + +mod support; + +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use support::*; + +/// A snapshot whose header parses and whose hash no record covers. +const UNKNOWN_HASH: &str = "ace654289f5abc240509fc941453ebc5"; + +fn write_libapp(prefix: &Prefix, name: &str, hash: &str) -> String { + let path = prefix.root().join(name); + fs::write(&path, synthetic_libapp(hash, FEATURES)).expect("write libapp"); + path.to_str().expect("path").to_string() +} + +fn read_json(path: &Path) -> Value { + serde_json::from_slice(&fs::read(path).expect("read artifact")).expect("artifact JSON") +} + +/// `decompile` with the gates relaxed, so the assertion is about what was +/// reported rather than about whether a heuristic model clears a quality bar. +fn decompile_args<'a>(input: &'a str, out: &'a str) -> Vec<&'a str> { + vec![ + "decompile", + input, + "-o", + out, + "--function-scope", + "all", + "--min-disassembly-ratio", + "0.0", + "--max-placeholder-ifs", + "999999", + "--max-unresolved-cf", + "999999", + "--max-indirect-call-ratio", + "1.0", + ] +} + +fn out_dir(prefix: &Prefix, name: &str) -> PathBuf { + prefix.root().join(name) +} + +/// Every domain a snapshot parser would supply, and none of which instruction +/// bytes can. +const SEMANTIC_DOMAINS: &[&str] = &[ + "libraries", + "classes", + "class_relationships", + "function_names", + "object_pool", + "pool_index_space", +]; + +fn assert_core_recovery_is_honest(provider: &Value, capabilities: &Value, source: &str) { + assert_eq!( + provider["adapter_executed"], + Value::Bool(false), + "{source} claims an adapter ran" + ); + assert_eq!( + provider["resolved_backend"], + Value::String("internal".to_string()), + "{source} named a backend core cannot be" + ); + assert_eq!( + provider["adapter_exec_path"], + Value::Null, + "{source} names an executable for a run that had none" + ); + assert_eq!( + provider["containment"], + Value::Null, + "{source} reports containment for a child that never existed" + ); + assert_eq!( + provider["compatibility_record_sha256"], + Value::Null, + "{source} bound a core-recovered model to a compatibility record" + ); + assert!( + provider["core_fallback_effect"] + .as_str() + .is_some_and(|effect| effect.contains("no function names") + && effect.contains("no authoritative ObjectPool index space")), + "{source} does not say what the fallback costs: {provider}" + ); + for domain in SEMANTIC_DOMAINS { + assert_eq!( + capabilities[*domain], + Value::String("unavailable".to_string()), + "{source} claims {domain} without a snapshot parser" + ); + } +} + +/// The honest-fallback case, end to end: an unknown snapshot still produces +/// useful code and refuses to produce meaning. +#[test] +fn an_unknown_snapshot_is_recovered_by_core_with_no_adapter_execution() { + let prefix = Prefix::answering(); + assert_eq!(code(&prefix.install()), 0); + let input = write_libapp(&prefix, "unknown.so", UNKNOWN_HASH); + let out = out_dir(&prefix, "out"); + let out_arg = out.to_str().expect("path").to_string(); + + let info = prefix.run(&["info", &input, "--json"]); + assert_eq!(code(&info), 0, "{}", stderr(&info)); + let report = json(&info); + assert_eq!( + report["provider"]["core_fallback_reason"], + text("no_compatibility_record") + ); + assert_eq!(report["registry_record_present"], Value::Bool(false)); + assert_eq!(report["adapter_installed"], Value::Bool(false)); + assert_core_recovery_is_honest( + &report["provider"], + &report["provider"]["capabilities"], + "flutterdec info", + ); + + let decompile = prefix.run(&decompile_args(&input, &out_arg)); + assert_eq!(code(&decompile), 0, "{}", stderr(&decompile)); + let summary = read_json(&out.join("report.json")); + let provider = &summary["adapter_selection"]["provider"]; + assert_eq!( + provider["core_fallback_reason"], + text("no_compatibility_record") + ); + assert_core_recovery_is_honest(provider, &summary["model"]["capabilities"], "report.json"); + + // Useful code output: candidates recovered, disassembled, and emitted. + assert!( + summary["counts"]["functions"].as_u64().unwrap_or(0) > 0, + "core recovered no function candidates: {summary}" + ); + assert!( + summary["counts"]["disassembled_functions"] + .as_u64() + .unwrap_or(0) + > 0, + "no candidate reached the disassembler: {summary}" + ); + let pseudocode = fs::read_dir(out.join("pseudocode")) + .expect("pseudocode dir") + .count(); + assert!(pseudocode > 0, "no pseudocode was written"); + + // And no semantic record was fabricated to get there. + assert_eq!(summary["counts"]["libraries"], Value::from(0)); + assert_eq!(summary["counts"]["classes"], Value::from(0)); + assert_eq!(summary["counts"]["object_pool"], Value::from(0)); + assert_eq!( + summary["model"]["function_name_provenance"]["named"], + Value::from(0), + "core recovery named a function" + ); + + // One diagnostic per domain that stayed unavailable, plus the one that says + // the code candidates are a scan. + assert!( + summary["model"]["diagnostics"].as_u64().unwrap_or(0) > SEMANTIC_DOMAINS.len() as u64, + "the fallback left domains unavailable without saying why: {summary}" + ); + // The quality artifact describes the same recovery, and no emitted name + // claims to come from the snapshot. + let quality = read_json(&out.join("quality.json")); + assert_eq!( + quality["function_count"], summary["counts"]["functions"], + "quality.json and report.json disagree about what was recovered" + ); + assert!(quality["disassembled_function_count"].as_u64().unwrap_or(0) > 0); + assert_eq!( + summary["name_resolution"]["final_quality"]["exact"], + Value::from(0), + "core recovery produced an exact symbol name" + ); + + assert_eq!( + prefix.spawns(), + 0, + "an unknown snapshot executed an adapter {} times", + prefix.spawns() + ); + assert!(!prefix.marker.exists()); + + // Deterministic: the same input through the same command twice reports the + // same reason, the same counts and the same provider block. + let second = out_dir(&prefix, "out-again"); + let second_arg = second.to_str().expect("path").to_string(); + let repeat = prefix.run(&decompile_args(&input, &second_arg)); + assert_eq!(code(&repeat), 0, "{}", stderr(&repeat)); + let repeated = read_json(&second.join("report.json")); + assert_eq!( + comparable_across_runs(&repeated["adapter_selection"]["provider"]), + comparable_across_runs(&summary["adapter_selection"]["provider"]), + "two identical runs described the provider differently" + ); + assert_eq!(repeated["counts"], summary["counts"]); + assert_eq!(prefix.spawns(), 0); +} + +/// The control for the case above: the same prefix, the same producer, a hash +/// the registry does cover. Without this, zero spawns could mean the fixture +/// producer never works. +#[test] +fn an_exact_snapshot_runs_the_authorized_adapter_and_reports_the_binding() { + let prefix = Prefix::answering(); + let install = prefix.install(); + assert_eq!(code(&install), 0, "{}", stderr(&install)); + let input = write_libapp(&prefix, "exact.so", HASH); + let out = out_dir(&prefix, "out"); + let out_arg = out.to_str().expect("path").to_string(); + + let info = prefix.run(&["info", &input, "--json"]); + assert_eq!(code(&info), 0, "{}", stderr(&info)); + let provider = json(&info)["provider"].clone(); + assert_eq!(provider["adapter_executed"], Value::Bool(true)); + assert_eq!(provider["core_fallback_reason"], Value::Null); + assert_eq!(provider["resolved_backend"], text("internal")); + assert_eq!(provider["requested_backend"], text("auto")); + assert_eq!(provider["backend_mismatch"], Value::Bool(false)); + assert_eq!(provider["registry_record_present"], Value::Bool(true)); + assert_eq!(provider["parser_family_id"], text("fixture-family")); + assert_eq!(provider["profile_id"], text("fixture-profile")); + assert_eq!(provider["artifact_id"], text("fixture-artifact")); + assert_eq!(provider["producer_trust"], text("registered")); + assert_eq!(provider["target_arch"], text("arm64")); + assert_eq!(provider["host_os"], text(std::env::consts::OS)); + assert_eq!(provider["host_arch"], text(std::env::consts::ARCH)); + assert_eq!( + provider["snapshot_identity_is_exact"], + Value::Bool(true), + "an exact identity was not reported as header-derived" + ); + + // The digests are the ones the install reported, not restatements. + let installed = json(&install); + assert_eq!( + provider["compatibility_record_sha256"], + installed["record"]["compatibility_record_sha256"] + ); + assert_eq!(provider["artifact_sha256"], installed["record"]["sha256"]); + assert_eq!( + provider["producer_artifact_sha256"], installed["record"]["sha256"], + "the producer digest is not the digest of the bytes that ran" + ); + assert_eq!( + provider["adapter_exec_path"].as_str().map(PathBuf::from), + Some(prefix.artifact()) + ); + + let decompile = prefix.run(&decompile_args(&input, &out_arg)); + assert_eq!(code(&decompile), 0, "{}", stderr(&decompile)); + let summary = read_json(&out.join("report.json")); + assert_eq!( + comparable_across_runs(&summary["adapter_selection"]["provider"]), + comparable_across_runs(&provider), + "info and report.json describe the same run differently" + ); + assert_eq!( + summary["compatibility"]["core_fallback_reason"], + Value::Null + ); + + assert_eq!( + prefix.spawns(), + 2, + "the two commands did not each run the adapter exactly once" + ); +} + +/// A pinned external backend is refused by name rather than answered with +/// prologue scanning, and refused before anything is executed. +#[test] +fn a_pinned_external_backend_is_refused_instead_of_substituted() { + let prefix = Prefix::answering(); + assert_eq!(code(&prefix.install()), 0); + let input = write_libapp(&prefix, "unknown.so", UNKNOWN_HASH); + let out = out_dir(&prefix, "out"); + let out_arg = out.to_str().expect("path").to_string(); + + let mut args = decompile_args(&input, &out_arg); + args.extend(["--adapter-backend", "r2flutter"]); + let refused = prefix.run(&args); + assert_ne!(code(&refused), 0, "a pinned backend was silently answered"); + let message = stderr(&refused); + assert!( + message.contains("--adapter-backend r2flutter") + && message.contains("no_compatibility_record"), + "the refusal does not name the deterministic reason: {message}" + ); + assert!( + !out.join("report.json").exists(), + "a refused run still wrote a report" + ); + assert_eq!(prefix.spawns(), 0); +} + +/// The explicit-internal case reads no registry and executes nothing, on a +/// snapshot that an installed adapter would otherwise have handled. +#[test] +fn explicit_internal_mode_bypasses_an_adapter_that_would_have_run() { + let prefix = Prefix::answering(); + assert_eq!(code(&prefix.install()), 0); + let input = write_libapp(&prefix, "exact.so", HASH); + let out = out_dir(&prefix, "out"); + let out_arg = out.to_str().expect("path").to_string(); + + let mut args = decompile_args(&input, &out_arg); + args.extend(["--adapter-backend", "internal"]); + let decompile = prefix.run(&args); + assert_eq!(code(&decompile), 0, "{}", stderr(&decompile)); + + let summary = read_json(&out.join("report.json")); + let provider = &summary["adapter_selection"]["provider"]; + assert_eq!(provider["core_fallback_reason"], text("internal_requested")); + assert_eq!(provider["requested_backend"], text("internal")); + assert_core_recovery_is_honest(provider, &summary["model"]["capabilities"], "report.json"); + // A record exists for this snapshot; internal mode never looked for it. + assert_eq!(provider["registry_record_present"], Value::Bool(false)); + assert_eq!( + prefix.spawns(), + 0, + "an explicitly internal run executed an adapter" + ); + assert!(!prefix.marker.exists()); +} + +/// An adapter that answers about a snapshot it was not given is rejected, and +/// the rejection is not quietly turned into core recovery. +#[test] +fn an_adapter_that_reports_the_wrong_identity_fails_with_its_own_category() { + let prefix = Prefix::with_producer(&wrong_identity_producer()); + assert_eq!(code(&prefix.install()), 0); + let input = write_libapp(&prefix, "exact.so", HASH); + let out = out_dir(&prefix, "out"); + let out_arg = out.to_str().expect("path").to_string(); + + let decompile = prefix.run(&decompile_args(&input, &out_arg)); + assert_ne!(code(&decompile), 0, "a wrong-identity model was accepted"); + let message = stderr(&decompile); + assert!( + message.contains("error category: adapter_model_rejected"), + "the failure category is not the model rejection: {message}" + ); + assert!( + !out.join("report.json").exists(), + "a rejected model still produced a report" + ); + assert_eq!(prefix.spawns(), 1, "the producer did not run exactly once"); + + // `info` reports the same failure rather than printing a report with the + // model fields missing. + let info = prefix.run(&["info", &input, "--json"]); + assert_ne!(code(&info), 0); + assert_eq!( + json(&info)["adapter_error_category"], + text("adapter_model_rejected") + ); +} + +/// Two records claiming one snapshot fails both commands, before anything runs. +/// +/// This is the registry refusal that must not look like the unknown-snapshot +/// case above. That one exits 0 with a heuristic model and says so; this one +/// means the operator's own registry cannot name a parser for a snapshot it may +/// well have one for, so answering it with heuristics would be a wrong answer +/// dressed up as an honest one. +/// +/// The same prefix runs successfully first, so the zero exit code, the spawn and +/// the artifacts are all known to be reachable; the second half then asks for +/// exactly none of them. +#[test] +fn an_ambiguous_registry_fails_both_commands_before_any_adapter_runs() { + let prefix = Prefix::answering(); + assert_eq!(code(&prefix.install()), 0); + let input = write_libapp(&prefix, "exact.so", HASH); + let control_out = out_dir(&prefix, "control"); + let control_arg = control_out.to_str().expect("path").to_string(); + + // The control: this registry, this snapshot and this producer do work. + let control = prefix.run(&decompile_args(&input, &control_arg)); + assert_eq!(code(&control), 0, "{}", stderr(&control)); + assert!(control_out.join("report.json").exists()); + assert_eq!(prefix.spawns(), 1); + + // Now the record is written twice, which is the only difference. + let registry_path = prefix.share().join("adapters/registry.json"); + let mut registry: Value = + serde_json::from_slice(&fs::read(®istry_path).expect("read registry")) + .expect("registry JSON"); + let record = registry["records"][0].clone(); + registry["records"] + .as_array_mut() + .expect("records array") + .push(record); + fs::write( + ®istry_path, + serde_json::to_vec_pretty(®istry).expect("serialize registry"), + ) + .expect("write ambiguous registry"); + + let out = out_dir(&prefix, "out"); + let out_arg = out.to_str().expect("path").to_string(); + let decompile = prefix.run(&decompile_args(&input, &out_arg)); + assert_ne!( + code(&decompile), + 0, + "an ambiguous registry produced a decompile: {}", + stderr(&decompile) + ); + let message = stderr(&decompile); + assert!( + message.contains("error category: registry_ambiguous"), + "the failure category is not the ambiguity: {message}" + ); + assert!( + !out.exists(), + "an ambiguous registry still wrote decompile artifacts" + ); + + let info = prefix.run(&["info", &input, "--json"]); + assert_ne!(code(&info), 0, "an ambiguous registry exited 0 from info"); + assert_eq!( + json(&info)["adapter_error_category"], + text("registry_ambiguous") + ); + assert_eq!( + json(&info)["provider"], + Value::Null, + "an ambiguous registry still reported a provider" + ); + + // Neither command reached a child, and neither of them fell back: the run + // count is still the control's single spawn. + assert_eq!( + prefix.spawns(), + 1, + "an ambiguous registry executed an adapter" + ); +} + +/// A producer that never finishes is ended by the host deadline, and the +/// command says so rather than hanging or reporting an empty snapshot. +#[test] +fn an_adapter_that_never_finishes_times_out_with_its_own_category() { + let prefix = Prefix::with_producer(&sleeping_producer()); + assert_eq!(code(&prefix.install()), 0); + let input = write_libapp(&prefix, "exact.so", HASH); + let out = out_dir(&prefix, "out"); + let out_arg = out.to_str().expect("path").to_string(); + + let started = std::time::Instant::now(); + let mut args = decompile_args(&input, &out_arg); + args.extend(["--adapter-timeout", "2"]); + let decompile = prefix.run(&args); + let elapsed = started.elapsed(); + + assert_ne!(code(&decompile), 0, "a run that never answered exited 0"); + let message = stderr(&decompile); + assert!( + message.contains("error category: adapter_timeout"), + "the failure category is not the timeout: {message}" + ); + assert!( + elapsed < std::time::Duration::from_secs(60), + "the deadline did not bound the run: {elapsed:?}" + ); + assert!( + !out.join("report.json").exists(), + "a timed-out run still produced a report" + ); + assert_eq!(prefix.spawns(), 1); +} + +/// A model that is not JSON is a corrupt answer, not an absent one, and it gets +/// its own category rather than the generic failure. +#[test] +fn an_adapter_that_writes_a_corrupt_model_fails_with_its_own_category() { + let prefix = Prefix::with_producer(&corrupt_model_producer()); + assert_eq!(code(&prefix.install()), 0); + let input = write_libapp(&prefix, "exact.so", HASH); + let out = out_dir(&prefix, "out"); + let out_arg = out.to_str().expect("path").to_string(); + + let decompile = prefix.run(&decompile_args(&input, &out_arg)); + assert_ne!(code(&decompile), 0, "a corrupt model was accepted"); + let message = stderr(&decompile); + assert!( + message.contains("error category: adapter_malformed_document"), + "the failure category is not the malformed document: {message}" + ); + assert!( + !out.join("report.json").exists(), + "a corrupt model still produced a report" + ); + assert_eq!(prefix.spawns(), 1); +} + +/// A two-sided diff reports each side separately, flags a run whose sides were +/// not produced the same way, and refuses to count address-only candidates as +/// functions that matched. +#[test] +fn a_two_sided_diff_reports_each_side_and_flags_a_mixed_run() { + let prefix = Prefix::answering(); + assert_eq!(code(&prefix.install()), 0); + let exact = write_libapp(&prefix, "exact.so", HASH); + let unknown = write_libapp(&prefix, "unknown.so", UNKNOWN_HASH); + let out = out_dir(&prefix, "diff-out"); + let out_arg = out.to_str().expect("path").to_string(); + + let diff = prefix.run(&[ + "diff", "--old", &exact, "--new", &unknown, "-o", &out_arg, "--json", + ]); + assert_eq!(code(&diff), 0, "{}", stderr(&diff)); + let report = json(&diff); + + assert_eq!( + report["provider_mismatch"], + Value::Bool(true), + "a diff between an adapter model and a core-recovered one was not flagged" + ); + assert_eq!( + report["old_provider"]["adapter_executed"], + Value::Bool(true) + ); + assert_eq!(report["old_provider"]["core_fallback_reason"], Value::Null); + assert_eq!( + report["new_provider"]["adapter_executed"], + Value::Bool(false) + ); + assert_eq!( + report["new_provider"]["core_fallback_reason"], + text("no_compatibility_record") + ); + assert_eq!( + report["old_provider"]["parser_family_id"], + text("fixture-family") + ); + assert_eq!(report["new_provider"]["parser_family_id"], Value::Null); + + // The core-recovered side is all address-only candidates, so none of them + // is comparable. Counting them as one matching `::` descriptor would have + // read as "one function, unchanged". + assert!( + report["new_uncomparable_function_count"] + .as_u64() + .unwrap_or(0) + > 0, + "the core-recovered side reported no uncomparable candidates: {report}" + ); + assert_eq!( + report["new_function_count"], + Value::from(0), + "an address-only candidate was counted as a comparable function" + ); + + let written = read_json(&out.join("diff_report.json")); + assert_eq!( + written["provider_mismatch"], report["provider_mismatch"], + "the printed report and the written one disagree" + ); + + // Exactly one side ran an adapter. + assert_eq!(prefix.spawns(), 1); +} + +/// A diff whose old side fails names the side. Both sides are selected +/// independently, so "the old one" is information the operator does not have. +#[test] +fn a_diff_that_fails_on_one_side_says_which_side() { + let prefix = Prefix::with_producer(&corrupt_model_producer()); + assert_eq!(code(&prefix.install()), 0); + let exact = write_libapp(&prefix, "exact.so", HASH); + let unknown = write_libapp(&prefix, "unknown.so", UNKNOWN_HASH); + let out = out_dir(&prefix, "diff-out"); + let out_arg = out.to_str().expect("path").to_string(); + + let diff = prefix.run(&[ + "diff", "--old", &exact, "--new", &unknown, "-o", &out_arg, "--json", + ]); + assert_ne!(code(&diff), 0); + let message = stderr(&diff); + assert!( + message.contains("old input") && message.contains("exact.so"), + "the failure does not name the failing side: {message}" + ); + assert!( + message.contains("error category: adapter_malformed_document"), + "the failing side lost its category: {message}" + ); + assert!(!out.join("diff_report.json").exists()); + + // And the mirror image: the failing input on the new side, where the old + // side succeeds first. One spawn on each run, and the second names the + // other side. + let mirrored = prefix.run(&[ + "diff", "--old", &unknown, "--new", &exact, "-o", &out_arg, "--json", + ]); + assert_ne!(code(&mirrored), 0); + let message = stderr(&mirrored); + assert!( + message.contains("new input") && message.contains("exact.so"), + "the mirrored failure does not name the failing side: {message}" + ); + assert_eq!( + prefix.spawns(), + 2, + "the unknown side spawned an adapter, or a failing side spawned twice" + ); +} diff --git a/crates/flutterdec-cli/tests/support/mod.rs b/crates/flutterdec-cli/tests/support/mod.rs new file mode 100644 index 00000000..f4663ff7 --- /dev/null +++ b/crates/flutterdec-cli/tests/support/mod.rs @@ -0,0 +1,842 @@ +//! The shared rig every CLI integration test drives the product through. +//! +//! Every case built here runs the built `flutterdec` binary from a temporary +//! *package prefix* (`bin/flutterdec` plus `share/flutterdec/...`), with a +//! cleared environment, an isolated `HOME`, and a current directory that is not +//! a checkout and contains nothing at all. That is deliberate: install, +//! listing, and discovery all used to depend on the current directory sitting +//! inside a source tree, so a test that runs from the repository root cannot +//! tell a fix from the old behavior. +//! +//! The fixture registry and profile are written here as fresh JSON rather than +//! built from the crate's own types, and the fixture producer is a real +//! executable script whose digest the fixture registry content-addresses, so +//! the digest, host, and containment checks are exercised against bytes rather +//! than against a mock. +#![allow(dead_code)] + +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use tempfile::TempDir; + +pub const HASH: &str = "80a49c7111088100a233b2ae788e1f48"; +pub const OTHER_HASH: &str = "ace654289f5abc240509fc941453ebc5"; +pub const FEATURES: &str = "product arm64 android compressed-pointers"; +pub const ARTIFACT_RELATIVE: &str = "artifacts/dart_adapter"; + +pub fn digest(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +/// SHA-256 of every regular file under `root`, keyed by relative path. +/// +/// Used to assert that a directory was not written to, which is stronger than +/// checking a modification time and does not depend on filesystem timestamp +/// granularity. +pub fn tree_digests(root: &Path) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let meta = match fs::symlink_metadata(&path) { + Ok(meta) => meta, + Err(_) => continue, + }; + if meta.is_dir() { + stack.push(path); + continue; + } + if !meta.is_file() { + continue; + } + let key = path + .strip_prefix(root) + .unwrap_or(&path) + .display() + .to_string(); + let bytes = fs::read(&path).unwrap_or_default(); + out.insert(key, digest(&bytes)); + } + } + out +} + +/// Files under the store that are neither the lock nor part of a finished +/// install. A staged temporary left behind is a partial-state failure. +pub fn store_files(store: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![store.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + if path.file_name().and_then(|name| name.to_str()) == Some(".lock") { + continue; + } + out.push(path); + } + } + out.sort(); + out +} + +pub fn checkout_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize checkout root") +} + +/// A temporary release-style package prefix and an isolated home. +pub struct Prefix { + dir: TempDir, + /// `dir.path()` with every platform symlink resolved. See `root`. + root: PathBuf, + /// Absolute path baked into the fixture producer, touched when it runs. + pub marker: PathBuf, +} + +impl Prefix { + pub fn new() -> Self { + Self::with_variant( + ARTIFACT_RELATIVE, + std::env::consts::OS, + std::env::consts::ARCH, + ) + } + + /// `variant_path`, `host_os` and `host_arch` are what the fixture registry + /// declares, which is how the containment and host cases are set up. + pub fn with_variant(variant_path: &str, host_os: &str, host_arch: &str) -> Self { + Self::build(variant_path, host_os, host_arch, None) + } + + /// A prefix whose packaged producer answers the request instead of only + /// proving it ran. + /// + /// Needed wherever the assertion is about what a *completed* run reports: + /// a producer that exits without a model never gets as far as a model, a + /// containment report, or a `report.json`. + pub fn answering() -> Self { + Self::with_producer(&answering_producer()) + } + + /// A prefix carrying an arbitrary packaged producer. + /// + /// `__SPAWN_LOG__` in the source is replaced with an absolute path this + /// prefix owns, so every fixture producer records the fact that it ran. That + /// count is the evidence behind "no adapter was executed": an assertion + /// about an absent side effect is only as good as the proof the side effect + /// would have appeared. + pub fn with_producer(source: &str) -> Self { + Self::build( + ARTIFACT_RELATIVE, + std::env::consts::OS, + std::env::consts::ARCH, + Some(source), + ) + } + + /// Where each spawn appended a line, and how many did. + pub fn spawn_log(&self) -> PathBuf { + self.root().join("producer_spawns.log") + } + + pub fn spawns(&self) -> usize { + fs::read_to_string(self.spawn_log()) + .map(|text| text.lines().filter(|line| !line.is_empty()).count()) + .unwrap_or(0) + } + + pub fn build( + variant_path: &str, + host_os: &str, + host_arch: &str, + producer_source: Option<&str>, + ) -> Self { + let dir = TempDir::new().expect("tempdir"); + let root = fs::canonicalize(dir.path()).expect("resolve the prefix root"); + let root = root.as_path(); + let marker = root.join("producer_ran.marker"); + fs::create_dir_all(root.join("bin")).expect("mkdir bin"); + fs::create_dir_all(root.join("home")).expect("mkdir home"); + fs::create_dir_all(root.join("cwd")).expect("mkdir cwd"); + fs::create_dir_all(root.join("share/flutterdec/adapters/python")).expect("mkdir python"); + fs::create_dir_all(root.join("share/flutterdec/data")).expect("mkdir data"); + + // Only release-distributed files are copied in: the binary and the + // package data. Nothing from the checkout is linked or referenced. + fs::copy( + env!("CARGO_BIN_EXE_flutterdec"), + root.join("bin/flutterdec"), + ) + .expect("copy release binary"); + + let producer = match producer_source { + Some(source) => source.replace( + "__SPAWN_LOG__", + root.join("producer_spawns.log") + .to_str() + .expect("spawn log path"), + ), + None => format!( + "#!/bin/sh\ntouch '{}'\necho spawn >> '{}'\nexit 3\n", + marker.display(), + root.join("producer_spawns.log").display() + ), + }; + let producer_path = root.join("share/flutterdec/adapters/python/adapter_template.py"); + fs::write(&producer_path, &producer).expect("write producer"); + let mut perms = fs::metadata(&producer_path) + .expect("metadata") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&producer_path, perms).expect("chmod producer"); + + let profile = serde_json::to_vec_pretty(&serde_json::json!({ + "profiles": { + "fixture-profile": { + "tag_style": "CID_INT32", + "compressed_word_size": 4, + "header_fields": 5, + "max_alignment": 16, + "heap_object_tag": 1, + "cids": {"class": 1, "object_pool": 23} + } + } + })) + .expect("serialize profile"); + fs::write( + root.join("share/flutterdec/data/fixture-profile.json"), + &profile, + ) + .expect("write profile"); + + let registry = serde_json::json!({ + "version": 1, + "records": [record_json( + HASH, + variant_path, + host_os, + host_arch, + producer.as_bytes(), + &profile, + )] + }); + fs::write( + root.join("share/flutterdec/adapters/registry.json"), + serde_json::to_vec_pretty(®istry).expect("serialize registry"), + ) + .expect("write registry"); + + let root = root.to_path_buf(); + Self { dir, root, marker } + } + + /// The prefix root, as the product will spell it back. + /// + /// The temporary directory can sit behind a platform symlink — Darwin hands + /// out `/var/folders/...` for a tree that really lives at + /// `/private/var/folders/...` — and anything the product canonicalizes comes + /// back in the resolved spelling. Resolving here once means every path this + /// rig derives is comparable to what the product reports, rather than every + /// assertion having to resolve for itself. + pub fn root(&self) -> &Path { + &self.root + } + + pub fn share(&self) -> PathBuf { + self.root().join("share/flutterdec") + } + + /// The store the default discovery rule lands on for this isolated home. + pub fn store(&self) -> PathBuf { + self.root().join("home/.local/share/flutterdec/adapters") + } + + pub fn artifact(&self) -> PathBuf { + self.store().join(ARTIFACT_RELATIVE) + } + + pub fn producer(&self) -> PathBuf { + self.share().join("adapters/python/adapter_template.py") + } + + /// What the store holds after one successful install and nothing else. + pub fn settled_store_files(&self) -> Vec { + let mut files = vec![self.artifact(), self.store().join("store.json")]; + files.sort(); + files + } + + /// A run of the packaged binary from an unrelated working directory. + pub fn cmd(&self) -> Command { + let mut cmd = Command::new(self.root().join("bin/flutterdec")); + cmd.env_clear() + .env("PATH", "/usr/bin:/bin") + .env("HOME", self.root().join("home")) + .current_dir(self.root().join("cwd")); + cmd + } + + pub fn run(&self, args: &[&str]) -> Output { + self.run_with(&[], args) + } + + pub fn run_with(&self, env: &[(&str, &str)], args: &[&str]) -> Output { + let mut cmd = self.cmd(); + for (key, value) in env { + cmd.env(key, value); + } + cmd.args(args); + run(&mut cmd) + } + + pub fn install(&self) -> Output { + self.run(&["adapter", "install", "--dart-hash", HASH, "--json"]) + } + + pub fn list(&self) -> Output { + self.run(&["adapter", "list", "--json"]) + } +} + +/// One compatibility record as JSON, content-addressing `producer` and +/// `profile` exactly as a real registry does. +pub fn record_json( + hash: &str, + variant_path: &str, + host_os: &str, + host_arch: &str, + producer: &[u8], + profile: &[u8], +) -> Value { + let features = ["android", "arm64", "compressed-pointers", "product"]; + let mut hasher = Sha256::new(); + hasher.update(features.join("\n").as_bytes()); + let fingerprint = format!("{:x}", hasher.finalize()); + serde_json::json!({ + "snapshot_hash": hash, + "snapshot_kind": "full_aot", + "target_arch": "arm64", + "features": features, + "feature_fingerprint": fingerprint, + "known_features": features, + "forbidden_features": ["no-compressed-pointers"], + "sdk_aliases": [], + "parser_family": {"id": "fixture-family", "version": "1", "sha256": null}, + "profile": { + "id": "fixture-profile", + "path": "data/fixture-profile.json", + "sha256": digest(profile) + }, + "artifact": { + "id": "fixture-artifact", + "variants": [{ + "host_os": host_os, + "host_arch": host_arch, + "path": variant_path, + "size": producer.len(), + "sha256": digest(producer), + "provenance": "integration fixture" + }] + }, + "evidence": {"source": "fixture", "provenance": "integration test", "references": []}, + "trust_tier": "experimental", + "protocol_major": 1, + "model_major": 4 + }) +} + +/// Run a command, retrying while a freshly copied binary is still reported busy. +/// +/// Tests run as parallel threads in one process, and a thread that forks while +/// another thread is writing a file can leave the kernel's deny-write count on +/// that inode raised for a moment, which surfaces as `ETXTBSY` from `exec`. +/// That is a property of the harness, not of the binary under test. +pub fn run(cmd: &mut Command) -> Output { + for _ in 0..200 { + match cmd.output() { + Ok(output) => return output, + Err(err) if err.raw_os_error() == Some(26) => { + std::thread::sleep(std::time::Duration::from_millis(20)) + } + Err(err) => panic!("run {cmd:?}: {err}"), + } + } + panic!("{cmd:?} stayed busy") +} + +pub fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).to_string() +} + +pub fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).to_string() +} + +/// The machine-readable half of a failure, as the CLI printed it. +/// +/// Returned rather than asserted on with `contains`, so a case that prints the +/// wrong category fails with the category it did print instead of with "the +/// string was not found". +pub fn category(output: &Output) -> String { + stderr(output) + .lines() + .find_map(|line| line.strip_prefix("error category: ")) + .unwrap_or("") + .to_string() +} + +pub fn json(output: &Output) -> Value { + serde_json::from_str(&stdout(output)) + .unwrap_or_else(|err| panic!("stdout is not JSON ({err}): {}", stdout(output))) +} + +pub fn code(output: &Output) -> i32 { + output.status.code().unwrap_or(-1) +} + +pub fn text(value: &str) -> Value { + Value::String(value.to_string()) +} + +/// A minimal ARM64 `libapp.so` carrying a FullAOT snapshot header. +/// +/// One `PT_LOAD` at address zero, so a symbol's virtual address equals its file +/// offset, plus the four `_kDart*` symbols the loader looks for. The snapshot +/// header layout is `runtime/vm/snapshot.h`: magic, `int64` length, `int64` +/// kind, then the 32-character hash and the NUL-terminated features string. +pub fn synthetic_libapp(hash: &str, features: &str) -> Vec { + const EHDR: usize = 64; + const PHDR: usize = 56; + const SHDR: usize = 64; + const SYM: usize = 24; + const RET: [u8; 4] = 0xD65F_03C0u32.to_le_bytes(); + + let mut vm_data = vec![0u8; 8]; + vm_data.extend_from_slice(&[0xf5, 0xf5, 0xdc, 0xdc]); + let payload = 20 + hash.len() + features.len() + 1; + vm_data.extend_from_slice(&(payload as i64).to_le_bytes()); + vm_data.extend_from_slice(&3i64.to_le_bytes()); // kFullAOT + vm_data.extend_from_slice(hash.as_bytes()); + vm_data.extend_from_slice(features.as_bytes()); + vm_data.push(0); + + let mut out = vec![0u8; 128]; + let place = |out: &mut Vec, bytes: &[u8]| -> (u64, u64) { + let at = out.len() as u64; + out.extend_from_slice(bytes); + (at, bytes.len() as u64) + }; + let spans = [ + place(&mut out, &vm_data), + place(&mut out, &[0u8; 32]), + place(&mut out, &RET), + place(&mut out, &RET.repeat(4)), + ]; + + let mut strtab = vec![0u8]; + let mut name_offsets = Vec::new(); + for name in [ + "_kDartVmSnapshotData", + "_kDartIsolateSnapshotData", + "_kDartVmSnapshotInstructions", + "_kDartIsolateSnapshotInstructions", + ] { + name_offsets.push(strtab.len() as u32); + strtab.extend_from_slice(name.as_bytes()); + strtab.push(0); + } + + let mut symtab = vec![0u8; SYM]; + for (index, (value, size)) in spans.iter().enumerate() { + symtab.extend_from_slice(&name_offsets[index].to_le_bytes()); + symtab.push(0x11); // STB_GLOBAL | STT_OBJECT + symtab.push(0); + symtab.extend_from_slice(&1u16.to_le_bytes()); + symtab.extend_from_slice(&value.to_le_bytes()); + symtab.extend_from_slice(&size.to_le_bytes()); + } + + let mut shstrtab = vec![0u8]; + let section_name = |shstrtab: &mut Vec, name: &str| -> u32 { + let at = shstrtab.len() as u32; + shstrtab.extend_from_slice(name.as_bytes()); + shstrtab.push(0); + at + }; + let symtab_name = section_name(&mut shstrtab, ".symtab"); + let strtab_name = section_name(&mut shstrtab, ".strtab"); + let shstrtab_name = section_name(&mut shstrtab, ".shstrtab"); + + let symtab_off = out.len() as u64; + out.extend_from_slice(&symtab); + let strtab_off = out.len() as u64; + out.extend_from_slice(&strtab); + let shstrtab_off = out.len() as u64; + out.extend_from_slice(&shstrtab); + let shoff = out.len() as u64; + + let mut section = |name: u32, kind: u32, offset: u64, size: u64, link: u32, entsize: u64| { + let mut hdr = Vec::with_capacity(SHDR); + hdr.extend_from_slice(&name.to_le_bytes()); + hdr.extend_from_slice(&kind.to_le_bytes()); + hdr.extend_from_slice(&0u64.to_le_bytes()); + hdr.extend_from_slice(&0u64.to_le_bytes()); + hdr.extend_from_slice(&offset.to_le_bytes()); + hdr.extend_from_slice(&size.to_le_bytes()); + hdr.extend_from_slice(&link.to_le_bytes()); + hdr.extend_from_slice(&0u32.to_le_bytes()); + hdr.extend_from_slice(&1u64.to_le_bytes()); + hdr.extend_from_slice(&entsize.to_le_bytes()); + out.extend_from_slice(&hdr); + }; + section(0, 0, 0, 0, 0, 0); + section( + symtab_name, + 2, + symtab_off, + symtab.len() as u64, + 2, + SYM as u64, + ); + section(strtab_name, 3, strtab_off, strtab.len() as u64, 0, 0); + section(shstrtab_name, 3, shstrtab_off, shstrtab.len() as u64, 0, 0); + + let total = out.len() as u64; + + let mut header = Vec::with_capacity(EHDR); + header.extend_from_slice(&[0x7f, b'E', b'L', b'F', 2, 1, 1, 0]); + header.extend_from_slice(&[0u8; 8]); + header.extend_from_slice(&3u16.to_le_bytes()); // ET_DYN + header.extend_from_slice(&183u16.to_le_bytes()); // EM_AARCH64 + header.extend_from_slice(&1u32.to_le_bytes()); + header.extend_from_slice(&0u64.to_le_bytes()); + header.extend_from_slice(&(EHDR as u64).to_le_bytes()); + header.extend_from_slice(&shoff.to_le_bytes()); + header.extend_from_slice(&0u32.to_le_bytes()); + header.extend_from_slice(&(EHDR as u16).to_le_bytes()); + header.extend_from_slice(&(PHDR as u16).to_le_bytes()); + header.extend_from_slice(&1u16.to_le_bytes()); + header.extend_from_slice(&(SHDR as u16).to_le_bytes()); + header.extend_from_slice(&4u16.to_le_bytes()); + header.extend_from_slice(&3u16.to_le_bytes()); + out[..EHDR].copy_from_slice(&header); + + let mut phdr = Vec::with_capacity(PHDR); + phdr.extend_from_slice(&1u32.to_le_bytes()); // PT_LOAD + phdr.extend_from_slice(&5u32.to_le_bytes()); // R+X + phdr.extend_from_slice(&0u64.to_le_bytes()); + phdr.extend_from_slice(&0u64.to_le_bytes()); + phdr.extend_from_slice(&0u64.to_le_bytes()); + phdr.extend_from_slice(&total.to_le_bytes()); + phdr.extend_from_slice(&total.to_le_bytes()); + phdr.extend_from_slice(&0x1000u64.to_le_bytes()); + out[EHDR..EHDR + PHDR].copy_from_slice(&phdr); + + out +} + +/// The interpreter this test process can see, as an absolute path. +/// +/// The CLI runs with `PATH=/usr/bin:/bin`, and the adapter host passes that +/// `PATH` through, so a `/usr/bin/env` shebang would resolve against a +/// deliberately bare search path. Baking the interpreter in keeps the fixture +/// independent of what the packaged prefix happens to have on `PATH`. +pub fn interpreter() -> PathBuf { + let path = std::env::var_os("PATH").expect("PATH"); + std::env::split_paths(&path) + .map(|dir| dir.join("python3")) + .find(|candidate| candidate.is_file()) + .expect("a python3 on the test process PATH") +} + +/// A packaged producer that answers correctly and recovers nothing. +/// +/// Every host-selected fact is echoed out of the request rather than restated, +/// because a fixture that restates them is a fixture that can disagree with the +/// host for reasons that have nothing to do with what is under test. +pub fn answering_producer() -> String { + format!( + r#"#!{} +import argparse, json, pathlib + +pathlib.Path("__SPAWN_LOG__").open("a").write("spawn\n") + +DOMAINS = [ + "libraries", "classes", "class_relationships", "functions", + "function_names", "object_pool", "pool_index_space", +] + +p = argparse.ArgumentParser() +p.add_argument("--request", required=True) +p.add_argument("--result", required=True) +p.add_argument("--input-path") +p.add_argument("--libapp-path") +args = p.parse_args() +request = json.loads(pathlib.Path(args.request).read_text()) +code_region = next( + handle for handle in request["inputs"] if handle["region"] == "isolate_instructions" +) + +model = {{ + "model_version": 4, + "producer": request["producer"], + "input": {{ + "identity": request["identity"], + "regions": [ + {{ + "region": handle["region"], + "size": handle["size"], + "sha256": handle["sha256"], + "virtual_address": handle["virtual_address"], + "executable": handle["executable"], + }} + for handle in request["inputs"] + ], + }}, + "compatibility": request["compatibility"], + "capabilities": dict( + {{domain: "unavailable" for domain in DOMAINS}}, functions="partial" + ), + "libraries": [], + "classes": [], + # One unnamed heuristic code range. A model with no functions at all makes + # `decompile` stop before it writes a report, and the report is the point. + "functions": [ + {{ + "id": 1, + "name": None, + "owner": None, + "code": {{ + "start_va": code_region["virtual_address"], + "size": code_region["size"], + }}, + "code_section_va": code_region["virtual_address"], + "provenance": "heuristic", + }} + ], + "object_pool": {{"index_space": "ordinal", "geometry": None, "entries": []}}, + "diagnostics": [ + {{ + "code": ( + "domain_heuristic_only" + if domain == "functions" + else "domain_not_recovered" + ), + "severity": "warning", + "subject": domain, + "message": "the packaging fixture parses nothing", + }} + for domain in DOMAINS + ], + "extensions": {{}}, +}} +pathlib.Path(request["output"]).write_text(json.dumps(model)) +pathlib.Path(args.result).write_text(json.dumps({{ + "protocol_major": 1, + "model_major": 4, + "status": "ok", + "model": request["output"], + "error": None, + "resolved_backend": "internal", + "fallback_reason": None, + "diagnostics": [], +}})) +"#, + interpreter().display() + ) +} + +/// Every containment control the host names, so the assertion cannot silently +/// stop covering one that was added later. +pub const CONTROLS: &[&str] = &[ + "wall_clock_deadline", + "process_group", + "descriptor_isolation", + "image_integrity", + "cpu_seconds", + "file_size", + "address_space", + "process_count", + "descriptors", + "network", + "stdout_bytes", + "stderr_bytes", + "model_bytes", +]; + +/// The same report with the one number in it that is not about this product +/// blanked out. +/// +/// The process budget is the host's own task count plus an allowance, so two +/// runs of the binary see different budgets whenever anything else on the +/// machine starts or exits between them. Comparing two runs is a claim about +/// what the product established, not about how busy the machine was, so the +/// budget's value is replaced and everything else — including whether the +/// budget was established at all — is compared exactly. +pub fn comparable_across_runs(report: &Value) -> Value { + match report { + Value::Object(fields) => Value::Object( + fields + .iter() + .map(|(key, value)| { + if key == "process_count" && value.get("limit").is_some() { + let mut control = value.as_object().expect("a control object").clone(); + control.insert("limit".into(), Value::from("a snapshot of the host")); + return (key.clone(), Value::Object(control)); + } + (key.clone(), comparable_across_runs(value)) + }) + .collect(), + ), + Value::Array(items) => Value::Array(items.iter().map(comparable_across_runs).collect()), + other => other.clone(), + } +} + +pub fn assert_controls_are_accurate(containment: &Value, source: &str) { + let object = containment + .as_object() + .unwrap_or_else(|| panic!("{source} has no containment object: {containment}")); + for control in CONTROLS { + let state = object + .get(*control) + .unwrap_or_else(|| panic!("{source} does not report {control}: {containment}")); + match state["state"].as_str() { + Some("applied") => {} + Some("unavailable") => assert!( + state["reason"] + .as_str() + .is_some_and(|r| !r.trim().is_empty()), + "{source} reports {control} unavailable without a reason: {state}" + ), + other => panic!("{source} reports {control} as {other:?}: {state}"), + } + } + assert!( + object["process_tree_terminated"].is_boolean(), + "{source} does not say whether the host had to end the run: {containment}" + ); + assert_eq!( + object.len(), + CONTROLS.len() + 1, + "{source} reports controls this test does not check: {containment}" + ); + + // Host-side bounds and POSIX controls hold everywhere this crate builds. + for control in [ + "wall_clock_deadline", + "stdout_bytes", + "stderr_bytes", + "model_bytes", + "process_group", + "descriptor_isolation", + "cpu_seconds", + "file_size", + "descriptors", + ] { + assert_eq!( + object[control]["state"], "applied", + "{source} could not establish {control}: {}", + object[control] + ); + } + + if cfg!(target_os = "linux") { + for control in ["address_space", "process_count"] { + assert_eq!( + object[control]["state"], "applied", + "{source} did not establish {control} on linux: {}", + object[control] + ); + } + } else { + for control in ["address_space", "process_count", "network"] { + assert_eq!( + object[control]["state"], "unavailable", + "{source} claimed {control} on a platform that cannot establish it: {}", + object[control] + ); + } + } +} + +/// A producer that never finishes, so the host's deadline is what ends it. +/// +/// It logs the spawn before sleeping, so the timeout case can still prove a +/// child existed. Without that the test could not tell "the deadline killed it" +/// from "nothing ever ran". +pub fn sleeping_producer() -> String { + format!( + r#"#!{} +import pathlib, time + +pathlib.Path("__SPAWN_LOG__").open("a").write("spawn\n") +time.sleep(3600) +"#, + interpreter().display() + ) +} + +/// A producer that reports success and writes a model that is not JSON. +/// +/// The result document is well-formed, so nothing before the model read can +/// catch this: the host has to reject the model itself. +pub fn corrupt_model_producer() -> String { + format!( + r#"#!{} +import argparse, json, pathlib + +pathlib.Path("__SPAWN_LOG__").open("a").write("spawn\n") +p = argparse.ArgumentParser() +p.add_argument("--request", required=True) +p.add_argument("--result", required=True) +p.add_argument("--input-path") +p.add_argument("--libapp-path") +args = p.parse_args() +request = json.loads(pathlib.Path(args.request).read_text()) +pathlib.Path(request["output"]).write_text("{{not json at all") +pathlib.Path(args.result).write_text(json.dumps({{ + "protocol_major": 1, + "model_major": 4, + "status": "ok", + "model": request["output"], + "error": None, + "resolved_backend": "internal", + "fallback_reason": None, + "diagnostics": [], +}})) +"#, + interpreter().display() + ) +} + +/// A producer that answers correctly about a snapshot it was not given. +/// +/// Every other field is echoed from the request, so the only thing wrong with +/// the model is the identity: the host's own fact, restated differently. +pub fn wrong_identity_producer() -> String { + answering_producer().replace( + r#""identity": request["identity"],"#, + r#""identity": dict(request["identity"], hash="ffffffffffffffffffffffffffffffff"),"#, + ) +} diff --git a/crates/flutterdec-core/src/lib.rs b/crates/flutterdec-core/src/lib.rs index ba7673bb..81969a21 100644 --- a/crates/flutterdec-core/src/lib.rs +++ b/crates/flutterdec-core/src/lib.rs @@ -2,13 +2,17 @@ use anyhow::{bail, Context, Result}; use flutterdec_adapter::model::{ - Capabilities, CompatibilityBinding, Domain, InputRegionName, Producer, ProducerTrust, - ProgramModel, + Capabilities, CapabilityLevel, CodeRange, CompatibilityBinding, Diagnostic, DiagnosticCode, + DiagnosticSeverity, Domain, Function, FunctionId, InputRegion, InputRegionName, ObjectPool, + ObservedInput, Producer, ProducerTrust, ProgramModel, Provenance, }; use flutterdec_adapter::primitives::Sha256Digest; use flutterdec_adapter::protocol::{BackendId, FallbackReason, RequestedBackend}; +use flutterdec_adapter::store::{self, EntryState, StoreEntry}; +use flutterdec_adapter::validate; use flutterdec_adapter::{ - list_adapters, resolve_adapter_exec, run_adapter, AdapterInput, AdapterRegionInput, + run_adapter, AdapterInput, AdapterRegionInput, ContainmentReport, HostAuthorization, HostError, + LibappSource, Limits, }; use flutterdec_decompiler::{emit_program_with_runtime_stubs, PseudocodeArtifact}; use flutterdec_disasm_arm64::{ @@ -16,16 +20,20 @@ use flutterdec_disasm_arm64::{ FunctionPriorityBreakdown, HintKind, HintOrigin, HintProvenance, ProgramHints, }; use flutterdec_ir::{build_program_ir, FunctionIr}; -use flutterdec_loader::dart_profile::ResolvedDartProfile; -use flutterdec_loader::identity::ExactSelectionKey; +use flutterdec_loader::dart_profile::{ResolvedDartProfile, SdkAlias}; +use flutterdec_loader::identity::IdentityRejection; +use flutterdec_loader::layout::{Layout, LayoutError}; +use flutterdec_loader::registry::RegistryError; use flutterdec_loader::{ load_snapshot_bundle, load_snapshot_bundle_from_apk_session, ApkSession, SnapshotBundle, }; use serde::Serialize; use serde_json::json; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::fmt; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; #[derive(Debug, Clone)] pub struct DecompileOptions { @@ -53,6 +61,9 @@ pub struct DecompileOptions { pub function_scope: FunctionScope, pub app_packages: Vec, pub adapter_backend: AdapterBackend, + /// Wall-clock deadline for one adapter invocation. `None` keeps the host + /// default. + pub adapter_timeout_seconds: Option, pub require_snapshot_hash_match: bool, pub analysis_profile: DecompileAnalysisProfile, pub engine_options: DecompileEngineOptions, @@ -87,6 +98,8 @@ impl FunctionTarget { pub struct DiffOptions { pub out_dir: PathBuf, pub adapter_backend: AdapterBackend, + /// Wall-clock deadline for one adapter invocation, applied to each side. + pub adapter_timeout_seconds: Option, pub function_scope: FunctionScope, pub app_packages: Vec, pub require_snapshot_hash_match: bool, @@ -218,12 +231,12 @@ pub struct InfoOutput { pub libapp_path: String, pub arch: String, pub snapshot_hash: String, - /// Dart SDK version behind `snapshot_hash`, when the hash is tabulated. + /// Legacy display marker (`unverified` when an alias exists); SDK aliases + /// are provenance only and never compatibility selectors. pub dart_version: Option, - /// Object-header tag encoding for that version (`CID_INT32`, `CID_SHIFT1`, - /// `OBJECT_HEADER`); the layout dimension most likely to break a parser. + pub dart_aliases: Option>, + /// Object-header tag encoding for the selected profile. pub dart_tag_style: Option, - /// Whether the snapshot was built with compressed pointers, read from the /// features string in its header rather than inferred from the code. It /// decides the width of a reference field and the value of `kSmiBits`, so it /// selects which offset tables apply. `None` means the header did not parse @@ -231,7 +244,12 @@ pub struct InfoOutput { pub compressed_pointers: Option, /// The snapshot's features string verbatim, when the header parsed. pub snapshot_features: Option, + /// Whether a verified adapter artifact is installed for the selected record. pub adapter_installed: bool, + /// Who produced the model and under what authorization. Always present: + /// core recovers the program itself when no adapter is authorized, so there + /// is always a provider to describe. + pub provider: Option, /// What the operator asked for. pub requested_backend: Option, /// What actually answered, as the protocol result reported it. @@ -241,16 +259,25 @@ pub struct InfoOutput { pub producer_id: Option, pub producer_trust: Option, pub compatibility_record_sha256: Option, - pub manifest_entry_present: Option, + pub registry_record_present: Option, + /// Which containment controls were established for the adapter child, as + /// the child itself reported them. Absent when no adapter ran. + pub adapter_containment: Option, /// Whether the snapshot identity came out of a real header. Replaces the v3 /// "does the adapter agree about the hash" check, which compared a host fact /// against a string the adapter chose. pub snapshot_identity_is_exact: Option, - /// Why this snapshot may not select an adapter at all, when it may not. - /// `Some` means no manifest was read, no executable was resolved, and no - /// adapter ran; the fields below that describe a run are absent for that - /// reason rather than because a run failed. + /// Why no adapter was selected, when the identity gate refused the snapshot. + /// + /// `Some` means no registry record was selected, no executable was + /// resolved, and no adapter ran. pub identity_rejection: Option, + /// An adapter that was authorized, ran, and failed. Reported rather than + /// swallowed: `info` used to drop this on the floor and print a report that + /// looked like a snapshot with nothing in it. + pub adapter_error: Option, + /// The stable category of `adapter_error`. + pub adapter_error_category: Option, /// Per-domain capability levels the model reported. pub model_capabilities: Option>, pub compatibility_warnings: Option>, @@ -271,6 +298,172 @@ pub struct PackageCount { pub functions: usize, } +/// Everything an operator needs to know about who produced a model. +/// +/// One struct rather than one set of fields per command, because `info`, +/// `decompile` and each side of a `diff` all have to answer the same questions +/// and used to answer them in three different shapes. Every field here is a +/// host fact or a protocol fact; none of it is read out of a filename or a +/// substring of adapter output. +#[derive(Debug, Clone, Serialize)] +pub struct ProviderReport { + /// What the operator asked for. + pub requested_backend: String, + /// What produced the model, as the protocol result named it, or `internal` + /// when core recovered the program itself. + pub resolved_backend: String, + /// Set when a pinned request was answered by something else. Always `false` + /// for `auto`, which pins nothing. + pub backend_mismatch: bool, + /// Why a producer used a backend other than the one it prefers. + pub backend_fallback_reason: Option, + /// Why no adapter was executed at all. `Some` means zero adapter processes + /// existed for this input. + pub core_fallback_reason: Option, + /// The condition behind `core_fallback_reason`, verbatim. + pub core_fallback_detail: Option, + /// The stable sentence explaining what that fallback costs. + pub core_fallback_effect: Option, + pub adapter_executed: bool, + pub adapter_exec_path: Option, + pub producer_id: String, + pub producer_version: String, + pub producer_artifact_sha256: String, + pub producer_trust: String, + pub registry_record_present: bool, + pub compatibility_record_sha256: Option, + pub parser_family_id: Option, + pub profile_id: Option, + pub profile_sha256: Option, + pub artifact_id: Option, + pub artifact_sha256: Option, + /// The machine this ran on, which is not the machine the snapshot targets. + pub host_os: String, + pub host_arch: String, + /// The architecture the snapshot's code was generated for, from the ELF + /// container rather than from adapter output. + pub target_arch: String, + pub snapshot_identity_is_exact: bool, + /// Why the identity may not authorize an adapter, when it may not. + pub identity_rejection: Option, + /// Per-domain capability levels the model reported. + pub capabilities: BTreeMap, + /// Which containment controls were established for the adapter child, as + /// the child itself reported them. Absent when no adapter ran. + pub containment: Option, + pub warnings: Vec, +} + +/// A stable token for what went wrong, for operators matching on outcomes. +/// +/// The message is for humans and may change. This may not: it is the difference +/// between a script retrying a timeout and a script reporting a corrupt store. +pub fn error_category(error: &anyhow::Error) -> &'static str { + for cause in error.chain() { + if let Some(host) = cause.downcast_ref::() { + return host_error_category(host); + } + if let Some(registry) = cause.downcast_ref::() { + return registry_error_category(registry); + } + if let Some(store) = cause.downcast_ref::() { + return store_error_category(store); + } + if let Some(layout) = cause.downcast_ref::() { + return layout_error_category(layout); + } + if cause.downcast_ref::().is_some() { + return "identity_rejected"; + } + } + "unclassified" +} + +fn host_error_category(error: &HostError) -> &'static str { + match error { + HostError::IdentityRejected(_) => "identity_rejected", + HostError::RecordInvalid(_) => "record_invalid", + HostError::RecordDigestMismatch { .. } => "record_digest_mismatch", + HostError::UnsupportedMajors { .. } => "unsupported_majors", + HostError::IdentityRecordMismatch { .. } => "identity_record_mismatch", + HostError::TargetMismatch { .. } => "target_mismatch", + HostError::FeatureMismatch { .. } => "feature_mismatch", + HostError::HostVariantMismatch { .. } => "host_variant_mismatch", + HostError::VariantNotInRecord { .. } => "variant_not_in_record", + HostError::ArtifactPathRejected(_) => "artifact_path_rejected", + HostError::ArtifactNotExecutable(_) => "artifact_not_executable", + HostError::ArtifactDigestMismatch { .. } => "artifact_digest_mismatch", + HostError::NotInstalled { .. } => "adapter_not_installed", + HostError::ProfileRejected(_) => "profile_rejected", + HostError::ProducerMismatch(_) => "producer_mismatch", + HostError::BindingMismatch(_) => "binding_mismatch", + HostError::InputRejected(_) => "input_rejected", + HostError::RequestRejected(_) => "request_rejected", + HostError::OutputHandleRejected(_) => "output_handle_rejected", + HostError::ImageNotSealed(_) => "image_not_sealed", + HostError::Workspace(_) => "workspace_failed", + HostError::Spawn(_) => "spawn_failed", + HostError::Timeout { .. } => "adapter_timeout", + HostError::OutputLimitExceeded { .. } => "adapter_output_limit_exceeded", + HostError::Crashed { .. } => "adapter_crashed", + HostError::NoResult { .. } => "adapter_no_result", + HostError::DocumentTooLarge { .. } => "adapter_document_too_large", + HostError::MalformedDocument { .. } => "adapter_malformed_document", + HostError::ResultMismatch(_) => "adapter_result_mismatch", + HostError::ModelPathMismatch { .. } => "adapter_model_path_mismatch", + HostError::AdapterFailed { .. } => "adapter_reported_failure", + HostError::ModelRejected(_) => "adapter_model_rejected", + HostError::ContainmentUnreported => "containment_unreported", + HostError::Io(_) => "adapter_io", + } +} + +/// Installing and inspecting the store, which fails for its own reasons: the +/// registry authorizes nothing for the hash, the operator's bytes are not the +/// authorized bytes, or a publish step was cut short and left nothing behind. +fn store_error_category(error: &store::StoreError) -> &'static str { + match error { + store::StoreError::InvalidInput(_) => "store_invalid_input", + store::StoreError::NoRecord(_) => "store_no_record", + store::StoreError::Ambiguous(_) => "store_ambiguous", + store::StoreError::Incompatible(_) => "store_incompatible", + store::StoreError::Source(_) => "store_artifact_source_rejected", + store::StoreError::DigestMismatch { .. } => "store_artifact_digest_mismatch", + store::StoreError::Containment(_) => "store_path_rejected", + store::StoreError::Profile(_) => "store_profile_rejected", + store::StoreError::Malformed(_) => "store_state_malformed", + store::StoreError::Io(_) => "store_io", + store::StoreError::Injected(_) => "store_install_interrupted", + } +} + +/// Deciding where the packaged data and the writable store are, which fails +/// before any command runs when the binary was moved out of its prefix. +fn layout_error_category(error: &LayoutError) -> &'static str { + match error { + LayoutError::Executable(_) => "layout_executable_unknown", + LayoutError::Override { .. } => "layout_data_dir_override_invalid", + LayoutError::NoDataDirectory { .. } => "layout_no_data_directory", + LayoutError::NoDataHome { .. } => "layout_no_data_home", + } +} + +fn registry_error_category(error: &RegistryError) -> &'static str { + match error { + RegistryError::Malformed(_) => "registry_malformed", + RegistryError::UnsupportedVersion(_) => "registry_unsupported_version", + RegistryError::Identity(_) => "identity_rejected", + RegistryError::NoRecord(_) => "registry_no_record", + RegistryError::TargetMismatch { .. } => "registry_target_mismatch", + RegistryError::FeatureMismatch { .. } => "registry_feature_mismatch", + RegistryError::Ambiguous(_) => "registry_ambiguous", + RegistryError::InvalidRecord(_) => "registry_invalid_record", + RegistryError::Profile(_) => "registry_profile_rejected", + RegistryError::ArtifactAbsent(_) => "registry_artifact_absent", + RegistryError::Artifact(_) => "registry_artifact_rejected", + } +} + #[derive(Debug, Clone, Serialize)] pub struct QualityReport { pub mode: String, @@ -309,8 +502,22 @@ pub struct DiffReport { pub old_snapshot_hash_match: bool, pub new_snapshot_hash_match: bool, pub require_snapshot_hash_match: bool, - pub old_dart_version: String, - pub new_dart_version: String, + pub old_dart_aliases: Vec, + pub new_dart_aliases: Vec, + /// Who produced each side. Reported per side rather than once, because the + /// two sides are selected independently and a diff between an adapter model + /// and a core-recovered one compares unlike things. + pub old_provider: ProviderReport, + pub new_provider: ProviderReport, + /// Set when the two sides were not produced the same way, which is the one + /// condition that makes the counts below misleading rather than merely + /// incomplete. + pub provider_mismatch: bool, + /// Functions with no name, owner or library on each side. An address alone + /// is not stable across builds, so these are counted and excluded rather + /// than collapsed into one descriptor that reads as "unchanged". + pub old_uncomparable_function_count: usize, + pub new_uncomparable_function_count: usize, pub function_scope: String, pub app_packages: Vec, pub old_function_count: usize, @@ -326,6 +533,7 @@ pub struct DiffReport { } include!("pipeline/helpers.rs"); +include!("pipeline/fallback.rs"); include!("pipeline/model.rs"); include!("pipeline/quality.rs"); include!("pipeline/bootflow_hints.rs"); diff --git a/crates/flutterdec-core/src/pipeline/apk_startup.rs b/crates/flutterdec-core/src/pipeline/apk_startup.rs index fca93373..de5775e2 100644 --- a/crates/flutterdec-core/src/pipeline/apk_startup.rs +++ b/crates/flutterdec-core/src/pipeline/apk_startup.rs @@ -2492,12 +2492,12 @@ mod apk_startup_tests { executable: true, }], }, - compatibility: CompatibilityBinding { + compatibility: Some(CompatibilityBinding { record_sha256: digest.clone(), parser_family_id: "fixture".to_string(), profile_id: "fixture".to_string(), profile_sha256: digest, - }, + }), capabilities: Capabilities { libraries: CapabilityLevel::Partial, classes: CapabilityLevel::Partial, diff --git a/crates/flutterdec-core/src/pipeline/fallback.rs b/crates/flutterdec-core/src/pipeline/fallback.rs new file mode 100644 index 00000000..10e81adf --- /dev/null +++ b/crates/flutterdec-core/src/pipeline/fallback.rs @@ -0,0 +1,378 @@ +// Honest machine-code recovery for snapshots no adapter is authorized to parse. +// +// Nothing here deserializes a snapshot. The only evidence available is ARM64 +// instruction bytes and the region table the loader already established, so the +// only facts this can produce are where code plausibly starts and how far it +// plausibly runs. Every one of those is a guess and is marked `heuristic`. +// +// Everything a snapshot parser would supply -- libraries, classes, the class +// hierarchy, function names, the original entry function, and the ObjectPool +// index space -- stays `unavailable` with a diagnostic naming why. The +// alternative is what v3 did: emit `package:app/main.dart`, `Global`, `main` and +// an ordinal pool, and let every consumer downstream treat them as recovered. + +/// Why core recovered the program itself instead of running an adapter. +/// +/// Closed and typed for the same reason [`FallbackReason`] is: an operator +/// deciding whether to go find a parser needs to know *which* of these it is, +/// and a free-text sentence cannot be matched on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CoreFallbackReason { + /// The operator pinned `--adapter-backend internal`. + InternalRequested, + /// The snapshot identity may not authorize any adapter at all: it is not a + /// FullAOT snapshot, or its hash did not come out of a header. + IdentityRejected, + /// The identity is exact and no compatibility record matches it. + NoCompatibilityRecord, + /// A record exists for the snapshot hash and none of them covers this + /// snapshot: wrong target architecture, or a feature tuple no record was + /// written for. A registry that is malformed, ambiguous, or carries a + /// profile that does not verify is an installation failure, not this. + CompatibilityUnsupported, + /// A record authorizes an adapter and no verified artifact is installed. + AdapterNotInstalled, +} + +impl CoreFallbackReason { + pub fn as_str(self) -> &'static str { + match self { + Self::InternalRequested => "internal_requested", + Self::IdentityRejected => "identity_rejected", + Self::NoCompatibilityRecord => "no_compatibility_record", + Self::CompatibilityUnsupported => "compatibility_unsupported", + Self::AdapterNotInstalled => "adapter_not_installed", + } + } + + /// One sentence an operator can act on, stable per variant. + pub fn detail(self) -> &'static str { + match self { + Self::InternalRequested => { + "the internal backend was requested, so no adapter was selected or executed" + } + Self::IdentityRejected => { + "the snapshot identity may not authorize an adapter, so none was selected or executed" + } + Self::NoCompatibilityRecord => { + "no compatibility record matches this snapshot identity, so no adapter was selected or executed" + } + Self::CompatibilityUnsupported => { + "the compatibility record for this snapshot is not usable on this host, so no adapter was executed" + } + Self::AdapterNotInstalled => { + "no verified adapter artifact is installed for the selected record, so none was executed" + } + } + } +} + +impl fmt::Display for CoreFallbackReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Largest span a single recovered candidate may claim. +/// +/// Two neighbouring starts bound each other; the last one has nothing after it +/// but the end of the region, and a candidate that claims a megabyte of +/// unexamined bytes is not a function candidate, it is the rest of the file. +const MAX_CANDIDATE_SIZE: u64 = 0x8000; + +/// A call target has to be reached this often before it counts as a start on its +/// own. One `bl` into the middle of a function is a tail call or a computed +/// offset as easily as it is an entry point. +const MIN_CALL_TARGET_HITS: usize = 2; + +/// `bl