diff --git a/Cargo.lock b/Cargo.lock index 1909d98..848f35e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,37 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -17,6 +42,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -29,6 +64,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -38,6 +79,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -58,10 +108,18 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + [[package]] name = "flow" version = "0.1.0" dependencies = [ + "base64", + "blake3", "fs2", "nix", "semver", @@ -195,10 +253,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "syn" version = "3.0.5" diff --git a/Cargo.toml b/Cargo.toml index 706f3ac..69953ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ test = false bench = false [dependencies] +base64 = "0.22.1" +blake3 = "1.8.2" fs2 = "0.4.3" semver = "1.0.26" serde = { version = "1.0.219", features = ["derive"] } diff --git a/README.md b/README.md index 411cb84..0f64bd6 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,10 @@ neutral process transcript. Flow can also launch one exact authorized control, and direct-child reaping; observe exact locked package/executable subjects; require exact correlated process authority/isolation evidence; accept explicitly bound artifacts; and validate a closed scenario manifest for -synthetic orchestration fixtures. It does not copy holon source or claim a -product orchestrator, CLI, real provider adapter, operating-system sandbox, +synthetic orchestration fixtures. The first released-provider library adapter +pins [Optiflow v0.1.1 read-only inspection](docs/integrations/optiflow-v0.1.1-read-only.md) +and retains a separate durable attempt receipt. It does not copy holon source +or claim a product orchestrator, public Flow CLI, mutation adapter, operating-system sandbox, general scenario executor, automatic recovery scheduler, or public resume CLI. Prepared process execution now has [durable run state](docs/integrations/durable-state.md) with immutable plans, atomic snapshots, verified checkpoints, status inspection, @@ -175,10 +177,9 @@ FLO-Q03. The process seam still does not implement authenticity verification, operating-system sandbox enforcement, authenticated host evidence, descriptor-bound launch, or process-tree containment, and the scenario contract is not an executor. -Current descriptions of Aniflow, Optiflow, and Renderflow are grounded in their default -branches as inspected on 2026-08-13. The holons remain independently released -repositories; real provider adapters and the restore-and-assess workflow remain -follow-up work. +The holons remain independently released repositories. Optiflow v0.1.1 is +the first pinned real provider adapter; Aniflow and Renderflow adapters and +the restore-and-assess workflow remain follow-up work. ## License diff --git a/ROADMAP.md b/ROADMAP.md index 1e576d5..2f5515b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,12 +3,12 @@ schema: aether.architecture-document/v1 id: flow-roadmap title: Flow Roadmap kind: architecture-document -version: 1.3.5 +version: 1.3.6 status: draft owners: - egohygiene created: 2026-08-13 -updated: 2026-09-26 +updated: 2026-09-27 governed_by: - architecture-roadmap depends_on: @@ -23,6 +23,22 @@ supersedes: [] # Flow Roadmap +## 2026-09-27 Optiflow read-only adapter handoff + +Flow #50 now proposes a pinned Optiflow v0.1.1 native CLI library adapter for +read-only scan, report, and exact-duplicate review planning. The +[compatibility receipt](docs/integrations/optiflow-v0.1.1-read-only.md) +records source revision, archive/executable digests, contract schemas, +declared effects, Linux synthetic proof, and macOS native-execution gap. The +adapter writes versioned local evidence; dry-run, quarantine, restore, and +finalization remain explicitly unsupported. The maintainer owns PR merge. + +After review/merge, Flow #53 may compose this read-only capability into the +suite CLI alongside other released-provider adapters. Flow #73 separately +qualifies a v0.2 mutation release after Optiflow #93; Flow #74 owns the later +PNG integration after Optiflow #95. Older queue snapshots below are historical +where they conflict with this handoff and the live Flow #11 issue. + ## 2026-09-26 live suite handoff > [!IMPORTANT] diff --git a/docs/architecture/foundation/ARCHITECTURE.md b/docs/architecture/foundation/ARCHITECTURE.md index e074155..dc301ae 100644 --- a/docs/architecture/foundation/ARCHITECTURE.md +++ b/docs/architecture/foundation/ARCHITECTURE.md @@ -3,12 +3,12 @@ schema: aether.architecture-document/v1 id: flow-architecture title: Flow Architecture kind: architecture-document -version: 0.13.2 +version: 0.13.3 status: draft owners: - egohygiene created: 2026-08-13 -updated: 2026-09-26 +updated: 2026-09-27 governed_by: - architecture-architecture depends_on: @@ -188,6 +188,26 @@ This runner is not a sandbox. It does not authenticate host evidence, constrain filesystem/network/subprocess authority, contain descendants, or bind the fresh digest observation to the host's later executable file object. +The first released-provider adapter pins the immutable Optiflow v0.1.1 +executable and consumes its native `optiflow.command-result.v1` single JSON +document. It does not translate native stdout into a synthetic Flow JSON Lines +transcript. Its public library API probes the exact release, exposes only +read-only scan, report, and exact-duplicate review-plan capabilities, and +rejects mutation capabilities individually. It clears the child environment, +disables configuration discovery and media probing, constrains traversal, and +checks source root and filesystem identity between steps. Flow independently +verifies each committed provider artifact set, its member bytes and schema +bindings, and the review-only plan safety fields. + +The adapter records a separate `flow.optiflow-read-only-receipt/v1` local +attempt before launch and after scan, report, and plan. A reopened intermediate +attempt requires review; a completed receipt must still match its retained +artifact bytes. This receipt is local audit evidence, not an execution grant, +checkpoint in the generic durable graph, provider signature, sandbox claim, or +approval. The existing Flow JSONL runner and durable graph contracts remain +unchanged. ADR-0013 owns this native-protocol exception and the staged handoff +to later composition. + ### Extension lifecycle Flow coordinates extensions through the ordered lifecycle `discover → inspect → diff --git a/docs/architecture/governance/DECISIONS.md b/docs/architecture/governance/DECISIONS.md index 2b4b98f..96e76e2 100644 --- a/docs/architecture/governance/DECISIONS.md +++ b/docs/architecture/governance/DECISIONS.md @@ -3,12 +3,12 @@ schema: aether.architecture-document/v1 id: flow-decisions title: Flow Decisions kind: architecture-document -version: 0.10.0 +version: 0.10.1 status: draft owners: - egohygiene created: 2026-08-13 -updated: 2026-09-26 +updated: 2026-09-27 governed_by: - architecture-decisions depends_on: @@ -66,12 +66,14 @@ justifies separate ADRs. | [ADR-0010](decisions/ADR-0010-bounded-direct-process-supervision.md) | Bound direct provider launch and supervision | Accepted | 2026-09-21 | None | Sandbox enforcement, descriptor-bound launch, process-tree containment, or durable interruption changes the runner boundary | | [ADR-0011](decisions/ADR-0011-durable-run-state.md) | Persist prepared intent and acceptance in immutable local snapshots | Proposed | Pending review | None | Migration, distributed writers, automatic recovery, authenticated state, or stronger durability changes the boundary | | [ADR-0012](decisions/ADR-0012-fresh-graph-assessment.md) | Require fresh prerequisite evidence for durable graph execution | Proposed | Pending review | None | Scheduling, cross-plan reuse, or concurrent artifact mutation changes the assessment boundary | +| [ADR-0013](decisions/ADR-0013-optiflow-native-read-only-adapter.md) | Pin Optiflow v0.1.1 behind a native read-only adapter | Proposed | Pending review | None | A released mutation contract, native protocol revision, or generic graph composition changes this boundary | ## Active decisions ADR-0011 is proposed with Flow #49 and remains subject to maintainer review. Its implementation merged in PR #63. ADR-0012 is proposed with Flow #64, the -first bounded checkpoint under #31. +first bounded checkpoint under #31. ADR-0013 is proposed with Flow #50 for +the first independently verified released-provider integration. The indexed ADRs are authoritative. Summaries in other documents must link back to them rather than recreate rationale. diff --git a/docs/architecture/governance/decisions/ADR-0013-optiflow-native-read-only-adapter.md b/docs/architecture/governance/decisions/ADR-0013-optiflow-native-read-only-adapter.md new file mode 100644 index 0000000..b9d3c61 --- /dev/null +++ b/docs/architecture/governance/decisions/ADR-0013-optiflow-native-read-only-adapter.md @@ -0,0 +1,72 @@ +--- +schema: aether.architecture-document/v1 +id: flow-adr-0013 +title: Pin Optiflow v0.1.1 behind a native read-only adapter +kind: architecture-document +version: 0.1.0 +status: proposed +owners: + - egohygiene +created: 2026-09-27 +updated: 2026-09-27 +governed_by: + - architecture-decisions +depends_on: + - flow-architecture +related: + - flow-decisions + - flow-roadmap +supersedes: [] +--- + +# ADR-0013: Pin Optiflow v0.1.1 behind a native read-only adapter + +## Context + +Flow's generic process contract uses a Flow JSON Lines invocation, event, and +result protocol. Immutable Optiflow v0.1.1 exposes a different released CLI: +direct argv and one `optiflow.command-result.v1` stdout document with +provider-owned committed artifact sets. An invented JSONL transcript would +confuse provider evidence with Flow's own validation claims. The release +qualifies read-only scan, report, and review planning. Later mutation code is +not part of that immutable release. + +## Decision + +The initial adapter is a public Flow library API with a strict native-protocol +translator. It pins exact target-specific release archives and executable +digests, probes `--version`, launches the binary with bounded output/time and +literal argv, and validates exit, coverage, native schema, source identity, +artifact-set marker, member digests, and plan safety. It records a separate +versioned local receipt before and after each step; partial coverage remains +partial. Dry-run, quarantine, restore, and finalization are explicit +unsupported capabilities. No review plan grants mutation authority. + +This local receipt does not enter `flow.run-state/v1` as an accepted generic +checkpoint. The provider's native CLI cannot satisfy the existing generic +Flow process invocation contract without a separate wrapper and authority +mapping. The suite CLI and cross-provider graph composition will consume the +read-only adapter in later Flow work. A mutation adapter requires a separately +qualified release and a new authority/recovery contract. + +## Consequences and review triggers + +The adapter writes only local provider and Flow evidence outside the selected +source root. It does not sandbox the trusted pinned binary or prove a +race-free executable open. Root identity is rechecked between commands; +individual source observations are the provider's read-only evidence, not +apply-time authorization. An interrupted receipt requires inspection and no +artifact file alone implies Flow completion. Receipt integrity is local +content verification, not an authenticated signature. + +Review this decision when a released mutation protocol is qualified, when the +native CLI contract changes, or when Flow's generic graph accepts a +provider-native adapter through an explicitly versioned authority boundary. + +## Validation + +The synthetic release fixture calls the public Flow API, verifies source bytes +remain unchanged, checks the three committed artifact sets and receipt reopen, +and exercises release mismatch, path overlap, escape, interruption, and +post-run artifact corruption refusals. macOS binaries are pinned by digest but +need native execution verification. diff --git a/docs/integrations/README.md b/docs/integrations/README.md index 637453b..e7cff82 100644 --- a/docs/integrations/README.md +++ b/docs/integrations/README.md @@ -7,6 +7,8 @@ implementations. directions. - [Capability matrix](capability-matrix.md) records the evidence baseline and gates future adapter claims. +- [Optiflow v0.1.1 read-only adapter](optiflow-v0.1.1-read-only.md) pins the + immutable native CLI, effects, receipt, clean-room fixture, and refusal scope. - [First slice](first-slice-restore-and-assess.md) bounds the initial executable orchestration outcome. - [Federated extension contract](extension-contract.md) defines extension @@ -46,6 +48,8 @@ and [ADR-0009](../architecture/governance/decisions/ADR-0009-process-authority-isolation.md). The bounded runner is governed by [ADR-0010](../architecture/governance/decisions/ADR-0010-bounded-direct-process-supervision.md). +The native Optiflow adapter is proposed under +[ADR-0013](../architecture/governance/decisions/ADR-0013-optiflow-native-read-only-adapter.md). ## Implementation status diff --git a/docs/integrations/capability-matrix.md b/docs/integrations/capability-matrix.md index 01f20a1..c801b97 100644 --- a/docs/integrations/capability-matrix.md +++ b/docs/integrations/capability-matrix.md @@ -7,7 +7,7 @@ Implementation work must reinspect named provider releases and replace each | Provider snapshot | Observed domain capability | Current integration evidence | First-slice role | Adapter decision | | --- | --- | --- | --- | --- | | Aniflow `20783d7374298e5fd44c782348a3c944c9318656` / package v0.2.0 | temporal inspection, decomposition, ordered processing, reconstruction, validation | single-crate behavior documented on 2026-08-13; final public library contract not yet proven | create and validate a new temporal master | pending release reinspection; library if stable, otherwise CLI | -| Optiflow v0.1.0 snapshot documented on 2026-08-13 | read-only collection discovery, identity/relationship evidence, reporting | mutation is explicitly outside the v0.1 safety boundary | scan source before processing; rescan source plus output | pending release reinspection; prefer structured read-only CLI if library is not stable | +| Optiflow immutable v0.1.1 (`b82599a2231e997d42fd9f26f4b59587f4ae14cf`) | scan, report, exact-duplicate review plan; dry-run/quarantine/restore/finalize unsupported | [release adapter and synthetic Linux fixture](optiflow-v0.1.1-read-only.md) pin binary/contract/schema/effects; macOS archive digests pinned but native run pending | read-only source inventory and later rescan | native `optiflow.command-result.v1` direct CLI adapter; Flow-owned receipt, no mutation authority | | Renderflow v0.2.1 snapshot documented on 2026-08-13 | transform DAGs, documents, image/audio conversion, plugins and build caching | existing core/CLI/plugin-SDK split; suite result compatibility unproven | none | defer until restore-and-assess passes | An adapter is compatible only when a fixture records the provider version, diff --git a/docs/integrations/optiflow-v0.1.1-read-only.md b/docs/integrations/optiflow-v0.1.1-read-only.md new file mode 100644 index 0000000..650b503 --- /dev/null +++ b/docs/integrations/optiflow-v0.1.1-read-only.md @@ -0,0 +1,115 @@ +# Optiflow v0.1.1 read-only release adapter + +Flow #50 pins the independently verified, signed Optiflow v0.1.1 release +(`b82599a2231e997d42fd9f26f4b59587f4ae14cf`). Use the +[`OptiflowReadOnlyAdapter`](../../src/optiflow.rs) public library API with +the exact executable extracted from the release bundle. The adapter never +downloads or searches `PATH` at runtime. + +## Release lock + +The release's `binary-v0.1.1.tar.gz` bundle has SHA-256 +`f576131f2695a218fabfaa5167fd16f17641c267194254a9e624303a8cf23e07`. +The target archives and extracted executables are pinned separately: + +| Target | Archive SHA-256 | Executable SHA-256 | Local execution | +| --- | --- | --- | --- | +| `x86_64-unknown-linux-gnu` | `5e8a0eea84f5ab55fe75fc8a8abc2e9d36e3512537b754262b1f34a9c40aae31` | `46af9399f3785607f835d9a805a8daa42c93597772c6ee812911615ea4b76280` | synthetic fixture passed on Linux/Rust 1.85 | +| `aarch64-apple-darwin` | `f83fa18a98e99198e535e37dd93e1584024985df4960f2defd8124d52e656ab1` | `2a2411dbbafd5c22a2b398f13eaf4f0be84e5dedb8f4c9d80f7df418522c6ca3` | archive inspected; native run pending | +| `x86_64-apple-darwin` | `71f1cc9be7ec7d3373807e012114bb64aba62f550fd9da6b4ddccda62397c694` | `a3097b5060ae05a361c97dd004857e7152a019f3f11dc0459b993183a6cf2135` | archive inspected; native run pending | + +Verify the bundle and target archive SHA-256 before extraction. `probe` +checks a regular absolute executable's exact target digest and a bounded +`optiflow 0.1.1` version response. The release evidence identifies the +source revision above; digest matching is byte identity, not a new signature +verification performed by this adapter. + +## Interface and effects + +| Capability | v0.1.1 state | Source effect | Local evidence effect | +| --- | --- | --- | --- | +| Scan | available read-only | recursive read and hashing within one root and filesystem | Optiflow state, run, report, policy, artifact-set marker | +| Report | available read-only | reads committed provider state | re-verifies report set; Flow receipt | +| Plan exact duplicates | available read-only | review suggestions only | immutable review plan and marker; Flow receipt | +| Dry-run | unsupported by pinned release | none | typed refusal | +| Quarantine | unsupported by pinned release | none | typed refusal | +| Restore | unsupported by pinned release | none | typed refusal | +| Finalize | unsupported by pinned release | none | typed refusal | + +The direct child uses a cleared environment, `--no-config`, `--no-probe`, +`--no-follow-symlinks`, and `--stay-on-filesystem`. No network, media +probe, source mutation, deletion, approval, or physical savings is claimed. +Its trusted executable is not an OS sandbox; host code can still reach +resources the OS permits. The selected evidence directory must be disjoint +from the source. The child has a 120-second scan deadline and 30-second +report/plan deadline, 16 MiB stdout, 64 KiB stderr, and 16 MiB per artifact +read limits. A larger run refuses; callers can select a smaller source. + +The native interface is `optiflow.command-result.v1`, exit classes +`success` (0), `partial_success` (3), `stale_state` (5), and other +provider-declared failures. Accepted domain schemas are +`optiflow.run.v5`, `optiflow.report.v6`, `optiflow.plan.v5`, +`optiflow.effective-policy.v1`, and `optiflow.artifact-set.v1`. +Flow checks outcome against actual exit, requires explicit complete or +partial coverage, re-hashes committed marker members, verifies native path +containment and source bindings, and retains typed provider diagnostics. +Human text is never interpreted as machine output. + +## Library use + +```rust,no_run +use std::path::Path; +use flow::optiflow::{OptiflowReadOnlyAdapter, ReadOnlyReceipt}; + +# fn main() -> Result<(), flow::optiflow::AdapterError> { +let adapter = OptiflowReadOnlyAdapter::probe( + Path::new("/absolute/path/to/verified/optiflow") +)?; +let (receipt, receipt_path) = adapter.inspect( + Path::new("/selected/source"), + Path::new("/separate/local/optiflow-state"), +)?; +assert!(receipt.plan_sha256.is_some()); +let reopened = ReadOnlyReceipt::load(&receipt_path)?; +assert!(!reopened.needs_recovery()); +# Ok(()) +# } +``` + +The public API completes scan → report → review plan sequentially. It +records `flow.optiflow-read-only-receipt/v1` at +`/flow-optiflow-v0.1.1/.json` before launch and after +each validated transition. The [receipt schema](../../schemas/optiflow-read-only-receipt-v1.schema.json) +includes target digest, exact tagged argv, cleared-environment assumptions, +source root filesystem and file IDs, run and set IDs, provider diagnostics, +member sizes and SHA-256/BLAKE3 digests, plan digest, and coverage. Approval +and physical savings are null; duplicate logical bytes are a separate +observation. Native plan `keep_path` is a suggestion, never an authority +record. + +If a process or host stops between transitions, reopen the receipt. Scanning, +reporting, or planning status requires operator inspection of local provider +state; no output file alone marks a Flow attempt complete. A timeout or local +I/O uncertainty is also marked for inspection. Reopening checks recorded +artifact bytes again and refuses changed evidence. An explicit new invocation +can start a fresh attempt after review; the adapter never automatically +promotes or retries an uncertain attempt. The local receipt and state are +trusted local audit data, not cryptographic authentication. + +## Reproducing the clean-room fixture + +From a fresh Flow checkout with Rust 1.85, verify and extract the official +release archive, then run: + +```bash +FLOW_OPTIFLOW_V011_EXECUTABLE="/absolute/path/to/release/optiflow" \ + cargo test --locked --test optiflow_read_only +``` + +The test creates only disposable text files with duplicates and Unicode +paths, checks no source byte change, and probes refusal and interrupted +evidence. It never scans personal media. Without the variable, the local +release execution cases are skipped while the executable mismatch test +still runs. The generic Flow JSON Lines runner and durable graph contracts +remain separate. Flow #53 will compose released adapters into the suite +CLI; Flow #73 handles the separately qualified mutation release. diff --git a/schemas/optiflow-read-only-receipt-v1.schema.json b/schemas/optiflow-read-only-receipt-v1.schema.json new file mode 100644 index 0000000..5e04a0e --- /dev/null +++ b/schemas/optiflow-read-only-receipt-v1.schema.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/egohygiene/flow/schemas/optiflow-read-only-receipt-v1.schema.json", + "title": "Flow Optiflow v0.1.1 read-only attempt receipt", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "attempt_id", "status", "provider", "source", "state_directory", "environment", "capabilities", "commands", "source_run_id", "source_set_id", "plan_id", "plan_sha256", "approval", "recovery", "duplicate_logical_bytes", "physical_savings_bytes", "failure"], + "properties": { + "schema_version": { "const": "flow.optiflow-read-only-receipt/v1" }, + "attempt_id": { "type": "string", "minLength": 1 }, + "status": { "enum": ["scanning", "reporting", "planning", "complete", "partial", "refused"] }, + "provider": { + "type": "object", "additionalProperties": false, + "required": ["version", "source_revision", "target", "archive_sha256", "executable_sha256", "interface"], + "properties": { + "version": { "const": "0.1.1" }, + "source_revision": { "const": "b82599a2231e997d42fd9f26f4b59587f4ae14cf" }, + "target": { "enum": ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin", "x86_64-apple-darwin"] }, + "archive_sha256": { "$ref": "#/$defs/sha256" }, + "executable_sha256": { "$ref": "#/$defs/sha256" }, + "interface": { "const": "optiflow.command-result.v1" } + } + }, + "source": { + "type": "object", "additionalProperties": false, + "required": ["root", "filesystem_id", "file_id"], + "properties": { + "root": { "$ref": "#/$defs/native_path" }, + "filesystem_id": { "type": "integer", "minimum": 0 }, + "file_id": { "type": "integer", "minimum": 0 } + } + }, + "state_directory": { "$ref": "#/$defs/native_path" }, + "environment": { "const": "cleared; no config; no media probe; no network grant" }, + "capabilities": { + "type": "object", "additionalProperties": false, + "required": ["scan", "report", "plan-exact-duplicates", "dry-run", "quarantine", "restore", "finalize"], + "properties": { + "scan": { "const": "available-read-only" }, + "report": { "const": "available-read-only" }, + "plan-exact-duplicates": { "const": "available-read-only" }, + "dry-run": { "const": "unsupported-by-pinned-release" }, + "quarantine": { "const": "unsupported-by-pinned-release" }, + "restore": { "const": "unsupported-by-pinned-release" }, + "finalize": { "const": "unsupported-by-pinned-release" } + } + }, + "commands": { "type": "array", "maxItems": 3, "items": { "$ref": "#/$defs/command" } }, + "source_run_id": { "$ref": "#/$defs/nullable_id" }, + "source_set_id": { "$ref": "#/$defs/nullable_id" }, + "plan_id": { "$ref": "#/$defs/nullable_id" }, + "plan_sha256": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] }, + "approval": { "type": "null" }, + "recovery": { "enum": ["required-if-interrupted", "inspect-provider-state-before-retry", "no-source-mutation", "none"] }, + "duplicate_logical_bytes": { "type": ["integer", "null"], "minimum": 0 }, + "physical_savings_bytes": { "type": "null" }, + "failure": { + "anyOf": [ + { "type": "null" }, + { + "type": "object", "additionalProperties": false, + "required": ["code", "detail", "provider_diagnostics", "provider_stdout_sha256"], + "properties": { + "code": { "type": "string", "minLength": 1 }, + "detail": { "type": "string", "minLength": 1 }, + "provider_diagnostics": { "type": "array", "items": { "type": "object" } }, + "provider_stdout_sha256": { "anyOf": [{ "$ref": "#/$defs/sha256" }, { "type": "null" }] } + } + } + ] + } + }, + "$defs": { + "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "nullable_id": { "type": ["string", "null"] }, + "native_path": { + "oneOf": [ + { + "type": "object", "additionalProperties": false, "required": ["encoding", "value"], + "properties": { "encoding": { "const": "utf8" }, "value": { "type": "string" } } + }, + { + "type": "object", "additionalProperties": false, "required": ["encoding", "base64"], + "properties": { "encoding": { "const": "unix_bytes" }, "base64": { "type": "string" } } + } + ] + }, + "artifact": { + "type": "object", "additionalProperties": false, + "required": ["kind", "schema", "path", "size_bytes", "sha256", "blake3_256"], + "properties": { + "kind": { "type": "string", "minLength": 1 }, + "schema": { "type": "string", "minLength": 1 }, + "path": { "$ref": "#/$defs/native_path" }, + "size_bytes": { "type": "integer", "minimum": 0 }, + "sha256": { "$ref": "#/$defs/sha256" }, + "blake3_256": { "$ref": "#/$defs/sha256" } + } + }, + "command": { + "type": "object", "additionalProperties": false, + "required": ["command", "arguments", "outcome", "coverage", "stdout_sha256", "diagnostics", "set_id", "marker_sha256", "artifacts"], + "properties": { + "command": { "enum": ["scan", "report", "plan"] }, + "arguments": { "type": "array", "items": { "$ref": "#/$defs/native_path" } }, + "outcome": { "enum": ["success", "partial_success"] }, + "coverage": { "enum": ["complete", "partial"] }, + "stdout_sha256": { "$ref": "#/$defs/sha256" }, + "diagnostics": { "type": "array", "items": { "type": "object" } }, + "set_id": { "type": "string", "minLength": 1 }, + "marker_sha256": { "$ref": "#/$defs/sha256" }, + "artifacts": { "type": "array", "minItems": 2, "maxItems": 4, "items": { "$ref": "#/$defs/artifact" } } + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 5fc2def..25fe357 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,7 @@ pub mod contracts; pub mod execution; pub mod execution_subjects; pub mod hermetic; +pub mod optiflow; pub mod process; pub mod runner; pub mod scenario; diff --git a/src/optiflow.rs b/src/optiflow.rs new file mode 100644 index 0000000..54ab40e --- /dev/null +++ b/src/optiflow.rs @@ -0,0 +1,1366 @@ +//! Optiflow v0.1.1 read-only CLI adapter. The native command-result protocol is +//! separate from Flow's extension JSONL protocol; this adapter validates it +//! before recording a Flow-owned receipt. A receipt is evidence, not authority. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use base64::Engine; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +pub const RECEIPT_SCHEMA: &str = "flow.optiflow-read-only-receipt/v1"; +pub const PROVIDER_VERSION: &str = "0.1.1"; +pub const PROVIDER_SOURCE_REVISION: &str = "b82599a2231e997d42fd9f26f4b59587f4ae14cf"; +pub const COMMAND_RESULT_SCHEMA: &str = "optiflow.command-result.v1"; +pub const RUN_SCHEMA: &str = "optiflow.run.v5"; +pub const REPORT_SCHEMA: &str = "optiflow.report.v6"; +pub const PLAN_SCHEMA: &str = "optiflow.plan.v5"; +pub const ARTIFACT_SET_SCHEMA: &str = "optiflow.artifact-set.v1"; +const POLICY_SCHEMA: &str = "optiflow.effective-policy.v1"; +const MAX_STDOUT: usize = 16 * 1024 * 1024; +const MAX_STDERR: usize = 64 * 1024; +const MAX_ARTIFACT: u64 = 16 * 1024 * 1024; +const SCAN_DEADLINE: Duration = Duration::from_secs(120); +const SHORT_DEADLINE: Duration = Duration::from_secs(30); + +/// The pinned release archives and their executable digests. The Linux +/// executable was exercised locally; the two macOS archives were inspected and +/// hashed, but still require native execution validation. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ReleaseProfile { + pub target: &'static str, + pub archive_sha256: &'static str, + pub executable_sha256: &'static str, +} + +pub const RELEASE_PROFILES: [ReleaseProfile; 3] = [ + ReleaseProfile { + target: "x86_64-unknown-linux-gnu", + archive_sha256: "5e8a0eea84f5ab55fe75fc8a8abc2e9d36e3512537b754262b1f34a9c40aae31", + executable_sha256: "46af9399f3785607f835d9a805a8daa42c93597772c6ee812911615ea4b76280", + }, + ReleaseProfile { + target: "aarch64-apple-darwin", + archive_sha256: "f83fa18a98e99198e535e37dd93e1584024985df4960f2defd8124d52e656ab1", + executable_sha256: "2a2411dbbafd5c22a2b398f13eaf4f0be84e5dedb8f4c9d80f7df418522c6ca3", + }, + ReleaseProfile { + target: "x86_64-apple-darwin", + archive_sha256: "71f1cc9be7ec7d3373807e012114bb64aba62f550fd9da6b4ddccda62397c694", + executable_sha256: "a3097b5060ae05a361c97dd004857e7152a019f3f11dc0459b993183a6cf2135", + }, +]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Capability { + Scan, + Report, + PlanExactDuplicates, + DryRun, + Quarantine, + Restore, + Finalize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CapabilityState { + AvailableReadOnly, + UnsupportedByPinnedRelease, +} + +impl Capability { + #[must_use] + pub const fn state(self) -> CapabilityState { + match self { + Self::Scan | Self::Report | Self::PlanExactDuplicates => { + CapabilityState::AvailableReadOnly + } + Self::DryRun | Self::Quarantine | Self::Restore | Self::Finalize => { + CapabilityState::UnsupportedByPinnedRelease + } + } + } +} + +#[derive(Debug, Error)] +pub enum AdapterError { + #[error("pinned Optiflow executable is unavailable")] + Unavailable, + #[error("Optiflow executable, version, or host target does not match v0.1.1")] + Incompatible, + #[error("{0:?} is unsupported by pinned Optiflow v0.1.1")] + UnsupportedCapability(Capability), + #[error("source root changed identity or filesystem")] + SourceChanged, + #[error("source root and local evidence directory overlap, or an artifact escapes it")] + PathEscape, + #[error("Optiflow returned stale state")] + StalePlan { + diagnostics: Vec, + stdout_sha256: String, + }, + #[error("Optiflow reported {class} for {command}")] + ProviderFailure { + command: String, + class: String, + diagnostics: Vec, + stdout_sha256: String, + }, + #[error("provider output or committed artifact is incompatible: {0}")] + InvalidEvidence(&'static str), + #[error("provider exceeded its {0} bound")] + Limit(&'static str), + #[error("provider exceeded its deadline")] + Timeout, + #[error("local evidence I/O failed: {0}")] + Io(#[from] io::Error), + #[error("local evidence serialization failed: {0}")] + Json(#[from] serde_json::Error), +} + +impl AdapterError { + #[must_use] + pub const fn code(&self) -> &'static str { + match self { + Self::Unavailable => "provider_unavailable", + Self::Incompatible => "provider_incompatible", + Self::UnsupportedCapability(_) => "capability_unsupported", + Self::SourceChanged => "source_identity_changed", + Self::PathEscape => "path_escape", + Self::StalePlan { .. } => "stale_plan", + Self::ProviderFailure { .. } => "provider_failure", + Self::InvalidEvidence(_) => "invalid_provider_evidence", + Self::Limit(_) => "limit_exceeded", + Self::Timeout => "provider_timeout", + Self::Io(_) | Self::Json(_) => "local_evidence_failure", + } + } +} + +/// Lossless provider path. Non-UTF-8 paths retain their original Unix bytes. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "encoding", rename_all = "snake_case")] +pub enum NativePath { + Utf8 { value: String }, + UnixBytes { base64: String }, +} + +impl NativePath { + fn path(&self) -> Result { + match self { + Self::Utf8 { value } => Ok(PathBuf::from(value)), + Self::UnixBytes { base64 } => { + let bytes = base64::engine::general_purpose::STANDARD + .decode(base64) + .map_err(|_| AdapterError::InvalidEvidence("invalid native path bytes"))?; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + Ok(PathBuf::from(OsString::from_vec(bytes))) + } + #[cfg(not(unix))] + { + let _ = bytes; + Err(AdapterError::Incompatible) + } + } + } + } + + fn from_path(path: &Path) -> Self { + if let Some(value) = path.to_str() { + return Self::Utf8 { + value: value.to_owned(), + }; + } + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + Self::UnixBytes { + base64: base64::engine::general_purpose::STANDARD + .encode(path.as_os_str().as_bytes()), + } + } + #[cfg(not(unix))] + { + unreachable!("supported release profiles are Unix-only") + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SourceIdentity { + pub root: NativePath, + pub filesystem_id: u64, + pub file_id: u64, +} + +impl SourceIdentity { + fn observe(root: &Path) -> Result { + if fs::symlink_metadata(root)?.file_type().is_symlink() { + return Err(AdapterError::PathEscape); + } + let canonical = fs::canonicalize(root)?; + let meta = fs::metadata(&canonical)?; + if !meta.is_dir() { + return Err(AdapterError::InvalidEvidence( + "source root must be a directory", + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + Ok(Self { + root: NativePath::from_path(&canonical), + filesystem_id: meta.dev(), + file_id: meta.ino(), + }) + } + #[cfg(not(unix))] + { + let _ = meta; + Err(AdapterError::Incompatible) + } + } + + fn require_current(&self) -> Result<(), AdapterError> { + let current = Self::observe(&self.root.path()?).map_err(|_| AdapterError::SourceChanged)?; + if current == *self { + Ok(()) + } else { + Err(AdapterError::SourceChanged) + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProviderIdentity { + pub version: String, + pub source_revision: String, + pub target: String, + pub archive_sha256: String, + pub executable_sha256: String, + pub interface: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ReceiptStatus { + Scanning, + Reporting, + Planning, + Complete, + Partial, + Refused, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ArtifactEvidence { + pub kind: String, + pub schema: String, + pub path: NativePath, + pub size_bytes: u64, + pub sha256: String, + pub blake3_256: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CommandEvidence { + pub command: String, + pub arguments: Vec, + pub outcome: String, + pub coverage: String, + pub stdout_sha256: String, + pub diagnostics: Vec, + pub set_id: String, + pub marker_sha256: String, + pub artifacts: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FailureEvidence { + pub code: String, + pub detail: String, + pub provider_diagnostics: Vec, + pub provider_stdout_sha256: Option, +} + +/// A local, durable attempt record. An intermediate status after reopen is +/// recovery-required. No record conveys approval or source mutation authority. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReadOnlyReceipt { + pub schema_version: String, + pub attempt_id: String, + pub status: ReceiptStatus, + pub provider: ProviderIdentity, + pub source: SourceIdentity, + pub state_directory: NativePath, + pub environment: String, + pub capabilities: BTreeMap, + pub commands: Vec, + pub source_run_id: Option, + pub source_set_id: Option, + pub plan_id: Option, + pub plan_sha256: Option, + pub approval: Option, + pub recovery: String, + pub duplicate_logical_bytes: Option, + pub physical_savings_bytes: Option, + pub failure: Option, +} + +impl ReadOnlyReceipt { + /// Reopen evidence without promoting an unfinished attempt to completion. + /// # Errors + /// Rejects invalid JSON, a different receipt schema, or changed evidence + /// files. A receipt and its hashes are local audit evidence, not a signature. + pub fn load(path: &Path) -> Result { + let receipt: Self = serde_json::from_slice(&read_regular(path, MAX_ARTIFACT)?)?; + if receipt.schema_version != RECEIPT_SCHEMA { + return Err(AdapterError::InvalidEvidence("receipt schema")); + } + let profile = RELEASE_PROFILES + .iter() + .find(|profile| profile.target == receipt.provider.target) + .ok_or(AdapterError::InvalidEvidence("receipt target"))?; + if receipt.provider.version != PROVIDER_VERSION + || receipt.provider.source_revision != PROVIDER_SOURCE_REVISION + || receipt.provider.interface != COMMAND_RESULT_SCHEMA + || receipt.provider.archive_sha256 != profile.archive_sha256 + || receipt.provider.executable_sha256 != profile.executable_sha256 + { + return Err(AdapterError::InvalidEvidence("receipt provider identity")); + } + let names = receipt + .commands + .iter() + .map(|command| command.command.as_str()) + .collect::>(); + let partial = receipt + .commands + .iter() + .any(|command| command.coverage == "partial"); + let consistent = match receipt.status { + ReceiptStatus::Scanning => names.is_empty(), + ReceiptStatus::Reporting => names == ["scan"], + ReceiptStatus::Planning => names == ["scan", "report"], + ReceiptStatus::Complete | ReceiptStatus::Partial => { + names == ["scan", "report", "plan"] + && receipt.source_run_id.is_some() + && receipt.source_set_id.is_some() + && receipt.plan_id.is_some() + && receipt.plan_sha256.as_deref() + == receipt.commands[2] + .artifacts + .iter() + .find(|artifact| artifact.kind == "plan") + .map(|artifact| artifact.sha256.as_str()) + && receipt.failure.is_none() + && receipt.recovery == "none" + && partial == matches!(receipt.status, ReceiptStatus::Partial) + } + ReceiptStatus::Refused => receipt.failure.is_some(), + }; + if !consistent || receipt.approval.is_some() || receipt.physical_savings_bytes.is_some() { + return Err(AdapterError::InvalidEvidence("receipt state or authority")); + } + for (key, capability) in [ + ("scan", Capability::Scan), + ("report", Capability::Report), + ("plan-exact-duplicates", Capability::PlanExactDuplicates), + ("dry-run", Capability::DryRun), + ("quarantine", Capability::Quarantine), + ("restore", Capability::Restore), + ("finalize", Capability::Finalize), + ] { + if receipt.capabilities.get(key) != Some(&capability.state()) { + return Err(AdapterError::InvalidEvidence("receipt capability state")); + } + } + let state = receipt.state_directory.path()?; + for command in &receipt.commands { + for artifact in &command.artifacts { + let path = artifact.path.path()?; + if !path.starts_with(&state) + || path + .components() + .any(|part| matches!(part, Component::ParentDir | Component::CurDir)) + || fs::canonicalize(&path)? != path + { + return Err(AdapterError::PathEscape); + } + let bytes = read_regular(&path, MAX_ARTIFACT)?; + if bytes.len() as u64 != artifact.size_bytes + || sha256(&bytes) != artifact.sha256 + || blake3::hash(&bytes).to_hex().as_str() != artifact.blake3_256 + { + return Err(AdapterError::InvalidEvidence("receipt artifact changed")); + } + } + } + Ok(receipt) + } + + #[must_use] + pub fn needs_recovery(&self) -> bool { + matches!( + self.status, + ReceiptStatus::Scanning | ReceiptStatus::Reporting | ReceiptStatus::Planning + ) || self.recovery == "inspect-provider-state-before-retry" + } +} + +/// A pinned native CLI, with exactly three read-only capabilities. +pub struct OptiflowReadOnlyAdapter { + executable: PathBuf, + identity: ProviderIdentity, +} + +impl OptiflowReadOnlyAdapter { + /// Check the host target, executable bytes, and bounded `--version` output. + /// # Errors + /// Refuses missing, different, or unsupported executables. + pub fn probe(executable: &Path) -> Result { + let target = match (std::env::consts::ARCH, std::env::consts::OS) { + ("x86_64", "linux") => "x86_64-unknown-linux-gnu", + ("x86_64", "macos") => "x86_64-apple-darwin", + ("aarch64", "macos") => "aarch64-apple-darwin", + _ => return Err(AdapterError::Incompatible), + }; + let profile = RELEASE_PROFILES + .iter() + .find(|profile| profile.target == target) + .ok_or(AdapterError::Incompatible)?; + if !executable.is_absolute() { + return Err(AdapterError::Incompatible); + } + let meta = fs::symlink_metadata(executable).map_err(|_| AdapterError::Unavailable)?; + if !meta.is_file() || meta.file_type().is_symlink() { + return Err(AdapterError::Incompatible); + } + if sha256_file(executable, u64::MAX)? != profile.executable_sha256 { + return Err(AdapterError::Incompatible); + } + let version = capture(executable, &[OsString::from("--version")], SHORT_DEADLINE)?; + if version.code != Some(0) + || version.stdout != b"optiflow 0.1.1\n" + || !version.stderr.is_empty() + { + return Err(AdapterError::Incompatible); + } + Ok(Self { + executable: executable.to_owned(), + identity: ProviderIdentity { + version: PROVIDER_VERSION.to_owned(), + source_revision: PROVIDER_SOURCE_REVISION.to_owned(), + target: target.to_owned(), + archive_sha256: profile.archive_sha256.to_owned(), + executable_sha256: profile.executable_sha256.to_owned(), + interface: COMMAND_RESULT_SCHEMA.to_owned(), + }, + }) + } + + #[must_use] + pub const fn identity(&self) -> &ProviderIdentity { + &self.identity + } + + /// Refuse a mutation request as a distinct unavailable capability. + /// # Errors + /// Unsupported requests are always refused; read-only requests use + /// `inspect` with a selected root and local state directory. + pub fn require_capability(&self, capability: Capability) -> Result<(), AdapterError> { + match capability.state() { + CapabilityState::AvailableReadOnly => Ok(()), + CapabilityState::UnsupportedByPinnedRelease => { + Err(AdapterError::UnsupportedCapability(capability)) + } + } + } + + /// Scan a selected directory and retain the report and review-only plan. + /// + /// The state directory must be disjoint from the source. Only synthetic + /// fixtures should be used in tests. A receipt is synced before the first + /// child and after every verified step. Provider and Flow evidence live in + /// the state directory, while the source remains read-only. + /// # Errors + /// Returns typed refusals; `receipt_path` can be inspected after failure. + pub fn inspect( + &self, + source_root: &Path, + state_directory: &Path, + ) -> Result<(ReadOnlyReceipt, PathBuf), AdapterError> { + let source = SourceIdentity::observe(source_root)?; + let root = source.root.path()?; + let prospective_state = prospective_path(state_directory)?; + if root.starts_with(&prospective_state) || prospective_state.starts_with(&root) { + return Err(AdapterError::PathEscape); + } + fs::create_dir_all(&prospective_state)?; + if fs::symlink_metadata(&prospective_state)? + .file_type() + .is_symlink() + { + return Err(AdapterError::PathEscape); + } + let state = fs::canonicalize(prospective_state)?; + if root.starts_with(&state) || state.starts_with(&root) { + return Err(AdapterError::PathEscape); + } + let receipt_dir = state.join("flow-optiflow-v0.1.1"); + fs::create_dir_all(&receipt_dir)?; + let attempt_id = format!( + "{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| AdapterError::InvalidEvidence("host clock"))? + .as_nanos() + ); + let path = receipt_dir.join(format!("{attempt_id}.json")); + let capabilities = [ + ("scan", Capability::Scan), + ("report", Capability::Report), + ("plan-exact-duplicates", Capability::PlanExactDuplicates), + ("dry-run", Capability::DryRun), + ("quarantine", Capability::Quarantine), + ("restore", Capability::Restore), + ("finalize", Capability::Finalize), + ] + .into_iter() + .map(|(key, capability)| (key.to_owned(), capability.state())) + .collect(); + let mut receipt = ReadOnlyReceipt { + schema_version: RECEIPT_SCHEMA.to_owned(), + attempt_id: attempt_id.clone(), + status: ReceiptStatus::Scanning, + provider: self.identity.clone(), + source: source.clone(), + state_directory: NativePath::from_path(&state), + environment: "cleared; no config; no media probe; no network grant".to_owned(), + capabilities, + commands: Vec::new(), + source_run_id: None, + source_set_id: None, + plan_id: None, + plan_sha256: None, + approval: None, + recovery: "required-if-interrupted".to_owned(), + duplicate_logical_bytes: None, + physical_savings_bytes: None, + failure: None, + }; + persist(&path, &receipt, true)?; + let result = self.inspect_steps(&state, &source, &attempt_id, &path, &mut receipt); + if let Err(error) = result { + receipt.status = ReceiptStatus::Refused; + receipt.recovery = if matches!(error, AdapterError::Timeout | AdapterError::Io(_)) { + "inspect-provider-state-before-retry".to_owned() + } else { + "no-source-mutation".to_owned() + }; + receipt.failure = Some(FailureEvidence { + code: error.code().to_owned(), + detail: error.to_string(), + provider_diagnostics: match &error { + AdapterError::ProviderFailure { diagnostics, .. } + | AdapterError::StalePlan { diagnostics, .. } => diagnostics.clone(), + _ => Vec::new(), + }, + provider_stdout_sha256: match &error { + AdapterError::ProviderFailure { stdout_sha256, .. } + | AdapterError::StalePlan { stdout_sha256, .. } => Some(stdout_sha256.clone()), + _ => None, + }, + }); + persist(&path, &receipt, false)?; + return Err(error); + } + Ok((receipt, path)) + } + + #[allow(clippy::too_many_lines)] + fn inspect_steps( + &self, + state: &Path, + source: &SourceIdentity, + attempt_id: &str, + receipt_path: &Path, + receipt: &mut ReadOnlyReceipt, + ) -> Result<(), AdapterError> { + source.require_current()?; + let root = source.root.path()?; + let scan_args = vec![ + OsString::from("scan"), + OsString::from("--no-follow-symlinks"), + OsString::from("--stay-on-filesystem"), + OsString::from("--no-probe"), + root.as_os_str().to_owned(), + ]; + let scan = self.invoke(state, "scan", &scan_args, SCAN_DEADLINE)?; + let run = field(&scan.result, "/run/run_id")? + .as_str() + .ok_or(AdapterError::InvalidEvidence("run id"))?; + validate_id(run)?; + let source_set = field(&scan.result, "/run/artifact_set_id")? + .as_str() + .ok_or(AdapterError::InvalidEvidence("source set id"))?; + validate_id(source_set)?; + if field(&scan.result, "/run/schema_version")?.as_str() != Some(RUN_SCHEMA) + || field(&scan.result, "/schema_version")?.as_str() != Some(REPORT_SCHEMA) + || field(&scan.result, "/run/options/follow_symlinks")?.as_bool() != Some(false) + || field(&scan.result, "/run/options/cross_filesystems")?.as_bool() != Some(false) + || field(&scan.result, "/run/options/probe_media")?.as_bool() != Some(false) + { + return Err(AdapterError::InvalidEvidence("scan schema or policy")); + } + let expected_root = source.root.path()?; + let inputs = field(&scan.result, "/run/inputs")? + .as_array() + .ok_or(AdapterError::InvalidEvidence("source roots"))?; + if inputs.len() != 1 || inputs[0].as_str().map(Path::new) != Some(expected_root.as_path()) { + return Err(AdapterError::InvalidEvidence("source root binding")); + } + validate_report_paths(&scan.result, source)?; + let scan_dir = state.join("runs").join(run); + let scan_evidence = validate_set( + &scan, + &scan_dir.join("artifact-set.json"), + &scan_dir, + "scan", + run, + None, + &[ + ("effective_policy", POLICY_SCHEMA), + ("report", REPORT_SCHEMA), + ("run", RUN_SCHEMA), + ], + "report", + )?; + receipt.source_run_id = Some(run.to_owned()); + receipt.source_set_id = Some(source_set.to_owned()); + if scan_evidence.set_id != source_set { + return Err(AdapterError::InvalidEvidence("scan set identity")); + } + receipt.duplicate_logical_bytes = Some( + field(&scan.result, "/storage/duplicate_logical_bytes")? + .as_u64() + .ok_or(AdapterError::InvalidEvidence("logical byte accounting"))?, + ); + receipt.commands.push(scan_evidence); + receipt.status = ReceiptStatus::Reporting; + persist(receipt_path, receipt, false)?; + source.require_current()?; + + let report = self.invoke( + state, + "report", + &[OsString::from("report"), OsString::from(run)], + SHORT_DEADLINE, + )?; + if field(&report.result, "/run/run_id")?.as_str() != Some(run) { + return Err(AdapterError::InvalidEvidence("report run identity")); + } + validate_report_paths(&report.result, source)?; + let report_evidence = validate_set( + &report, + &scan_dir.join("artifact-set.json"), + &scan_dir, + "scan", + run, + None, + &[ + ("effective_policy", POLICY_SCHEMA), + ("report", REPORT_SCHEMA), + ("run", RUN_SCHEMA), + ], + "report", + )?; + if report_evidence.set_id != source_set { + return Err(AdapterError::InvalidEvidence("report set identity")); + } + receipt.commands.push(report_evidence); + receipt.status = ReceiptStatus::Planning; + persist(receipt_path, receipt, false)?; + source.require_current()?; + + let plan_dir = state.join("plans"); + fs::create_dir_all(&plan_dir)?; + let plan_path = plan_dir.join(format!("{attempt_id}.json")); + let plan = self.invoke( + state, + "plan", + &[ + OsString::from("plan"), + OsString::from("exact-duplicates"), + OsString::from("--run"), + OsString::from(run), + OsString::from("--output"), + plan_path.as_os_str().to_owned(), + ], + SHORT_DEADLINE, + )?; + if field(&plan.result, "/schema_version")?.as_str() != Some(PLAN_SCHEMA) + || field(&plan.result, "/source_run_id")?.as_str() != Some(run) + || field(&plan.result, "/source_artifact_set_id")?.as_str() != Some(source_set) + || field(&plan.result, "/safety/mutates_files")?.as_bool() != Some(false) + || field(&plan.result, "/safety/requires_explicit_apply")?.as_bool() != Some(true) + { + return Err(AdapterError::InvalidEvidence( + "plan safety or source binding", + )); + } + let plan_id = field(&plan.result, "/plan_id")? + .as_str() + .ok_or(AdapterError::InvalidEvidence("plan id"))?; + validate_id(plan_id)?; + validate_plan_paths(&plan.result, source)?; + let plan_marker = plan_dir.join(format!("{attempt_id}.json.artifact-set.json")); + let plan_evidence = validate_set( + &plan, + &plan_marker, + &plan_dir, + "plan", + run, + Some(source_set), + &[("plan", PLAN_SCHEMA)], + "plan", + )?; + receipt.plan_sha256 = Some( + plan_evidence + .artifacts + .iter() + .find(|artifact| artifact.kind == "plan") + .ok_or(AdapterError::InvalidEvidence("plan artifact"))? + .sha256 + .clone(), + ); + receipt.plan_id = Some(plan_id.to_owned()); + receipt.commands.push(plan_evidence); + source.require_current()?; + let final_source = validate_set( + &scan, + &scan_dir.join("artifact-set.json"), + &scan_dir, + "scan", + run, + None, + &[ + ("effective_policy", POLICY_SCHEMA), + ("report", REPORT_SCHEMA), + ("run", RUN_SCHEMA), + ], + "report", + )?; + if final_source.marker_sha256 != receipt.commands[0].marker_sha256 + || final_source + .artifacts + .iter() + .map(|artifact| &artifact.sha256) + .collect::>() + != receipt.commands[0] + .artifacts + .iter() + .map(|artifact| &artifact.sha256) + .collect::>() + { + return Err(AdapterError::InvalidEvidence( + "source artifacts changed after scan", + )); + } + receipt.status = if receipt + .commands + .iter() + .any(|command| command.coverage == "partial") + { + ReceiptStatus::Partial + } else { + ReceiptStatus::Complete + }; + "none".clone_into(&mut receipt.recovery); + persist(receipt_path, receipt, false) + } + + fn invoke( + &self, + state: &Path, + name: &str, + specific: &[OsString], + deadline: Duration, + ) -> Result { + if sha256_file(&self.executable, u64::MAX)? != self.identity.executable_sha256 { + return Err(AdapterError::Incompatible); + } + let mut args = vec![ + OsString::from("--output-format"), + OsString::from("json"), + OsString::from("--state-directory"), + state.as_os_str().to_owned(), + OsString::from("--no-config"), + ]; + args.extend_from_slice(specific); + let output = capture(&self.executable, &args, deadline)?; + let digest = sha256(&output.stdout); + let response: NativeEnvelope = serde_json::from_slice(&output.stdout) + .map_err(|_| AdapterError::InvalidEvidence("command result JSON"))?; + if response.schema != COMMAND_RESULT_SCHEMA || response.command != name { + return Err(AdapterError::InvalidEvidence( + "command result schema or command", + )); + } + let (class, coverage) = match ( + response.outcome.class.as_str(), + response.outcome.exit_code, + output.code, + response.coverage.as_ref().map(|c| c.status.as_str()), + ) { + ("success", 0, Some(0), Some("complete")) => ("success", "complete"), + ("partial_success", 3, Some(3), Some("partial")) if !response.artifacts.is_empty() => { + ("partial_success", "partial") + } + ("stale_state", 5, Some(5), _) => { + return Err(AdapterError::StalePlan { + diagnostics: response.diagnostics, + stdout_sha256: digest, + }); + } + _ if output.code == Some(response.outcome.exit_code) => { + return Err(AdapterError::ProviderFailure { + command: name.to_owned(), + class: response.outcome.class, + diagnostics: response.diagnostics, + stdout_sha256: digest, + }); + } + _ => { + return Err(AdapterError::InvalidEvidence( + "process exit and declared outcome differ", + )); + } + }; + if !output.stderr.is_empty() { + return Err(AdapterError::InvalidEvidence("unexpected provider stderr")); + } + let result = response + .result + .ok_or(AdapterError::InvalidEvidence("missing domain result"))?; + Ok(NativeResult { + command: name.to_owned(), + arguments: args + .iter() + .map(|arg| NativePath::from_path(Path::new(arg))) + .collect(), + class: class.to_owned(), + coverage: coverage.to_owned(), + stdout_sha256: digest, + diagnostics: response.diagnostics, + artifacts: response.artifacts, + result, + }) + } +} + +fn field<'a>(value: &'a Value, pointer: &str) -> Result<&'a Value, AdapterError> { + value + .pointer(pointer) + .ok_or(AdapterError::InvalidEvidence("required domain field")) +} + +fn prospective_path(path: &Path) -> Result { + let absolute = if path.is_absolute() { + path.to_owned() + } else { + std::env::current_dir()?.join(path) + }; + if absolute + .components() + .any(|part| matches!(part, Component::ParentDir | Component::CurDir)) + { + return Err(AdapterError::PathEscape); + } + let mut ancestor = absolute.as_path(); + let mut missing = Vec::new(); + while !ancestor.exists() { + missing.push( + ancestor + .file_name() + .ok_or(AdapterError::PathEscape)? + .to_owned(), + ); + ancestor = ancestor.parent().ok_or(AdapterError::PathEscape)?; + } + let mut resolved = fs::canonicalize(ancestor)?; + for segment in missing.into_iter().rev() { + resolved.push(segment); + } + Ok(resolved) +} + +fn validate_id(value: &str) -> Result<(), AdapterError> { + if value.len() == 36 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() || byte == b'-') + { + Ok(()) + } else { + Err(AdapterError::InvalidEvidence("identifier format")) + } +} + +fn ensure_source_path(value: &Value, source: &SourceIdentity) -> Result<(), AdapterError> { + let native: NativePath = serde_json::from_value(value.clone())?; + let path = native.path()?; + if !path.is_absolute() + || path + .components() + .any(|part| matches!(part, Component::ParentDir | Component::CurDir)) + || !path.starts_with(source.root.path()?) + { + return Err(AdapterError::PathEscape); + } + Ok(()) +} + +fn validate_report_paths(report: &Value, source: &SourceIdentity) -> Result<(), AdapterError> { + let observations = field(report, "/observations")? + .as_array() + .ok_or(AdapterError::InvalidEvidence("report observations"))?; + for observation in observations { + ensure_source_path(field(observation, "/path")?, source)?; + if field(observation, "/device_id")?.as_u64() != Some(source.filesystem_id) { + return Err(AdapterError::SourceChanged); + } + let identity = field(observation, "/filesystem_identity/filesystem_id")?.as_str(); + if identity != Some(source.filesystem_id.to_string().as_str()) { + return Err(AdapterError::SourceChanged); + } + } + Ok(()) +} + +fn validate_plan_paths(plan: &Value, source: &SourceIdentity) -> Result<(), AdapterError> { + let actions = field(plan, "/actions")? + .as_array() + .ok_or(AdapterError::InvalidEvidence("plan actions"))?; + for action in actions { + if field(action, "/classification")?.as_str() != Some("exact") + || field(action, "/proposed_operation")?.as_str() != Some("review_and_select") + { + return Err(AdapterError::InvalidEvidence("unexpected plan action")); + } + ensure_source_path(field(action, "/keep_path")?, source)?; + for key in ["/candidate_paths", "/keep_alias_paths"] { + let paths = field(action, key)? + .as_array() + .ok_or(AdapterError::InvalidEvidence("plan paths"))?; + for path in paths { + ensure_source_path(path, source)?; + } + } + let preconditions = field(action, "/preconditions")? + .as_array() + .ok_or(AdapterError::InvalidEvidence("plan preconditions"))?; + for precondition in preconditions { + ensure_source_path(field(precondition, "/path")?, source)?; + } + } + Ok(()) +} + +#[derive(Deserialize)] +struct NativeEnvelope { + schema: String, + command: String, + outcome: NativeOutcome, + coverage: Option, + artifacts: Vec, + diagnostics: Vec, + result: Option, +} +#[derive(Deserialize)] +struct NativeOutcome { + class: String, + exit_code: i32, +} +#[derive(Deserialize)] +struct NativeCoverage { + status: String, +} +#[derive(Deserialize)] +struct NativeArtifact { + kind: String, + schema: String, + run_id: Option, + path: NativePath, +} +struct NativeResult { + command: String, + arguments: Vec, + class: String, + coverage: String, + stdout_sha256: String, + diagnostics: Vec, + artifacts: Vec, + result: Value, +} + +#[derive(Deserialize)] +struct NativeMarker { + schema: String, + set_id: String, + set_kind: String, + run_id: String, + state: String, + source_set_id: Option, + members: Vec, +} +#[derive(Deserialize)] +struct NativeMember { + kind: String, + schema: String, + path: NativePath, + size_bytes: u64, + digest: NativeDigest, +} +#[derive(Deserialize)] +struct NativeDigest { + algorithm: String, + value: String, +} + +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +fn validate_set( + response: &NativeResult, + marker_path: &Path, + directory: &Path, + kind: &str, + run_id: &str, + source_set_id: Option<&str>, + expected: &[(&str, &str)], + result_kind: &str, +) -> Result { + if fs::canonicalize(directory)? != directory { + return Err(AdapterError::PathEscape); + } + let marker_bytes = read_regular(marker_path, MAX_ARTIFACT)?; + let marker: NativeMarker = serde_json::from_slice(&marker_bytes)?; + if marker.schema != ARTIFACT_SET_SCHEMA + || marker.set_kind != kind + || marker.run_id != run_id + || marker.state != "committed" + || marker.source_set_id.as_deref() != source_set_id + { + return Err(AdapterError::InvalidEvidence("artifact set binding")); + } + validate_id(&marker.set_id)?; + if marker.members.len() != expected.len() { + return Err(AdapterError::InvalidEvidence("artifact member count")); + } + let mut artifacts = Vec::with_capacity(expected.len() + 1); + for (expected_kind, expected_schema) in expected { + let mut members = marker + .members + .iter() + .filter(|member| member.kind == *expected_kind); + let member = members + .next() + .ok_or(AdapterError::InvalidEvidence("missing artifact member"))?; + if members.next().is_some() + || member.schema != *expected_schema + || member.digest.algorithm != "blake3-256" + { + return Err(AdapterError::InvalidEvidence( + "artifact member kind, schema, or digest", + )); + } + let relative = member.path.path()?; + if relative.components().count() != 1 + || !matches!(relative.components().next(), Some(Component::Normal(_))) + { + return Err(AdapterError::PathEscape); + } + let path = directory.join(relative); + let bytes = read_regular(&path, MAX_ARTIFACT)?; + if bytes.len() as u64 != member.size_bytes + || blake3::hash(&bytes).to_hex().as_str() != member.digest.value + { + return Err(AdapterError::InvalidEvidence("artifact member digest")); + } + let document: Value = serde_json::from_slice(&bytes)?; + let schema_field = if *expected_kind == "effective_policy" { + "/schema" + } else { + "/schema_version" + }; + if field(&document, schema_field)?.as_str() != Some(*expected_schema) { + return Err(AdapterError::InvalidEvidence("artifact member schema")); + } + if *expected_kind == result_kind && document != response.result { + return Err(AdapterError::InvalidEvidence( + "stdout and committed member differ", + )); + } + if (*expected_kind == "run" + && field(&document, "/artifact_set_id")?.as_str() != Some(&marker.set_id)) + || (*expected_kind == "report" + && field(&document, "/run/artifact_set_id")?.as_str() != Some(&marker.set_id)) + || (*expected_kind == "plan" + && field(&document, "/source_artifact_set_id")?.as_str() != source_set_id) + { + return Err(AdapterError::InvalidEvidence("member set identity")); + } + artifacts.push(ArtifactEvidence { + kind: member.kind.clone(), + schema: member.schema.clone(), + path: NativePath::from_path(&path), + size_bytes: member.size_bytes, + sha256: sha256(&bytes), + blake3_256: member.digest.value.clone(), + }); + } + // Every reference must point to a verified member, policy, or marker. + for reference in &response.artifacts { + if reference.run_id.as_deref() != Some(run_id) { + return Err(AdapterError::InvalidEvidence("artifact run identity")); + } + let claimed = reference.path.path()?; + if reference.kind == "artifact_set" { + if claimed != marker_path || reference.schema != ARTIFACT_SET_SCHEMA { + return Err(AdapterError::PathEscape); + } + } else if !artifacts.iter().any(|artifact| { + artifact.kind == reference.kind + && artifact.schema == reference.schema + && artifact.path.path().ok().as_deref() == Some(claimed.as_path()) + }) { + // A plan command also references the source effective policy. + if !(kind == "plan" + && reference.kind == "effective_policy" + && reference.schema == POLICY_SCHEMA + && claimed + == directory + .parent() + .unwrap_or(directory) + .join("runs") + .join(run_id) + .join("effective-policy.json")) + { + return Err(AdapterError::PathEscape); + } + } + } + let claimed_result = response + .artifacts + .iter() + .any(|reference| reference.kind == result_kind); + let claimed_marker = response + .artifacts + .iter() + .any(|reference| reference.kind == "artifact_set"); + if !claimed_result || !claimed_marker { + return Err(AdapterError::InvalidEvidence( + "missing committed artifact reference", + )); + } + artifacts.push(ArtifactEvidence { + kind: "artifact_set".to_owned(), + schema: ARTIFACT_SET_SCHEMA.to_owned(), + path: NativePath::from_path(marker_path), + size_bytes: marker_bytes.len() as u64, + sha256: sha256(&marker_bytes), + blake3_256: blake3::hash(&marker_bytes).to_hex().to_string(), + }); + Ok(CommandEvidence { + command: response.command.clone(), + arguments: response.arguments.clone(), + outcome: response.class.clone(), + coverage: response.coverage.clone(), + stdout_sha256: response.stdout_sha256.clone(), + diagnostics: response.diagnostics.clone(), + set_id: marker.set_id, + marker_sha256: sha256(&marker_bytes), + artifacts, + }) +} + +fn read_regular(path: &Path, limit: u64) -> Result, AdapterError> { + let meta = fs::symlink_metadata(path)?; + if !meta.is_file() || meta.file_type().is_symlink() || meta.len() > limit { + return Err(AdapterError::InvalidEvidence( + "artifact is not a bounded regular file", + )); + } + let mut bytes = Vec::new(); + File::open(path)?.take(limit + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 != meta.len() || bytes.len() as u64 > limit { + return Err(AdapterError::InvalidEvidence( + "artifact changed during observation", + )); + } + Ok(bytes) +} + +fn sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn sha256_file(path: &Path, limit: u64) -> Result { + let mut file = File::open(path)?; + let mut hash = Sha256::new(); + let mut total = 0_u64; + let mut chunk = [0_u8; 8192]; + loop { + let count = file.read(&mut chunk)?; + if count == 0 { + break; + } + total = total.saturating_add(count as u64); + if total > limit { + return Err(AdapterError::Limit("file")); + } + hash.update(&chunk[..count]); + } + Ok(format!("{:x}", hash.finalize())) +} + +struct Captured { + code: Option, + stdout: Vec, + stderr: Vec, +} + +fn capture( + executable: &Path, + args: &[OsString], + deadline: Duration, +) -> Result { + let mut child = Command::new(executable) + .args(args) + .current_dir(executable.parent().ok_or(AdapterError::Incompatible)?) + .env_clear() + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| AdapterError::Unavailable)?; + let out = child + .stdout + .take() + .ok_or(AdapterError::InvalidEvidence("stdout pipe"))?; + let err = child + .stderr + .take() + .ok_or(AdapterError::InvalidEvidence("stderr pipe"))?; + let stdout = thread::spawn(move || read_capped(out, MAX_STDOUT)); + let stderr = thread::spawn(move || read_capped(err, MAX_STDERR)); + let start = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if start.elapsed() < deadline => thread::sleep(Duration::from_millis(5)), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(AdapterError::Timeout); + } + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(AdapterError::Io(error)); + } + } + }; + let stdout = stdout + .join() + .map_err(|_| AdapterError::InvalidEvidence("stdout worker"))??; + let stderr = stderr + .join() + .map_err(|_| AdapterError::InvalidEvidence("stderr worker"))??; + if stdout.len() > MAX_STDOUT || stderr.len() > MAX_STDERR { + return Err(AdapterError::Limit("output")); + } + Ok(Captured { + code: status.code(), + stdout, + stderr, + }) +} + +fn read_capped(stream: impl Read, limit: usize) -> io::Result> { + let mut bytes = Vec::new(); + stream.take((limit + 1) as u64).read_to_end(&mut bytes)?; + Ok(bytes) +} + +fn persist(path: &Path, receipt: &ReadOnlyReceipt, first: bool) -> Result<(), AdapterError> { + let mut bytes = serde_json::to_vec_pretty(receipt)?; + bytes.push(b'\n'); + let target = if first { + path.to_owned() + } else { + path.with_extension("tmp") + }; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&target)?; + file.write_all(&bytes)?; + file.sync_all()?; + if !first { + fs::rename(&target, path)?; + } + File::open( + path.parent() + .ok_or(AdapterError::InvalidEvidence("receipt parent"))?, + )? + .sync_all()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{AdapterError, NativePath, SourceIdentity, validate_plan_paths}; + use serde_json::json; + use std::fs; + + #[test] + fn plan_candidate_escape_is_refused_even_with_a_safe_keep_path() { + let root = std::env::temp_dir().join(format!("flow-optiflow-root-{}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + let source = SourceIdentity::observe(&root).unwrap(); + let keep = NativePath::from_path(&root.join("keep")); + let escape = NativePath::from_path(&root.join("..").join("outside")); + let plan = json!({ + "actions": [{ + "classification": "exact", "proposed_operation": "review_and_select", + "keep_path": keep, "candidate_paths": [escape], "keep_alias_paths": [], + "preconditions": [] + }] + }); + assert!(matches!( + validate_plan_paths(&plan, &source), + Err(AdapterError::PathEscape) + )); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/tests/optiflow_read_only.rs b/tests/optiflow_read_only.rs new file mode 100644 index 0000000..79443f4 --- /dev/null +++ b/tests/optiflow_read_only.rs @@ -0,0 +1,194 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use flow::optiflow::{ + AdapterError, Capability, CapabilityState, OptiflowReadOnlyAdapter, ReadOnlyReceipt, + ReceiptStatus, +}; + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = + std::env::temp_dir().join(format!("flow-optiflow-{}-{suffix}", std::process::id())); + fs::create_dir(&path).unwrap(); + Self(path) + } + + fn source(&self) -> PathBuf { + self.0.join("source 🌿 with spaces") + } + + fn state(&self) -> PathBuf { + self.0.join("local state") + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn release_executable() -> Option { + // The test runs against the independently pinned release when installed. + // Unit-level refusal checks remain available without a downloaded binary. + std::env::var_os("FLOW_OPTIFLOW_V011_EXECUTABLE").map(PathBuf::from) +} + +#[test] +fn missing_or_incompatible_provider_is_refused() { + let fixture = Fixture::new(); + assert!(matches!( + OptiflowReadOnlyAdapter::probe(&fixture.0.join("missing")), + Err(AdapterError::Unavailable) + )); + let impostor = fixture.0.join("optiflow"); + fs::write(&impostor, b"optiflow 0.1.1\n").unwrap(); + assert!(matches!( + OptiflowReadOnlyAdapter::probe(&impostor), + Err(AdapterError::Incompatible) + )); +} + +#[test] +fn native_release_inspects_only_synthetic_sources_and_verifies_durable_evidence() { + let Some(executable) = release_executable() else { + return; + }; + let fixture = Fixture::new(); + let root = fixture.source(); + fs::create_dir_all(&root).unwrap(); + let a = root.join("a 🍃.txt"); + let b = root.join("b 🍃.txt"); + let other = root.join("different.txt"); + let bytes = b"same bytes, distinct inodes\n"; + fs::write(&a, bytes).unwrap(); + fs::write(&b, bytes).unwrap(); + fs::write(&other, b"other\n").unwrap(); + let before = [ + fs::read(&a).unwrap(), + fs::read(&b).unwrap(), + fs::read(&other).unwrap(), + ]; + + let adapter = OptiflowReadOnlyAdapter::probe(&executable).unwrap(); + for unsupported in [ + Capability::DryRun, + Capability::Quarantine, + Capability::Restore, + Capability::Finalize, + ] { + assert_eq!( + unsupported.state(), + CapabilityState::UnsupportedByPinnedRelease + ); + assert!(matches!( + adapter.require_capability(unsupported), + Err(AdapterError::UnsupportedCapability(_)) + )); + } + let (receipt, path) = adapter.inspect(&root, &fixture.state()).unwrap(); + assert!(matches!(receipt.status, ReceiptStatus::Complete)); + assert_eq!( + receipt + .commands + .iter() + .map(|command| command.command.as_str()) + .collect::>(), + ["scan", "report", "plan"] + ); + assert_eq!(receipt.duplicate_logical_bytes, Some(bytes.len() as u64)); + assert_eq!(receipt.physical_savings_bytes, None); + assert_eq!(receipt.approval, None); + assert_eq!(receipt.recovery, "none"); + assert_eq!(receipt.provider.version, "0.1.1"); + assert_eq!(receipt.provider.interface, "optiflow.command-result.v1"); + assert!( + receipt + .plan_sha256 + .as_ref() + .is_some_and(|digest| digest.len() == 64) + ); + assert!( + receipt + .commands + .iter() + .all(|command| command.artifacts.iter().any(|a| a.kind == "artifact_set")) + ); + assert!(!receipt.needs_recovery()); + assert_eq!(fs::read(&a).unwrap(), before[0]); + assert_eq!(fs::read(&b).unwrap(), before[1]); + assert_eq!(fs::read(&other).unwrap(), before[2]); + + let reopened = ReadOnlyReceipt::load(&path).unwrap(); + assert_eq!(reopened.plan_sha256, receipt.plan_sha256); + // Simulate a crash after the report was committed but before the plan + // transition. Reopening cannot infer completion from provider files. + let original_receipt = fs::read(&path).unwrap(); + let mut interrupted = receipt; + interrupted.status = ReceiptStatus::Planning; + interrupted.commands.pop(); + interrupted.plan_id = None; + interrupted.plan_sha256 = None; + interrupted.recovery = "required-if-interrupted".to_owned(); + fs::write(&path, serde_json::to_vec(&interrupted).unwrap()).unwrap(); + assert!(ReadOnlyReceipt::load(&path).unwrap().needs_recovery()); + fs::write(&path, original_receipt).unwrap(); + + let plan_path = reopened.commands[2] + .artifacts + .iter() + .find(|a| a.kind == "plan") + .unwrap() + .path + .clone(); + let plan_path = native_path(&plan_path); + let mut altered = fs::read(&plan_path).unwrap(); + altered.push(b' '); + fs::write(&plan_path, altered).unwrap(); + assert!(matches!( + ReadOnlyReceipt::load(&path), + Err(AdapterError::InvalidEvidence("receipt artifact changed")) + )); +} + +#[test] +fn source_and_state_overlap_is_refused_before_any_provider_run() { + let Some(executable) = release_executable() else { + return; + }; + let fixture = Fixture::new(); + let root = fixture.source(); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("original"), b"preserve me").unwrap(); + let adapter = OptiflowReadOnlyAdapter::probe(&executable).unwrap(); + let nested = root.join("evidence"); + assert!(matches!( + adapter.inspect(&root, &nested), + Err(AdapterError::PathEscape) + )); + assert_eq!(fs::read(root.join("original")).unwrap(), b"preserve me"); + assert!(!nested.exists()); +} + +#[cfg(unix)] +fn native_path(value: &flow::optiflow::NativePath) -> PathBuf { + match value { + flow::optiflow::NativePath::Utf8 { value } => PathBuf::from(value), + flow::optiflow::NativePath::UnixBytes { base64 } => { + use base64::Engine; + use std::os::unix::ffi::OsStringExt; + let bytes = base64::engine::general_purpose::STANDARD + .decode(base64) + .unwrap(); + Path::new(&std::ffi::OsString::from_vec(bytes)).to_owned() + } + } +}