From d874bbc89b75793d597941366c8ca3f2e22532ea Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 09:42:17 -0600 Subject: [PATCH 1/3] feat(storage): prove native filesystem publication semantics --- .github/workflows/README.md | 7 +- .github/workflows/publish.yaml | 4 +- .github/workflows/test.yml | 47 + BUILD.bazel | 6 + Cargo.lock | 150 ++- Cargo.toml | 1 + Makefile | 2 +- RELEASING.md | 2 +- cargo-bazel-lock.json | 798 +++++++++++++- .../THIRD_PARTY_NOTICES.md | 46 +- .../THIRD_PARTY_NOTICES.md | 46 +- crates/graphforge-cli/THIRD_PARTY_NOTICES.md | 46 +- crates/graphforge-filesystem/BUILD.bazel | 16 + crates/graphforge-filesystem/Cargo.toml | 26 + crates/graphforge-filesystem/NOTICE | 18 + crates/graphforge-filesystem/src/lib.rs | 925 ++++++++++++++++ crates/graphforge-storage/BUILD.bazel | 1 + crates/graphforge-storage/Cargo.toml | 3 + .../src/filesystem_admission.rs | 993 ++++++++++++++++++ crates/graphforge-storage/src/lib.rs | 2 + docs/adr/0017-unified-release-version.md | 2 +- docs/development/bazel-bootstrap.md | 4 +- .../bazel-migration-ac-evidence.md | 2 +- docs/development/bazel-migration-ledger.md | 14 +- .../clean-environment-verification.md | 22 +- docs/development/publication-order.md | 41 +- docs/development/release-artifact-record.md | 10 +- docs/development/release-process.md | 4 +- docs/engineering/PUBLISHING.md | 2 +- docs/engineering/TESTING.md | 8 +- legal/THIRD_PARTY_NOTICES.md | 46 +- packages/cli/THIRD_PARTY_NOTICES.md | 46 +- scripts/ci/check-domain-dependencies.py | 1 + scripts/ci/clean-env-verify.py | 3 +- scripts/ci/release_candidate_manifest.py | 1 + scripts/ci/test-binding-release-candidate.py | 118 ++- scripts/ci/test-ci-storage-policy.py | 27 +- scripts/ci/test-clean-env-verify.py | 2 +- scripts/ci/test-crate-publish-plan.py | 5 +- scripts/ci/test-domain-dependencies.py | 3 +- scripts/ci/test-release-candidate.py | 20 +- scripts/ci/test-release-publish-preflight.py | 2 +- scripts/ci/test-release-registry.py | 2 +- scripts/ci/test-release-rehearsal.py | 12 +- scripts/license_check.py | 1 + scripts/publish_dry_run.py | 1 + scripts/verify_package_licenses.py | 1 + tests/unit/test_publish_dry_run.py | 2 +- .../drift/cargo_feature_fingerprint.json | 75 +- tools/bazel/parity/migration_target_map.json | 12 +- 50 files changed, 3509 insertions(+), 119 deletions(-) create mode 100644 crates/graphforge-filesystem/BUILD.bazel create mode 100644 crates/graphforge-filesystem/Cargo.toml create mode 100644 crates/graphforge-filesystem/NOTICE create mode 100644 crates/graphforge-filesystem/src/lib.rs create mode 100644 crates/graphforge-storage/src/filesystem_admission.rs diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 45019afa..08f8b94e 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -68,9 +68,10 @@ only when the platform supports them. native bindings. - Rust changes run Cargo formatting/Clippy (`Rust Quality`) and authoritative Bazel tests (`Bazel Bootstrap` → `//:ci_rust_tests`, including API BDD). The - same Rust classification also runs the Windows `graphforge-storage` - `project_generation` lock unit tests on `blacksmith-4vcpu-windows-2025` - (Linux Bazel CI cannot execute those `#[cfg(windows)]` cases). + same Rust classification also runs native filesystem publication/admission + tests on `blacksmith-4vcpu-windows-2025` and + `blacksmith-12vcpu-macos-15`; Windows retains the existing project-root lock + tests. Linux Bazel CI cannot execute those host-specific contracts. - Python, Gherkin, public binding, Pulumi static-validation, and Terraform static-validation gates run only when their owned surfaces change. Shared GraphForge configuration and infrastructure contract fixtures run both IaC diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 5c65e7bb..5603268e 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -642,7 +642,7 @@ jobs: done < <(python3 scripts/ci/crate-publish-plan.py list) reconcile: - name: Always reconcile all 24 public nodes + name: Always reconcile all 25 public nodes if: always() needs: - candidate-preflight @@ -714,4 +714,4 @@ jobs: if-no-files-found: error retention-days: 30 - name: Require complete public verification - run: jq -e '.complete == true and (.nodes | length) == 24' reconciliation/summary.json + run: jq -e '.complete == true and (.nodes | length) == 25' reconciliation/summary.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 515968cc..a48553b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -865,12 +865,57 @@ jobs: cargo test -p graphforge-storage project_generation::tests:: --lib --no-fail-fast + - name: Run Windows native filesystem admission tests + shell: bash + run: >- + cargo test -p graphforge-storage + filesystem_admission::tests:: --lib --no-fail-fast + + - name: Run Windows exact filesystem primitive tests + shell: bash + run: >- + cargo test -p graphforge-filesystem --lib --no-fail-fast + - name: Run Windows durability certification unit tests shell: bash run: >- cargo test -p graphforge-storage --features test-failpoints project_certification --lib --no-fail-fast + macos-graphforge-storage-durability: + name: macOS graphforge-storage Durability + runs-on: blacksmith-12vcpu-macos-15 + needs: changes + if: needs.changes.outputs.rust == 'true' + timeout-minutes: 30 + env: + CARGO_INCREMENTAL: 0 + CARGO_PROFILE_TEST_DEBUG: 0 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master as of 2026-08-05 + with: + toolchain: "1.96.0" + + - name: Cache Cargo registry + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-registry-v1-${{ hashFiles('Cargo.lock') }} + + - name: Run macOS native filesystem admission tests + run: >- + cargo test -p graphforge-storage + filesystem_admission::tests:: --lib --no-fail-fast + + - name: Run macOS exact filesystem primitive tests + run: >- + cargo test -p graphforge-filesystem --lib --no-fail-fast + bazel-bootstrap: # Job display name kept for continuity; this is the authoritative Rust # compile/test path under CI Gate after #4 cutover. @@ -1149,6 +1194,7 @@ jobs: - node-binding - concurrency-matrix - windows-graphforge-storage-locks + - macos-graphforge-storage-durability - bazel-bootstrap steps: - name: Checkout gate implementation @@ -1168,4 +1214,5 @@ jobs: "${{ needs.node-binding.result }}" "${{ needs.concurrency-matrix.result }}" "${{ needs.windows-graphforge-storage-locks.result }}" + "${{ needs.macos-graphforge-storage-durability.result }}" "${{ needs.bazel-bootstrap.result }}" diff --git a/BUILD.bazel b/BUILD.bazel index e11a1916..aef6bb1d 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -56,6 +56,7 @@ build_test( targets = [ "//crates/graphforge-ast:graphforge_ast", "//crates/graphforge-core:graphforge_core", + "//crates/graphforge-filesystem:graphforge_filesystem", "//crates/graphforge-cypher:graphforge_cypher", "//crates/graphforge-ir:graphforge_ir", "//crates/graphforge-ontology:graphforge_ontology", @@ -71,6 +72,7 @@ test_suite( tests = [ "//crates/graphforge-ast:graphforge_ast_test", "//crates/graphforge-core:graphforge_core_test", + "//crates/graphforge-filesystem:graphforge_filesystem_test", "//crates/graphforge-cypher:graphforge_cypher_test", "//crates/graphforge-ir:graphforge_ir_test", "//crates/graphforge-ontology:graphforge_ontology_test", @@ -87,6 +89,7 @@ build_test( targets = [ "//crates/graphforge-api:graphforge_api", "//crates/graphforge-exec:graphforge_exec", + "//crates/graphforge-filesystem:graphforge_filesystem", "//crates/graphforge-io:graphforge_io", "//crates/graphforge-knowledge:graphforge_knowledge", "//crates/graphforge-search:graphforge_search", @@ -99,6 +102,7 @@ test_suite( tests = [ "//crates/graphforge-api:graphforge_api_test", "//crates/graphforge-exec:graphforge_exec_test", + "//crates/graphforge-filesystem:graphforge_filesystem_test", "//crates/graphforge-io:graphforge_io_test", "//crates/graphforge-knowledge:graphforge_knowledge_test", "//crates/graphforge-search:graphforge_search_test", @@ -114,6 +118,7 @@ build_test( "//crates/graphforge-ast:graphforge_ast", "//crates/graphforge-cli:graphforge_cli", "//crates/graphforge-core:graphforge_core", + "//crates/graphforge-filesystem:graphforge_filesystem", "//crates/graphforge-cypher:graphforge_cypher", "//crates/graphforge-exec:graphforge_exec", "//crates/graphforge-io:graphforge_io", @@ -135,6 +140,7 @@ test_suite( "//crates/graphforge-ast:graphforge_ast_test", "//crates/graphforge-cli:graphforge_cli_test", "//crates/graphforge-core:graphforge_core_test", + "//crates/graphforge-filesystem:graphforge_filesystem_test", "//crates/graphforge-cypher:graphforge_cypher_test", "//crates/graphforge-exec:graphforge_exec_test", "//crates/graphforge-io:graphforge_io_test", diff --git a/Cargo.lock b/Cargo.lock index 8b617d11..b074a9f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -653,7 +653,7 @@ dependencies = [ "js-sys", "num-traits", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2283,6 +2283,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "graphforge-filesystem" +version = "0.5.2" +dependencies = [ + "rustix 1.1.4", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "graphforge-io" version = "0.5.2" @@ -2403,14 +2412,17 @@ dependencies = [ "fs4 1.1.0", "futures", "graphforge-core", + "graphforge-filesystem", "graphforge-ir", "graphforge-ontology", "libc", "named-lock", "parquet", + "rustix 1.1.4", "serde", "serde_json", "sha2", + "sysinfo", "tempfile", "thiserror 2.0.20", "tokio", @@ -2889,7 +2901,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3052,7 +3064,7 @@ dependencies = [ "once_cell", "parking_lot", "thiserror 1.0.69", - "windows", + "windows 0.53.0", ] [[package]] @@ -3198,6 +3210,25 @@ dependencies = [ "libm", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.37.3" @@ -3298,7 +3329,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4150,6 +4181,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "tantivy" version = "0.26.1" @@ -4962,6 +5005,28 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + [[package]] name = "windows-core" version = "0.53.0" @@ -4972,6 +5037,19 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -4980,9 +5058,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.2.1", "windows-result 0.4.1", - "windows-strings", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -5007,12 +5096,28 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -5022,13 +5127,31 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -5037,7 +5160,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -5064,7 +5187,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -5083,6 +5206,15 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index ca1dde81..e11da7d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/graphforge-exec", "crates/graphforge-search", "crates/graphforge-storage", + "crates/graphforge-filesystem", "crates/graphforge-io", "crates/graphforge-provenance", "crates/graphforge-knowledge", diff --git a/Makefile b/Makefile index 4aad9f32..56ec24c7 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ publish-dry-run-docs: ## Docs preview build (pnpm docs:build) python3 scripts/publish_dry_run.py --surface docs publish-dry-run-python: ## Local maturin sdist packaging check (not TestPyPI upload) python3 scripts/publish_dry_run.py --surface python -publish-dry-run-cargo: ## cargo package --list for all 15 crates.io packages in plan order +publish-dry-run-cargo: ## cargo package --list for all 16 crates.io packages in plan order python3 scripts/publish_dry_run.py --surface cargo-package record-release-artifacts: ## Hash artifacts in DIST_DIR into a release record JSON diff --git a/RELEASING.md b/RELEASING.md index 65108f1e..ccb23805 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -41,7 +41,7 @@ prepare one coordinated later GraphForge version. ## Verification -- Confirm the final reconciliation reports all 24 public nodes as verified. +- Confirm the final reconciliation reports all 25 public nodes as verified. - Exercise clean consumers from crates.io, PyPI, and npm. - Confirm the GitHub Release notes and public documentation resolve. - Close the human release tracker only after the public state is complete. diff --git a/cargo-bazel-lock.json b/cargo-bazel-lock.json index 09a636fb..a81e93b1 100644 --- a/cargo-bazel-lock.json +++ b/cargo-bazel-lock.json @@ -1,5 +1,5 @@ { - "checksum": "eb681b06744f07595ac6d7e8682804eaecdd89a3d11c462d55c444e6b7fede5d", + "checksum": "eb825bb532c27d7ca9b1742de33af130925482788ca47c526d094c8f6982f8d0", "crates": { "adler2 2.0.1": { "name": "adler2", @@ -13163,6 +13163,65 @@ ], "license_file": "../../LICENSE" }, + "graphforge-filesystem 0.5.2": { + "name": "graphforge-filesystem", + "version": "0.5.2", + "package_url": "https://github.com/CurateLabs/graphforge", + "repository": null, + "targets": [ + { + "Library": { + "crate_name": "graphforge_filesystem", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "graphforge_filesystem", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "deps": { + "common": [], + "selects": { + "cfg(unix)": [ + { + "id": "rustix 1.1.4", + "target": "rustix" + } + ], + "cfg(windows)": [ + { + "id": "windows-sys 0.61.2", + "target": "windows_sys" + } + ] + } + }, + "deps_dev": { + "common": [ + { + "id": "tempfile 3.27.0", + "target": "tempfile" + } + ], + "selects": {} + }, + "edition": "2024", + "version": "0.5.2" + }, + "license": "Apache-2.0", + "license_ids": [ + "Apache-2.0" + ], + "license_file": "../../LICENSE" + }, "graphforge-io 0.5.2": { "name": "graphforge-io", "version": "0.5.2", @@ -13721,6 +13780,10 @@ "id": "sha2 0.11.0", "target": "sha2" }, + { + "id": "sysinfo 0.37.2", + "target": "sysinfo" + }, { "id": "tempfile 3.27.0", "target": "tempfile" @@ -13747,6 +13810,10 @@ { "id": "libc 0.2.189", "target": "libc" + }, + { + "id": "rustix 1.1.4", + "target": "rustix" } ], "cfg(windows)": [ @@ -18824,6 +18891,135 @@ ], "license_file": "LICENSE-APACHE" }, + "objc2-core-foundation 0.3.2": { + "name": "objc2-core-foundation", + "version": "0.3.2", + "package_url": "https://github.com/madsmtm/objc2", + "repository": { + "Http": { + "url": "https://static.crates.io/crates/objc2-core-foundation/0.3.2/download", + "sha256": "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" + } + }, + "targets": [ + { + "Library": { + "crate_name": "objc2_core_foundation", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "objc2_core_foundation", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "crate_features": { + "common": [ + "CFArray", + "CFBase", + "CFData", + "CFDictionary", + "CFError", + "CFNumber", + "CFPlugInCOM", + "CFRunLoop", + "CFString", + "CFURL", + "CFUUID", + "alloc", + "bitflags", + "std" + ], + "selects": {} + }, + "deps": { + "common": [ + { + "id": "bitflags 2.11.1", + "target": "bitflags" + } + ], + "selects": {} + }, + "edition": "2021", + "version": "0.3.2" + }, + "license": "Zlib OR Apache-2.0 OR MIT", + "license_ids": [ + "Apache-2.0", + "MIT", + "Zlib" + ], + "license_file": null + }, + "objc2-io-kit 0.3.2": { + "name": "objc2-io-kit", + "version": "0.3.2", + "package_url": "https://github.com/madsmtm/objc2", + "repository": { + "Http": { + "url": "https://static.crates.io/crates/objc2-io-kit/0.3.2/download", + "sha256": "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" + } + }, + "targets": [ + { + "Library": { + "crate_name": "objc2_io_kit", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "objc2_io_kit", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "crate_features": { + "common": [ + "alloc", + "libc", + "std" + ], + "selects": {} + }, + "deps": { + "common": [ + { + "id": "libc 0.2.189", + "target": "libc" + }, + { + "id": "objc2-core-foundation 0.3.2", + "target": "objc2_core_foundation" + } + ], + "selects": {} + }, + "edition": "2021", + "version": "0.3.2" + }, + "license": "Zlib OR Apache-2.0 OR MIT", + "license_ids": [ + "Apache-2.0", + "MIT", + "Zlib" + ], + "license_file": null + }, "object 0.37.3": { "name": "object", "version": "0.37.3", @@ -25346,6 +25542,85 @@ ], "license_file": null }, + "sysinfo 0.37.2": { + "name": "sysinfo", + "version": "0.37.2", + "package_url": "https://github.com/GuillaumeGomez/sysinfo", + "repository": { + "Http": { + "url": "https://static.crates.io/crates/sysinfo/0.37.2/download", + "sha256": "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" + } + }, + "targets": [ + { + "Library": { + "crate_name": "sysinfo", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "sysinfo", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "crate_features": { + "common": [ + "disk", + "objc2-io-kit" + ], + "selects": { + "aarch64-apple-darwin": [ + "objc2-core-foundation" + ], + "x86_64-pc-windows-msvc": [ + "windows" + ] + } + }, + "deps": { + "common": [], + "selects": { + "aarch64-apple-darwin": [ + { + "id": "objc2-core-foundation 0.3.2", + "target": "objc2_core_foundation" + }, + { + "id": "objc2-io-kit 0.3.2", + "target": "objc2_io_kit" + } + ], + "cfg(not(any(target_os = \"unknown\", target_arch = \"wasm32\")))": [ + { + "id": "libc 0.2.189", + "target": "libc" + } + ], + "x86_64-pc-windows-msvc": [ + { + "id": "windows 0.61.3", + "target": "windows" + } + ] + } + }, + "edition": "2024", + "version": "0.37.2" + }, + "license": "MIT", + "license_ids": [ + "MIT" + ], + "license_file": "LICENSE" + }, "tantivy 0.26.1": { "name": "tantivy", "version": "0.26.1", @@ -30398,6 +30673,135 @@ ], "license_file": "license-apache-2.0" }, + "windows 0.61.3": { + "name": "windows", + "version": "0.61.3", + "package_url": "https://github.com/microsoft/windows-rs", + "repository": { + "Http": { + "url": "https://static.crates.io/crates/windows/0.61.3/download", + "sha256": "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" + } + }, + "targets": [ + { + "Library": { + "crate_name": "windows", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "windows", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "crate_features": { + "common": [ + "Win32", + "Win32_Foundation", + "Win32_Security", + "Win32_Storage", + "Win32_Storage_FileSystem", + "Win32_System", + "Win32_System_IO", + "Win32_System_Ioctl", + "Win32_System_SystemServices", + "Win32_System_WindowsProgramming", + "default", + "std" + ], + "selects": {} + }, + "deps": { + "common": [ + { + "id": "windows-collections 0.2.0", + "target": "windows_collections" + }, + { + "id": "windows-core 0.61.2", + "target": "windows_core" + }, + { + "id": "windows-future 0.2.1", + "target": "windows_future" + }, + { + "id": "windows-link 0.1.3", + "target": "windows_link" + }, + { + "id": "windows-numerics 0.2.0", + "target": "windows_numerics" + } + ], + "selects": {} + }, + "edition": "2021", + "version": "0.61.3" + }, + "license": "MIT OR Apache-2.0", + "license_ids": [ + "Apache-2.0", + "MIT" + ], + "license_file": "license-apache-2.0" + }, + "windows-collections 0.2.0": { + "name": "windows-collections", + "version": "0.2.0", + "package_url": "https://github.com/microsoft/windows-rs", + "repository": { + "Http": { + "url": "https://static.crates.io/crates/windows-collections/0.2.0/download", + "sha256": "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" + } + }, + "targets": [ + { + "Library": { + "crate_name": "windows_collections", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "windows_collections", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "deps": { + "common": [ + { + "id": "windows-core 0.61.2", + "target": "windows_core" + } + ], + "selects": {} + }, + "edition": "2021", + "version": "0.2.0" + }, + "license": "MIT OR Apache-2.0", + "license_ids": [ + "Apache-2.0", + "MIT" + ], + "license_file": "license-apache-2.0" + }, "windows-core 0.53.0": { "name": "windows-core", "version": "0.53.0", @@ -30456,6 +30860,81 @@ ], "license_file": "license-apache-2.0" }, + "windows-core 0.61.2": { + "name": "windows-core", + "version": "0.61.2", + "package_url": "https://github.com/microsoft/windows-rs", + "repository": { + "Http": { + "url": "https://static.crates.io/crates/windows-core/0.61.2/download", + "sha256": "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" + } + }, + "targets": [ + { + "Library": { + "crate_name": "windows_core", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "windows_core", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "crate_features": { + "common": [ + "std" + ], + "selects": {} + }, + "deps": { + "common": [ + { + "id": "windows-link 0.1.3", + "target": "windows_link" + }, + { + "id": "windows-result 0.3.4", + "target": "windows_result" + }, + { + "id": "windows-strings 0.4.2", + "target": "windows_strings" + } + ], + "selects": {} + }, + "edition": "2021", + "proc_macro_deps": { + "common": [ + { + "id": "windows-implement 0.60.2", + "target": "windows_implement" + }, + { + "id": "windows-interface 0.59.3", + "target": "windows_interface" + } + ], + "selects": {} + }, + "version": "0.61.2" + }, + "license": "MIT OR Apache-2.0", + "license_ids": [ + "Apache-2.0", + "MIT" + ], + "license_file": "license-apache-2.0" + }, "windows-core 0.62.2": { "name": "windows-core", "version": "0.62.2", @@ -30525,6 +31004,62 @@ ], "license_file": "license-apache-2.0" }, + "windows-future 0.2.1": { + "name": "windows-future", + "version": "0.2.1", + "package_url": "https://github.com/microsoft/windows-rs", + "repository": { + "Http": { + "url": "https://static.crates.io/crates/windows-future/0.2.1/download", + "sha256": "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" + } + }, + "targets": [ + { + "Library": { + "crate_name": "windows_future", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "windows_future", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "deps": { + "common": [ + { + "id": "windows-core 0.61.2", + "target": "windows_core" + }, + { + "id": "windows-link 0.1.3", + "target": "windows_link" + }, + { + "id": "windows-threading 0.1.0", + "target": "windows_threading" + } + ], + "selects": {} + }, + "edition": "2021", + "version": "0.2.1" + }, + "license": "MIT OR Apache-2.0", + "license_ids": [ + "Apache-2.0", + "MIT" + ], + "license_file": "license-apache-2.0" + }, "windows-implement 0.60.2": { "name": "windows-implement", "version": "0.60.2", @@ -30637,6 +31172,45 @@ ], "license_file": "license-apache-2.0" }, + "windows-link 0.1.3": { + "name": "windows-link", + "version": "0.1.3", + "package_url": "https://github.com/microsoft/windows-rs", + "repository": { + "Http": { + "url": "https://static.crates.io/crates/windows-link/0.1.3/download", + "sha256": "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + } + }, + "targets": [ + { + "Library": { + "crate_name": "windows_link", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "windows_link", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "edition": "2021", + "version": "0.1.3" + }, + "license": "MIT OR Apache-2.0", + "license_ids": [ + "Apache-2.0", + "MIT" + ], + "license_file": "license-apache-2.0" + }, "windows-link 0.2.1": { "name": "windows-link", "version": "0.2.1", @@ -30676,6 +31250,58 @@ ], "license_file": "license-apache-2.0" }, + "windows-numerics 0.2.0": { + "name": "windows-numerics", + "version": "0.2.0", + "package_url": "https://github.com/microsoft/windows-rs", + "repository": { + "Http": { + "url": "https://static.crates.io/crates/windows-numerics/0.2.0/download", + "sha256": "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" + } + }, + "targets": [ + { + "Library": { + "crate_name": "windows_numerics", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "windows_numerics", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "deps": { + "common": [ + { + "id": "windows-core 0.61.2", + "target": "windows_core" + }, + { + "id": "windows-link 0.1.3", + "target": "windows_link" + } + ], + "selects": {} + }, + "edition": "2021", + "version": "0.2.0" + }, + "license": "MIT OR Apache-2.0", + "license_ids": [ + "Apache-2.0", + "MIT" + ], + "license_file": "license-apache-2.0" + }, "windows-result 0.1.2": { "name": "windows-result", "version": "0.1.2", @@ -30731,6 +31357,60 @@ ], "license_file": "license-apache-2.0" }, + "windows-result 0.3.4": { + "name": "windows-result", + "version": "0.3.4", + "package_url": "https://github.com/microsoft/windows-rs", + "repository": { + "Http": { + "url": "https://static.crates.io/crates/windows-result/0.3.4/download", + "sha256": "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" + } + }, + "targets": [ + { + "Library": { + "crate_name": "windows_result", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "windows_result", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "crate_features": { + "common": [ + "std" + ], + "selects": {} + }, + "deps": { + "common": [ + { + "id": "windows-link 0.1.3", + "target": "windows_link" + } + ], + "selects": {} + }, + "edition": "2021", + "version": "0.3.4" + }, + "license": "MIT OR Apache-2.0", + "license_ids": [ + "Apache-2.0", + "MIT" + ], + "license_file": "license-apache-2.0" + }, "windows-result 0.4.1": { "name": "windows-result", "version": "0.4.1", @@ -30779,6 +31459,60 @@ ], "license_file": "license-apache-2.0" }, + "windows-strings 0.4.2": { + "name": "windows-strings", + "version": "0.4.2", + "package_url": "https://github.com/microsoft/windows-rs", + "repository": { + "Http": { + "url": "https://static.crates.io/crates/windows-strings/0.4.2/download", + "sha256": "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" + } + }, + "targets": [ + { + "Library": { + "crate_name": "windows_strings", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "windows_strings", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "crate_features": { + "common": [ + "std" + ], + "selects": {} + }, + "deps": { + "common": [ + { + "id": "windows-link 0.1.3", + "target": "windows_link" + } + ], + "selects": {} + }, + "edition": "2021", + "version": "0.4.2" + }, + "license": "MIT OR Apache-2.0", + "license_ids": [ + "Apache-2.0", + "MIT" + ], + "license_file": "license-apache-2.0" + }, "windows-strings 0.5.1": { "name": "windows-strings", "version": "0.5.1", @@ -30991,6 +31725,7 @@ "Win32_Networking", "Win32_Networking_WinSock", "Win32_Security", + "Win32_Security_Authorization", "Win32_Storage", "Win32_Storage_FileSystem", "Win32_System", @@ -31120,6 +31855,54 @@ ], "license_file": "license-apache-2.0" }, + "windows-threading 0.1.0": { + "name": "windows-threading", + "version": "0.1.0", + "package_url": "https://github.com/microsoft/windows-rs", + "repository": { + "Http": { + "url": "https://static.crates.io/crates/windows-threading/0.1.0/download", + "sha256": "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" + } + }, + "targets": [ + { + "Library": { + "crate_name": "windows_threading", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "windows_threading", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "deps": { + "common": [ + { + "id": "windows-link 0.1.3", + "target": "windows_link" + } + ], + "selects": {} + }, + "edition": "2021", + "version": "0.1.0" + }, + "license": "MIT OR Apache-2.0", + "license_ids": [ + "Apache-2.0", + "MIT" + ], + "license_file": "license-apache-2.0" + }, "windows_aarch64_gnullvm 0.52.6": { "name": "windows_aarch64_gnullvm", "version": "0.52.6", @@ -33324,6 +34107,7 @@ "graphforge-core 0.5.2": "crates/graphforge-core", "graphforge-cypher 0.5.2": "crates/graphforge-cypher", "graphforge-exec 0.5.2": "crates/graphforge-exec", + "graphforge-filesystem 0.5.2": "crates/graphforge-filesystem", "graphforge-io 0.5.2": "crates/graphforge-io", "graphforge-ir 0.5.2": "crates/graphforge-ir", "graphforge-knowledge 0.5.2": "crates/graphforge-knowledge", @@ -33449,6 +34233,13 @@ "x86_64-unknown-linux-gnu", "x86_64-unknown-nixos-gnu" ], + "cfg(not(any(target_os = \"unknown\", target_arch = \"wasm32\")))": [ + "aarch64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + "x86_64-unknown-nixos-gnu" + ], "cfg(not(target_arch = \"wasm32\"))": [ "aarch64-apple-darwin", "aarch64-unknown-linux-gnu", @@ -33543,17 +34334,20 @@ "parquet 58.3.0", "pyo3 0.28.3", "rayon 1.12.0", + "rustix 1.1.4", "serde 1.0.228", "serde_json 1.0.151", "serde_yaml_ng 0.9.36", "sha2 0.11.0", + "sysinfo 0.37.2", "tantivy 0.26.1", "tempfile 3.27.0", "thiserror 2.0.20", "tokio 1.53.1", "unicode-normalization 0.1.25", "ureq 3.4.0", - "uuid 1.24.0" + "uuid 1.24.0", + "windows-sys 0.61.2" ], "direct_dev_deps": [ "codspeed-divan-compat 5.0.1", diff --git a/crates/graphforge-bindings-node/THIRD_PARTY_NOTICES.md b/crates/graphforge-bindings-node/THIRD_PARTY_NOTICES.md index 550e359b..a38316e5 100644 --- a/crates/graphforge-bindings-node/THIRD_PARTY_NOTICES.md +++ b/crates/graphforge-bindings-node/THIRD_PARTY_NOTICES.md @@ -17,8 +17,8 @@ python3 scripts/generate_third_party_notices.py ## License overview -- Apache License 2.0 (312) -- MIT License (72) +- Apache License 2.0 (323) +- MIT License (73) - Unicode License v3 (19) - BSD 3-Clause "New" or "Revised" License (7) - ISC License (4) @@ -1611,18 +1611,27 @@ Software. Used by: - windows 0.53.0 +- windows 0.61.3 +- windows-collections 0.2.0 - windows-core 0.53.0 +- windows-core 0.61.2 - windows-core 0.62.2 +- windows-future 0.2.1 - windows-implement 0.60.2 - windows-interface 0.59.3 +- windows-link 0.1.3 - windows-link 0.2.1 +- windows-numerics 0.2.0 - windows-result 0.1.2 +- windows-result 0.3.4 - windows-result 0.4.1 +- windows-strings 0.4.2 - windows-strings 0.5.1 - windows-sys 0.52.0 - windows-sys 0.59.0 - windows-sys 0.61.2 - windows-targets 0.52.6 +- windows-threading 0.1.0 - windows_aarch64_gnullvm 0.52.6 - windows_aarch64_msvc 0.52.6 - windows_i686_gnu 0.52.6 @@ -6631,6 +6640,8 @@ Used by: - libc 0.2.189 - miniz_oxide 0.8.9 - num-conv 0.2.2 +- objc2-core-foundation 0.3.2 +- objc2-io-kit 0.3.2 - oneshot 0.1.13 - paste 1.0.15 - pin-project 1.1.13 @@ -8772,6 +8783,37 @@ SOFTWARE. ## MIT License +Used by: +- sysinfo 0.37.2 + +``` +The MIT License (MIT) + +Copyright (c) 2015 Guillaume Gomez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +-------------------------------------------------------------------------------- + +## MIT License + Used by: - twox-hash 2.1.2 diff --git a/crates/graphforge-bindings-py/THIRD_PARTY_NOTICES.md b/crates/graphforge-bindings-py/THIRD_PARTY_NOTICES.md index 550e359b..a38316e5 100644 --- a/crates/graphforge-bindings-py/THIRD_PARTY_NOTICES.md +++ b/crates/graphforge-bindings-py/THIRD_PARTY_NOTICES.md @@ -17,8 +17,8 @@ python3 scripts/generate_third_party_notices.py ## License overview -- Apache License 2.0 (312) -- MIT License (72) +- Apache License 2.0 (323) +- MIT License (73) - Unicode License v3 (19) - BSD 3-Clause "New" or "Revised" License (7) - ISC License (4) @@ -1611,18 +1611,27 @@ Software. Used by: - windows 0.53.0 +- windows 0.61.3 +- windows-collections 0.2.0 - windows-core 0.53.0 +- windows-core 0.61.2 - windows-core 0.62.2 +- windows-future 0.2.1 - windows-implement 0.60.2 - windows-interface 0.59.3 +- windows-link 0.1.3 - windows-link 0.2.1 +- windows-numerics 0.2.0 - windows-result 0.1.2 +- windows-result 0.3.4 - windows-result 0.4.1 +- windows-strings 0.4.2 - windows-strings 0.5.1 - windows-sys 0.52.0 - windows-sys 0.59.0 - windows-sys 0.61.2 - windows-targets 0.52.6 +- windows-threading 0.1.0 - windows_aarch64_gnullvm 0.52.6 - windows_aarch64_msvc 0.52.6 - windows_i686_gnu 0.52.6 @@ -6631,6 +6640,8 @@ Used by: - libc 0.2.189 - miniz_oxide 0.8.9 - num-conv 0.2.2 +- objc2-core-foundation 0.3.2 +- objc2-io-kit 0.3.2 - oneshot 0.1.13 - paste 1.0.15 - pin-project 1.1.13 @@ -8772,6 +8783,37 @@ SOFTWARE. ## MIT License +Used by: +- sysinfo 0.37.2 + +``` +The MIT License (MIT) + +Copyright (c) 2015 Guillaume Gomez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +-------------------------------------------------------------------------------- + +## MIT License + Used by: - twox-hash 2.1.2 diff --git a/crates/graphforge-cli/THIRD_PARTY_NOTICES.md b/crates/graphforge-cli/THIRD_PARTY_NOTICES.md index 550e359b..a38316e5 100644 --- a/crates/graphforge-cli/THIRD_PARTY_NOTICES.md +++ b/crates/graphforge-cli/THIRD_PARTY_NOTICES.md @@ -17,8 +17,8 @@ python3 scripts/generate_third_party_notices.py ## License overview -- Apache License 2.0 (312) -- MIT License (72) +- Apache License 2.0 (323) +- MIT License (73) - Unicode License v3 (19) - BSD 3-Clause "New" or "Revised" License (7) - ISC License (4) @@ -1611,18 +1611,27 @@ Software. Used by: - windows 0.53.0 +- windows 0.61.3 +- windows-collections 0.2.0 - windows-core 0.53.0 +- windows-core 0.61.2 - windows-core 0.62.2 +- windows-future 0.2.1 - windows-implement 0.60.2 - windows-interface 0.59.3 +- windows-link 0.1.3 - windows-link 0.2.1 +- windows-numerics 0.2.0 - windows-result 0.1.2 +- windows-result 0.3.4 - windows-result 0.4.1 +- windows-strings 0.4.2 - windows-strings 0.5.1 - windows-sys 0.52.0 - windows-sys 0.59.0 - windows-sys 0.61.2 - windows-targets 0.52.6 +- windows-threading 0.1.0 - windows_aarch64_gnullvm 0.52.6 - windows_aarch64_msvc 0.52.6 - windows_i686_gnu 0.52.6 @@ -6631,6 +6640,8 @@ Used by: - libc 0.2.189 - miniz_oxide 0.8.9 - num-conv 0.2.2 +- objc2-core-foundation 0.3.2 +- objc2-io-kit 0.3.2 - oneshot 0.1.13 - paste 1.0.15 - pin-project 1.1.13 @@ -8772,6 +8783,37 @@ SOFTWARE. ## MIT License +Used by: +- sysinfo 0.37.2 + +``` +The MIT License (MIT) + +Copyright (c) 2015 Guillaume Gomez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +-------------------------------------------------------------------------------- + +## MIT License + Used by: - twox-hash 2.1.2 diff --git a/crates/graphforge-filesystem/BUILD.bazel b/crates/graphforge-filesystem/BUILD.bazel new file mode 100644 index 00000000..e2c2a49b --- /dev/null +++ b/crates/graphforge-filesystem/BUILD.bazel @@ -0,0 +1,16 @@ +"""Bazel targets for audited GraphForge filesystem primitives.""" + +load("//tools/bazel:gf_rust.bzl", "gf_rust_library", "gf_rust_test") + +exports_files(["Cargo.toml"]) + +package(default_visibility = ["//visibility:public"]) + +gf_rust_library( + name = "graphforge_filesystem", +) + +gf_rust_test( + name = "graphforge_filesystem_test", + crate = ":graphforge_filesystem", +) diff --git a/crates/graphforge-filesystem/Cargo.toml b/crates/graphforge-filesystem/Cargo.toml new file mode 100644 index 00000000..588cce5f --- /dev/null +++ b/crates/graphforge-filesystem/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "graphforge-filesystem" +description = "Audited native filesystem primitives for GraphForge durability" +version.workspace = true +edition.workspace = true +license.workspace = true +license-file.workspace = true +repository.workspace = true + +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1", features = ["fs"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_IO", +] } + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/graphforge-filesystem/NOTICE b/crates/graphforge-filesystem/NOTICE new file mode 100644 index 00000000..7538d7ae --- /dev/null +++ b/crates/graphforge-filesystem/NOTICE @@ -0,0 +1,18 @@ +GraphForge +Copyright 2026 Curate Labs Inc. + +GraphForge v0.5.0 and later are licensed under the Apache License, Version 2.0. +Previously published v0.4.0 and earlier artifacts remain under the MIT License +shipped with those versions. + +The openCypher Technology Compatibility Kit material under tests/tck/ is +third-party material distributed under its own Apache License 2.0 and NOTICE. +Dependencies and other third-party materials remain under their respective +licenses and are not relicensed as part of GraphForge. + +A machine-generated inventory of third-party Rust dependency license texts for +binary redistributions (Python wheels, native Node addons, and the CLI) is in +legal/THIRD_PARTY_NOTICES.md. Published binary packages include a copy of that +file. Regenerate it with `make third-party-notices` after dependency changes. +Rust dependency SPDX allowlisting is enforced with `cargo deny check licenses` +using deny.toml. diff --git a/crates/graphforge-filesystem/src/lib.rs b/crates/graphforge-filesystem/src/lib.rs new file mode 100644 index 00000000..d88dfc6d --- /dev/null +++ b/crates/graphforge-filesystem/src/lib.rs @@ -0,0 +1,925 @@ +//! Audited native filesystem primitives used by GraphForge's durability +//! protocol. + +#![deny(unsafe_code)] + +use std::ffi::OsStr; +use std::fs::File; +use std::io; +use std::path::Path; + +/// Stable filesystem identity suitable for NTFS/ReFS and Unix filesystems. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FileIdentity { + /// Native volume/device identity. + pub volume_serial: u64, + /// Full native file identity (128-bit on ReFS; zero-extended inode on Unix). + pub file_id: [u8; 16], +} + +/// Create a durability-probe directory that is private to the current user. +/// +/// Unix uses mode `0700`. Windows installs a protected DACL that grants full +/// access only to the owner, LocalSystem, and local administrators. +pub fn create_private_directory(path: &Path) -> io::Result<()> { + create_private_directory_platform(path) +} + +/// Return the stable native volume/file identity of an open handle. +pub fn file_identity(file: &File) -> io::Result { + file_identity_platform(file) +} + +/// Return the stable native volume/file identity of a non-followed path. +pub fn path_identity(path: &Path) -> io::Result { + path_identity_platform(path) +} + +/// Return the native hard-link count of an open file handle. +pub fn file_link_count(file: &File) -> io::Result { + file_link_count_platform(file) +} + +/// Return the native hard-link count of a non-followed path. +pub fn path_link_count(path: &Path) -> io::Result { + path_link_count_platform(path) +} + +/// Failure classification for an attempted atomic replacement. +#[derive(Debug)] +pub enum ReplaceFileError { + /// The operating system rejected the operation and both named identities + /// were verified unchanged. The disposable replacement file may have had + /// streams or attributes changed by the operating system and must not be + /// reused after any failed call. + NotReplaced(io::Error), + /// The operating system reported failure after it may have moved or + /// modified one of the named files. The caller must reconcile from + /// authoritative persisted state. + StateUnknown(io::Error), +} + +impl std::fmt::Display for ReplaceFileError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotReplaced(error) => write!(formatter, "file was not replaced: {error}"), + Self::StateUnknown(error) => { + write!( + formatter, + "replacement state requires reconciliation: {error}" + ) + } + } + } +} + +impl std::error::Error for ReplaceFileError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(match self { + Self::NotReplaced(error) | Self::StateUnknown(error) => error, + }) + } +} + +/// Classify an OS-reported failed replacement from reconciled identities. +/// +/// This is public for the deterministic fault oracle; publication callers use +/// [`replace_file`] directly. +#[doc(hidden)] +#[must_use] +pub fn classify_failed_replacement( + error: io::Error, + source_before: FileIdentity, + target_before: FileIdentity, + source_after: Option, + target_after: Option, +) -> ReplaceFileError { + if source_after == Some(source_before) && target_after == Some(target_before) { + ReplaceFileError::NotReplaced(error) + } else { + ReplaceFileError::StateUnknown(error) + } +} + +/// Atomically replace an existing regular file with another regular file in +/// the same directory. +/// +/// Both files must already be closed and flushed. The caller remains +/// responsible for flushing the containing directory after this returns. +pub fn replace_file( + directory: &File, + source_name: &OsStr, + target_name: &OsStr, +) -> Result<(), ReplaceFileError> { + verify_single_component(source_name).map_err(ReplaceFileError::NotReplaced)?; + verify_single_component(target_name).map_err(ReplaceFileError::NotReplaced)?; + replace_file_platform(directory, source_name, target_name) +} + +/// Atomically install a new regular file without replacing an existing entry. +pub fn install_new_file( + directory: &File, + source_name: &OsStr, + target_name: &OsStr, +) -> io::Result<()> { + verify_single_component(source_name)?; + verify_single_component(target_name)?; + install_new_file_platform(directory, source_name, target_name) +} + +fn verify_single_component(name: &OsStr) -> io::Result<()> { + let mut components = Path::new(name).components(); + if !matches!(components.next(), Some(std::path::Component::Normal(_))) + || components.next().is_some() + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "filesystem operation requires one plain name", + )); + } + Ok(()) +} + +fn verify_regular_metadata(metadata: &std::fs::Metadata) -> io::Result<()> { + if is_link_or_reparse(metadata) || !metadata.is_file() || link_count(metadata) != 1 { + return Err(io::Error::other( + "replacement path is not a regular non-link file", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn is_link_or_reparse(metadata: &std::fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt as _; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + metadata.file_type().is_symlink() + || (metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT) != 0 +} + +#[cfg(not(windows))] +fn is_link_or_reparse(metadata: &std::fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[cfg(unix)] +fn link_count(metadata: &std::fs::Metadata) -> u64 { + use std::os::unix::fs::MetadataExt as _; + metadata.nlink() +} + +#[cfg(windows)] +fn link_count(metadata: &std::fs::Metadata) -> u64 { + let _ = metadata; + 1 +} + +#[cfg(all(not(unix), not(windows)))] +fn link_count(_metadata: &std::fs::Metadata) -> u64 { + 0 +} + +#[cfg(unix)] +fn replace_file_platform( + directory: &File, + source_name: &OsStr, + target_name: &OsStr, +) -> Result<(), ReplaceFileError> { + use rustix::fs::{AtFlags, Mode, OFlags, openat, renameat, statat}; + + let source = openat( + directory, + source_name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map(File::from) + .map_err(io::Error::from) + .map_err(ReplaceFileError::NotReplaced)?; + let target = openat( + directory, + target_name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map(File::from) + .map_err(io::Error::from) + .map_err(ReplaceFileError::NotReplaced)?; + verify_regular_metadata(&source.metadata().map_err(ReplaceFileError::NotReplaced)?) + .map_err(ReplaceFileError::NotReplaced)?; + verify_regular_metadata(&target.metadata().map_err(ReplaceFileError::NotReplaced)?) + .map_err(ReplaceFileError::NotReplaced)?; + let source_identity = unix_identity(&source).map_err(ReplaceFileError::NotReplaced)?; + renameat(directory, source_name, directory, target_name) + .map_err(io::Error::from) + .map_err(ReplaceFileError::NotReplaced)?; + let replaced = openat( + directory, + target_name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map(File::from) + .map_err(io::Error::from) + .map_err(ReplaceFileError::StateUnknown)?; + if unix_identity(&replaced).map_err(ReplaceFileError::StateUnknown)? != source_identity + || statat(directory, source_name, AtFlags::SYMLINK_NOFOLLOW).is_ok() + { + return Err(ReplaceFileError::StateUnknown(io::Error::other( + "replacement success state did not reconcile", + ))); + } + Ok(()) +} + +#[cfg(unix)] +fn unix_identity(file: &File) -> io::Result { + use std::os::unix::fs::MetadataExt as _; + let metadata = file.metadata()?; + Ok(FileIdentity { + volume_serial: metadata.dev(), + file_id: u128::from(metadata.ino()).to_le_bytes(), + }) +} + +#[cfg(unix)] +fn file_identity_platform(file: &File) -> io::Result { + unix_identity(file) +} + +#[cfg(unix)] +fn path_identity_platform(path: &Path) -> io::Result { + use std::os::unix::fs::MetadataExt as _; + let metadata = std::fs::symlink_metadata(path)?; + Ok(FileIdentity { + volume_serial: metadata.dev(), + file_id: u128::from(metadata.ino()).to_le_bytes(), + }) +} + +#[cfg(unix)] +fn file_link_count_platform(file: &File) -> io::Result { + use std::os::unix::fs::MetadataExt as _; + Ok(file.metadata()?.nlink()) +} + +#[cfg(unix)] +fn path_link_count_platform(path: &Path) -> io::Result { + use std::os::unix::fs::MetadataExt as _; + Ok(std::fs::symlink_metadata(path)?.nlink()) +} + +#[cfg(unix)] +fn create_private_directory_platform(path: &Path) -> io::Result<()> { + use std::os::unix::fs::DirBuilderExt as _; + + let mut builder = std::fs::DirBuilder::new(); + builder.mode(0o700).create(path) +} + +#[cfg(windows)] +fn file_identity_platform(file: &File) -> io::Result { + windows::file_identity(file) +} + +#[cfg(windows)] +fn path_identity_platform(path: &Path) -> io::Result { + windows::identity(path) +} + +#[cfg(windows)] +fn file_link_count_platform(file: &File) -> io::Result { + windows::link_count(file) +} + +#[cfg(windows)] +fn path_link_count_platform(path: &Path) -> io::Result { + windows::path_link_count(path) +} + +#[cfg(unix)] +fn install_new_file_platform( + directory: &File, + source_name: &OsStr, + target_name: &OsStr, +) -> io::Result<()> { + use rustix::fs::{Mode, OFlags, RenameFlags, openat, renameat_with}; + + let source = openat( + directory, + source_name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map(File::from) + .map_err(io::Error::from)?; + verify_regular_metadata(&source.metadata()?)?; + let source_identity = unix_identity(&source)?; + renameat_with( + directory, + source_name, + directory, + target_name, + RenameFlags::NOREPLACE, + ) + .map_err(io::Error::from)?; + let installed = openat( + directory, + target_name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map(File::from) + .map_err(io::Error::from)?; + if unix_identity(&installed)? != source_identity { + return Err(io::Error::other("atomic creation state did not reconcile")); + } + Ok(()) +} + +#[cfg(windows)] +fn replace_file_platform( + directory: &File, + source_name: &OsStr, + target_name: &OsStr, +) -> Result<(), ReplaceFileError> { + windows::replace_file(directory, source_name, target_name) +} + +#[cfg(windows)] +fn install_new_file_platform( + directory: &File, + source_name: &OsStr, + target_name: &OsStr, +) -> io::Result<()> { + // Windows rename does not replace an existing destination. The explicit + // precheck supplies a stable AlreadyExists class; the OS operation remains + // the race-free authority. + windows::install_new_file(directory, source_name, target_name) +} + +#[cfg(windows)] +fn create_private_directory_platform(path: &Path) -> io::Result<()> { + windows::create_private_directory(path) +} + +#[cfg(all(not(unix), not(windows)))] +fn replace_file_platform( + _directory: &File, + _source_name: &OsStr, + _target_name: &OsStr, +) -> Result<(), ReplaceFileError> { + Err(ReplaceFileError::NotReplaced(io::Error::new( + io::ErrorKind::Unsupported, + "atomic replacement is unsupported on this platform", + ))) +} + +#[cfg(all(not(unix), not(windows)))] +fn install_new_file_platform( + _directory: &File, + _source_name: &OsStr, + _target_name: &OsStr, +) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "atomic creation is unsupported on this platform", + )) +} + +#[cfg(all(not(unix), not(windows)))] +fn create_private_directory_platform(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "private directory creation is unsupported on this platform", + )) +} + +#[cfg(all(not(unix), not(windows)))] +fn file_identity_platform(_file: &File) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "identity unsupported", + )) +} + +#[cfg(all(not(unix), not(windows)))] +fn path_identity_platform(_path: &Path) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "identity unsupported", + )) +} + +#[cfg(all(not(unix), not(windows)))] +fn file_link_count_platform(_file: &File) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "link count unsupported", + )) +} + +#[cfg(all(not(unix), not(windows)))] +fn path_link_count_platform(_path: &Path) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "link count unsupported", + )) +} + +#[cfg(windows)] +#[allow(unsafe_code)] +mod windows { + use std::ffi::OsStr; + use std::fs::File; + use std::io; + use std::os::windows::ffi::{OsStrExt as _, OsStringExt as _}; + use std::os::windows::fs::OpenOptionsExt as _; + use std::os::windows::io::AsRawHandle as _; + use std::path::{Path, PathBuf}; + + use windows_sys::Win32::Foundation::LocalFree; + #[cfg(test)] + use windows_sys::Win32::Security::Authorization::{ + ConvertSecurityDescriptorToStringSecurityDescriptorW, GetNamedSecurityInfoW, SE_FILE_OBJECT, + }; + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + #[cfg(test)] + use windows_sys::Win32::Security::{DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION}; + use windows_sys::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES}; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, CreateDirectoryW, FILE_FLAG_BACKUP_SEMANTICS, + FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FILE_NAME_NORMALIZED, FILE_SHARE_DELETE, + FILE_SHARE_READ, FILE_SHARE_WRITE, FileIdInfo, GetFileInformationByHandle, + GetFileInformationByHandleEx, GetFinalPathNameByHandleW, ReplaceFileW, VOLUME_NAME_DOS, + }; + + #[cfg(test)] + use super::classify_failed_replacement; + use super::{FileIdentity, ReplaceFileError, verify_regular_metadata}; + + pub(super) fn replace_file( + directory: &File, + source_name: &OsStr, + target_name: &OsStr, + ) -> Result<(), ReplaceFileError> { + let directory_path = directory_path(directory).map_err(ReplaceFileError::NotReplaced)?; + let source_path = directory_path.join(source_name); + let target_path = directory_path.join(target_name); + verify_windows_regular(&source_path).map_err(ReplaceFileError::NotReplaced)?; + verify_windows_regular(&target_path).map_err(ReplaceFileError::NotReplaced)?; + let source_before = identity(&source_path).map_err(ReplaceFileError::NotReplaced)?; + let target_before = identity(&target_path).map_err(ReplaceFileError::NotReplaced)?; + let source = wide(source_path.as_os_str()).map_err(ReplaceFileError::NotReplaced)?; + let target = wide(target_path.as_os_str()).map_err(ReplaceFileError::NotReplaced)?; + // SAFETY: both strings are owned, NUL-terminated UTF-16 buffers for + // the duration of the call. Optional backup/exclusion pointers are + // null as required when unused. ReplaceFileW has no supported flags. + let succeeded = unsafe { + ReplaceFileW( + target.as_ptr(), + source.as_ptr(), + std::ptr::null(), + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if succeeded != 0 { + return if identity(&target_path).ok() == Some(source_before) && !source_path.exists() { + Ok(()) + } else { + Err(ReplaceFileError::StateUnknown(io::Error::other( + "replacement success state did not reconcile", + ))) + }; + } + let error = io::Error::last_os_error(); + let source_after = identity(&source_path); + let target_after = identity(&target_path); + Err(super::classify_failed_replacement( + error, + source_before, + target_before, + source_after.ok(), + target_after.ok(), + )) + } + + pub(super) fn install_new_file( + directory: &File, + source_name: &OsStr, + target_name: &OsStr, + ) -> io::Result<()> { + let directory_path = directory_path(directory)?; + let source = directory_path.join(source_name); + let target = directory_path.join(target_name); + verify_windows_regular(&source)?; + match std::fs::symlink_metadata(&target) { + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "target exists", + )); + } + Err(error) => return Err(error), + } + let source_identity = identity(&source)?; + std::fs::rename(&source, &target)?; + if identity(&target)? != source_identity || source.exists() { + return Err(io::Error::other("atomic creation state did not reconcile")); + } + Ok(()) + } + + pub(super) fn directory_path(directory: &File) -> io::Result { + let handle = directory.as_raw_handle(); + // SAFETY: this is a live owned directory handle. A null output buffer + // with length zero is the documented size query. + let required = unsafe { + GetFinalPathNameByHandleW( + handle, + std::ptr::null_mut(), + 0, + FILE_NAME_NORMALIZED | VOLUME_NAME_DOS, + ) + }; + if required == 0 { + return Err(io::Error::last_os_error()); + } + let mut buffer = vec![0u16; usize::try_from(required).unwrap_or(usize::MAX) + 1]; + // SAFETY: the buffer is writable for its advertised size and the + // directory handle stays live through the call. + let written = unsafe { + GetFinalPathNameByHandleW( + handle, + buffer.as_mut_ptr(), + u32::try_from(buffer.len()).unwrap_or(u32::MAX), + FILE_NAME_NORMALIZED | VOLUME_NAME_DOS, + ) + }; + if written == 0 || usize::try_from(written).unwrap_or(usize::MAX) >= buffer.len() { + return Err(io::Error::last_os_error()); + } + buffer.truncate(usize::try_from(written).unwrap_or_default()); + Ok(PathBuf::from(std::ffi::OsString::from_wide(&buffer))) + } + + pub(super) fn create_private_directory(path: &Path) -> io::Result<()> { + let path = wide(path.as_os_str())?; + // Protected DACL: owner, LocalSystem, and local Administrators only. + let descriptor_text = wide(OsStr::new("D:P(A;;FA;;;OW)(A;;FA;;;SY)(A;;FA;;;BA)"))?; + let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: the input is a valid NUL-terminated SDDL buffer and the + // output pointer is valid for the duration of the call. + let converted = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + descriptor_text.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + std::ptr::null_mut(), + ) + }; + if converted == 0 { + return Err(io::Error::last_os_error()); + } + let attributes = SECURITY_ATTRIBUTES { + nLength: u32::try_from(std::mem::size_of::()) + .expect("SECURITY_ATTRIBUTES size fits u32"), + lpSecurityDescriptor: descriptor, + bInheritHandle: 0, + }; + // SAFETY: `path` and the descriptor backing `attributes` remain live + // through the call. The descriptor is released exactly once below. + let succeeded = unsafe { CreateDirectoryW(path.as_ptr(), &attributes) }; + let operation_error = if succeeded == 0 { + Some(io::Error::last_os_error()) + } else { + None + }; + // SAFETY: successful conversion allocated this descriptor with + // LocalAlloc; LocalFree is the documented matching release function. + let free_result = unsafe { LocalFree(descriptor.cast()) }; + if let Some(error) = operation_error { + return Err(error); + } + if !free_result.is_null() { + return Err(io::Error::other( + "private directory security descriptor release failed", + )); + } + Ok(()) + } + + fn verify_windows_regular(path: &Path) -> io::Result<()> { + let metadata = std::fs::symlink_metadata(path)?; + verify_regular_metadata(&metadata)?; + let file = open_identity_handle(path)?; + let information = information(&file)?; + if information.nNumberOfLinks != 1 { + return Err(io::Error::other("replacement path is hard linked")); + } + Ok(()) + } + + pub(super) fn identity(path: &Path) -> io::Result { + file_identity(&open_identity_handle(path)?) + } + + fn open_identity_handle(path: &Path) -> io::Result { + std::fs::OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS) + .open(path) + } + + pub(super) fn file_identity(file: &File) -> io::Result { + let mut information = FILE_ID_INFO::default(); + // SAFETY: the handle is live and the output buffer has exactly the + // FILE_ID_INFO size required by FileIdInfo. + let succeeded = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FileIdInfo, + (&mut information as *mut FILE_ID_INFO).cast(), + u32::try_from(std::mem::size_of::()) + .expect("FILE_ID_INFO size fits u32"), + ) + }; + if succeeded == 0 { + return Err(io::Error::last_os_error()); + } + Ok(FileIdentity { + volume_serial: information.VolumeSerialNumber, + file_id: information.FileId.Identifier, + }) + } + + pub(super) fn link_count(file: &File) -> io::Result { + Ok(u64::from(information(file)?.nNumberOfLinks)) + } + + pub(super) fn path_link_count(path: &Path) -> io::Result { + link_count(&open_identity_handle(path)?) + } + + fn information(file: &File) -> io::Result { + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + // SAFETY: the file owns a live handle and the output points to a fully + // allocated structure for the duration of the call. + let succeeded = + unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) }; + if succeeded == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(information) + } + } + + fn wide(value: &OsStr) -> io::Result> { + let mut encoded = value.encode_wide().collect::>(); + if encoded.contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "filesystem path contains NUL", + )); + } + encoded.push(0); + Ok(encoded) + } + + #[cfg(test)] + fn security_descriptor_sddl(path: &Path) -> io::Result { + let path = wide(path.as_os_str())?; + let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: all optional component outputs are null; the descriptor + // output is valid and released below with LocalFree. + let status = unsafe { + GetNamedSecurityInfoW( + path.as_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut descriptor, + ) + }; + if status != 0 { + return Err(io::Error::from_raw_os_error( + i32::try_from(status).unwrap_or(i32::MAX), + )); + } + let mut text = std::ptr::null_mut(); + let mut length = 0; + // SAFETY: the descriptor was returned by GetNamedSecurityInfoW and + // the output pointer/length are valid for this call. + let converted = unsafe { + ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor, + SDDL_REVISION_1, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &mut text, + &mut length, + ) + }; + if converted == 0 { + // SAFETY: descriptor ownership is ours after the successful query. + unsafe { LocalFree(descriptor.cast()) }; + return Err(io::Error::last_os_error()); + } + // SAFETY: conversion returns `length` initialized UTF-16 code units. + let result = String::from_utf16_lossy(unsafe { + std::slice::from_raw_parts(text, usize::try_from(length).unwrap_or_default()) + }); + // SAFETY: both allocations use LocalAlloc and are released once. + unsafe { + LocalFree(text.cast()); + LocalFree(descriptor.cast()); + } + Ok(result) + } + + #[cfg(test)] + mod tests { + use super::*; + + fn id(volume_serial: u64, low: u64) -> FileIdentity { + FileIdentity { + volume_serial, + file_id: u128::from(low).to_le_bytes(), + } + } + + #[test] + fn failed_replace_is_unknown_unless_both_identities_are_unchanged() { + let unchanged = classify_failed_replacement( + io::Error::other("injected"), + id(1, 2), + id(1, 3), + Some(id(1, 2)), + Some(id(1, 3)), + ); + assert!(matches!(unchanged, ReplaceFileError::NotReplaced(_))); + + for (source_after, target_after) in [ + (None, Some(id(1, 3))), + (Some(id(1, 2)), None), + (Some(id(1, 4)), Some(id(1, 3))), + (Some(id(1, 2)), Some(id(1, 4))), + ] { + assert!(matches!( + classify_failed_replacement( + io::Error::other("injected"), + id(1, 2), + id(1, 3), + source_after, + target_after, + ), + ReplaceFileError::StateUnknown(_) + )); + } + + let mut high_bits_changed = id(1, 2); + high_bits_changed.file_id[15] = 1; + assert!(matches!( + classify_failed_replacement( + io::Error::other("injected"), + id(1, 2), + id(1, 3), + Some(high_bits_changed), + Some(id(1, 3)), + ), + ReplaceFileError::StateUnknown(_) + )); + } + + #[test] + fn private_directory_dacl_is_protected_and_has_no_public_trustee() { + let parent = tempfile::tempdir().unwrap(); + let path = parent.path().join("private"); + create_private_directory(&path).unwrap(); + let sddl = security_descriptor_sddl(&path).unwrap(); + assert!(sddl.contains("D:P"), "{sddl}"); + assert!(!sddl.contains(";;;WD)"), "{sddl}"); + assert!(!sddl.contains(";;;AU)"), "{sddl}"); + assert!(!sddl.contains(";;;BU)"), "{sddl}"); + assert_eq!(sddl.matches("(A;").count(), 3, "{sddl}"); + } + + #[test] + fn junction_reparse_directory_is_detected_fail_closed() { + let parent = tempfile::tempdir().unwrap(); + let target = parent.path().join("target"); + let junction = parent.path().join("junction"); + std::fs::create_dir(&target).unwrap(); + let status = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&junction) + .arg(&target) + .status() + .unwrap(); + assert!(status.success()); + let metadata = std::fs::symlink_metadata(&junction).unwrap(); + assert!(super::super::is_link_or_reparse(&metadata)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn directory_handle(path: &Path) -> File { + #[cfg(unix)] + return File::open(path).unwrap(); + + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt as _; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + return std::fs::OpenOptions::new() + .read(true) + .write(true) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(path) + .unwrap(); + } + } + + #[test] + fn replacement_changes_exact_bytes_and_consumes_source() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("source"); + let target = directory.path().join("target"); + std::fs::write(&source, b"new").unwrap(); + std::fs::write(&target, b"old").unwrap(); + let handle = directory_handle(directory.path()); + replace_file(&handle, OsStr::new("source"), OsStr::new("target")).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"new"); + assert!(!source.exists()); + } + + #[test] + fn new_install_never_replaces_an_existing_entry() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("source"); + let target = directory.path().join("target"); + std::fs::write(&source, b"new").unwrap(); + let handle = directory_handle(directory.path()); + install_new_file(&handle, OsStr::new("source"), OsStr::new("target")).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"new"); + + let second = directory.path().join("second"); + std::fs::write(&second, b"other").unwrap(); + let error = + install_new_file(&handle, OsStr::new("second"), OsStr::new("target")).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read(&target).unwrap(), b"new"); + assert_eq!(std::fs::read(&second).unwrap(), b"other"); + } + + #[test] + fn hard_linked_inputs_are_rejected() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("source"); + let alias = directory.path().join("alias"); + let target = directory.path().join("target"); + std::fs::write(&source, b"new").unwrap(); + std::fs::hard_link(&source, &alias).unwrap(); + std::fs::write(&target, b"old").unwrap(); + assert!(matches!( + replace_file( + &directory_handle(directory.path()), + OsStr::new("source"), + OsStr::new("target") + ), + Err(ReplaceFileError::NotReplaced(_)) + )); + assert_eq!(std::fs::read(&target).unwrap(), b"old"); + } + + #[test] + fn private_directory_is_created_without_inheriting_public_access() { + let parent = tempfile::tempdir().unwrap(); + let directory = parent.path().join("private"); + create_private_directory(&directory).unwrap(); + assert!(directory.is_dir()); + let identity = path_identity(&directory).unwrap(); + assert_ne!(identity.file_id, [0; 16]); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + std::fs::metadata(directory).unwrap().permissions().mode() & 0o777, + 0o700 + ); + } + } +} diff --git a/crates/graphforge-storage/BUILD.bazel b/crates/graphforge-storage/BUILD.bazel index 154c701a..f8e3b390 100644 --- a/crates/graphforge-storage/BUILD.bazel +++ b/crates/graphforge-storage/BUILD.bazel @@ -8,6 +8,7 @@ package(default_visibility = ["//visibility:public"]) _STORAGE_DEPS = [ "//crates/graphforge-core:graphforge_core", + "//crates/graphforge-filesystem:graphforge_filesystem", "//crates/graphforge-ir:graphforge_ir", "//crates/graphforge-ontology:graphforge_ontology", ] diff --git a/crates/graphforge-storage/Cargo.toml b/crates/graphforge-storage/Cargo.toml index a555213c..519ba81a 100644 --- a/crates/graphforge-storage/Cargo.toml +++ b/crates/graphforge-storage/Cargo.toml @@ -15,6 +15,7 @@ test-failpoints = [] [dependencies] graphforge-core = { version = "0.5.2", path = "../graphforge-core" } +graphforge-filesystem = { version = "0.5.2", path = "../graphforge-filesystem" } graphforge-ir = { version = "0.5.2", path = "../graphforge-ir" } graphforge-ontology = { version = "0.5.2", path = "../graphforge-ontology" } arrow = { workspace = true } @@ -25,6 +26,7 @@ datafusion-catalog = "54" fs4 = "1.1" futures = { workspace = true } parquet = { workspace = true } +sysinfo = { version = "0.37.2", default-features = false, features = ["disk"] } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } @@ -36,6 +38,7 @@ uuid = { workspace = true } [target.'cfg(unix)'.dependencies] libc = "0.2" +rustix = { version = "1.1", features = ["fs"] } [target.'cfg(windows)'.dependencies] named-lock = "0.4.1" diff --git a/crates/graphforge-storage/src/filesystem_admission.rs b/crates/graphforge-storage/src/filesystem_admission.rs new file mode 100644 index 00000000..8233ee67 --- /dev/null +++ b/crates/graphforge-storage/src/filesystem_admission.rs @@ -0,0 +1,993 @@ +//! Native filesystem admission for the immutable-generation durability model. +//! +//! The probe is deliberately independent of project contents. It operates in +//! one private sibling below the canonical target parent and proves the same +//! replacement primitive used by durable publication. +//! +//! This admission check is a capability probe, not a sandbox boundary against +//! another process already running as the same OS principal. Private directory +//! permissions and the per-target parent lock coordinate GraphForge processes; +//! retained directory identity plus post-operation reconciliation ensure a +//! concurrent namespace change cannot produce a successful admission. + +use std::fs::File; +#[cfg(any(test, windows))] +use std::fs::OpenOptions; +use std::io::{Read as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use graphforge_core::{GfError, ProjectErrorCode}; +use sha2::{Digest as _, Sha256}; +use sysinfo::Disks; + +const PROBE_BYTES_A: &[u8] = b"graphforge-filesystem-probe/a\n"; +const PROBE_BYTES_B: &[u8] = b"graphforge-filesystem-probe/b\n"; +const MAX_PROBE_FILES: u64 = 3; +const MAX_PROBE_BYTES: u64 = (PROBE_BYTES_A.len() * 2 + PROBE_BYTES_B.len()) as u64; + +/// Content-free evidence from one successful native durability admission. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FilesystemAdmissionEvidence { + /// Stable, non-device-specific filesystem class (`apfs`, `ext4`, ...). + pub filesystem_class: String, + /// Number of private regular files created by the probe. + pub files_created: u64, + /// Maximum fixed payload bytes written by the probe. + pub bytes_written: u64, + /// Wall-clock duration used only for safe operational diagnostics. + pub elapsed_ms: u64, +} + +/// Prove that the proposed project location provides GraphForge's required +/// local publication primitives. +/// +/// The target itself is never created or mutated. The nearest parent must +/// already exist. Caller-controlled final-component links are rejected; an +/// ancestor aliases and traversal components are rejected before the canonical +/// parent is opened. +/// +/// # Errors +/// Every inability to prove the contract returns +/// `GF_UNSUPPORTED_FILESYSTEM`. The diagnostic contains only a phase and safe +/// cause class, never the supplied path. +pub fn filesystem_durability_preflight( + proposed_project_root: impl AsRef, +) -> Result { + filesystem_durability_preflight_inner(proposed_project_root.as_ref(), ProbeFault::None) +} + +fn filesystem_durability_preflight_inner( + proposed_project_root: &Path, + fault: ProbeFault, +) -> Result { + let started = Instant::now(); + let (parent, target_name) = canonical_parent_and_name(proposed_project_root)?; + let _probe_lock = lock_probe_parent(&parent, &target_name)?; + let parent_metadata = std::fs::metadata(&parent) + .map_err(|_| unsupported("CLASSIFY", "parent_metadata_unavailable"))?; + if !parent_metadata.is_dir() { + return Err(unsupported("CLASSIFY", "parent_not_directory")); + } + if let Ok(target_metadata) = std::fs::symlink_metadata(parent.join(&target_name)) { + if is_link_or_reparse(&target_metadata) || !target_metadata.is_dir() { + return Err(unsupported("CLASSIFY", "target_link_or_special")); + } + if !same_volume_paths(&parent, &parent.join(&target_name))? { + return Err(unsupported("CLASSIFY", "target_cross_volume")); + } + } + hit(fault, ProbeFault::Classify, "CLASSIFY")?; + let filesystem_class = classify_supported_local_volume(&parent)?; + + let probe_name = stable_probe_name(&parent, &target_name); + let probe_root = parent.join(&probe_name); + if probe_root.exists() { + let stale = open_probe_directory(&probe_root)?; + cleanup_probe(&parent, stale, ProbeFault::None)?; + } + let probe = match create_private_probe_directory(&parent, &probe_name, &probe_root) { + Ok(probe) => probe, + Err(create_error) => { + if probe_root.exists() { + let partial = open_probe_directory(&probe_root)?; + cleanup_probe(&parent, partial, ProbeFault::None)?; + } + return Err(create_error); + } + }; + + let probe_result = run_probe(&parent, &probe, fault); + let cleanup_result = cleanup_probe(&parent, probe, fault); + cleanup_result?; + probe_result?; + + Ok(FilesystemAdmissionEvidence { + filesystem_class, + files_created: MAX_PROBE_FILES, + bytes_written: MAX_PROBE_BYTES, + elapsed_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), + }) +} + +fn stable_probe_name(parent: &Path, target_name: &std::ffi::OsStr) -> String { + let mut digest = Sha256::new(); + digest.update(path_bytes(parent.as_os_str())); + digest.update([0]); + digest.update(path_bytes(target_name)); + let digest: [u8; 32] = digest.finalize().into(); + let mut encoded = String::with_capacity(64); + for byte in digest { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + format!(".graphforge-probe-{encoded}") +} + +#[cfg(unix)] +fn path_bytes(value: &std::ffi::OsStr) -> Vec { + use std::os::unix::ffi::OsStrExt as _; + value.as_bytes().to_vec() +} + +#[cfg(windows)] +fn path_bytes(value: &std::ffi::OsStr) -> Vec { + use std::os::windows::ffi::OsStrExt as _; + value + .encode_wide() + .flat_map(u16::to_le_bytes) + .collect::>() +} + +#[cfg(all(not(unix), not(windows)))] +fn path_bytes(value: &std::ffi::OsStr) -> Vec { + value.to_string_lossy().as_bytes().to_vec() +} + +#[cfg(unix)] +struct ProbeParentLock(File); + +#[cfg(unix)] +impl Drop for ProbeParentLock { + fn drop(&mut self) { + let _ = crate::file_lock::unlock(&self.0); + } +} + +#[cfg(unix)] +fn lock_probe_parent( + parent: &Path, + _target_name: &std::ffi::OsStr, +) -> Result { + let handle = File::open(parent).map_err(|_| unsupported("LOCK", "parent_open_failed"))?; + crate::file_lock::lock_exclusive(&handle) + .map_err(|_| unsupported("LOCK", "parent_lock_failed"))?; + Ok(ProbeParentLock(handle)) +} + +#[cfg(windows)] +fn lock_probe_parent( + parent: &Path, + target_name: &std::ffi::OsStr, +) -> Result { + let name = stable_probe_name(parent, target_name); + let lock = named_lock::NamedLock::create(&format!("GraphForge.{name}")) + .map_err(|_| unsupported("LOCK", "parent_lock_create_failed"))?; + lock.lock() + .map_err(|_| unsupported("LOCK", "parent_lock_failed")) +} + +#[cfg(all(not(unix), not(windows)))] +fn lock_probe_parent(_parent: &Path, _target_name: &std::ffi::OsStr) -> Result<(), GfError> { + Err(unsupported("LOCK", "parent_lock_unsupported")) +} + +fn canonical_parent_and_name(root: &Path) -> Result<(PathBuf, std::ffi::OsString), GfError> { + let absolute = if root.is_absolute() { + root.to_path_buf() + } else { + std::env::current_dir() + .map_err(|_| unsupported("CLASSIFY", "working_directory_unavailable"))? + .join(root) + }; + if absolute.components().any(|component| { + matches!( + component, + std::path::Component::CurDir | std::path::Component::ParentDir + ) + }) { + return Err(unsupported("CLASSIFY", "path_traversal")); + } + let name = absolute + .file_name() + .filter(|name| !name.is_empty()) + .ok_or_else(|| unsupported("CLASSIFY", "target_name_invalid"))? + .to_owned(); + let supplied_parent = absolute + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + reject_ancestor_links(supplied_parent)?; + let parent = supplied_parent + .canonicalize() + .map_err(|_| unsupported("CLASSIFY", "parent_unavailable"))?; + let target = parent.join(&name); + if let Ok(metadata) = std::fs::symlink_metadata(&target) + && metadata.file_type().is_symlink() + { + return Err(unsupported("CLASSIFY", "target_link")); + } + Ok((parent, name)) +} + +fn reject_ancestor_links(parent: &Path) -> Result<(), GfError> { + let mut current = PathBuf::new(); + for component in parent.components() { + current.push(component.as_os_str()); + if matches!( + component, + std::path::Component::Prefix(_) | std::path::Component::RootDir + ) { + continue; + } + let metadata = std::fs::symlink_metadata(¤t) + .map_err(|_| unsupported("CLASSIFY", "ancestor_unavailable"))?; + if is_link_or_reparse(&metadata) || !metadata.is_dir() { + return Err(unsupported("CLASSIFY", "ancestor_link_or_special")); + } + } + Ok(()) +} + +fn classify_supported_local_volume(parent: &Path) -> Result { + classify_supported_local_volume_platform(parent) +} + +#[cfg(target_os = "macos")] +fn classify_supported_local_volume_platform(parent: &Path) -> Result { + let stat = rustix::fs::statfs(parent) + .map_err(|_| unsupported("CLASSIFY", "native_volume_query_failed"))?; + let class = stat + .f_fstypename + .iter() + .copied() + .take_while(|byte| *byte != 0) + .map(|byte| u8::try_from(byte).unwrap_or_default()) + .collect::>(); + let class = std::str::from_utf8(&class) + .map_err(|_| unsupported("CLASSIFY", "filesystem_class_invalid"))? + .to_ascii_lowercase(); + if class != "apfs" { + return Err(unsupported("CLASSIFY", "filesystem_class_unproven")); + } + let flags = stat.f_flags; + if (flags & u32::try_from(libc::MNT_LOCAL).unwrap_or(u32::MAX)) == 0 { + return Err(unsupported("CLASSIFY", "volume_not_local")); + } + if (flags & u32::try_from(libc::MNT_RDONLY).unwrap_or(u32::MAX)) != 0 { + return Err(unsupported("CLASSIFY", "volume_read_only")); + } + reject_removable_volume(parent)?; + Ok(class) +} + +#[cfg(target_os = "linux")] +fn classify_supported_local_volume_platform(parent: &Path) -> Result { + let stat = rustix::fs::statfs(parent) + .map_err(|_| unsupported("CLASSIFY", "native_volume_query_failed"))?; + let class = match u64::try_from(stat.f_type).unwrap_or_default() { + 0xEF53 => "ext", + 0x5846_5342 => "xfs", + 0x9123_683E => "btrfs", + _ => return Err(unsupported("CLASSIFY", "filesystem_class_unproven")), + }; + let vfs = rustix::fs::statvfs(parent) + .map_err(|_| unsupported("CLASSIFY", "native_volume_query_failed"))?; + if vfs.f_flag.contains(rustix::fs::StatVfsMountFlags::RDONLY) { + return Err(unsupported("CLASSIFY", "volume_read_only")); + } + reject_removable_volume(parent)?; + Ok(class.into()) +} + +#[cfg(target_os = "windows")] +fn classify_supported_local_volume_platform(parent: &Path) -> Result { + let disks = Disks::new_with_refreshed_list(); + let disk = disks + .list() + .iter() + .filter(|disk| parent.starts_with(disk.mount_point())) + .max_by_key(|disk| disk.mount_point().components().count()) + .ok_or_else(|| unsupported("CLASSIFY", "volume_unknown"))?; + if disk.is_read_only() { + return Err(unsupported("CLASSIFY", "volume_read_only")); + } + if disk.is_removable() { + return Err(unsupported("CLASSIFY", "volume_removable")); + } + let class = disk.file_system().to_string_lossy().to_ascii_lowercase(); + if !matches!(class.as_str(), "ntfs" | "refs") { + return Err(unsupported("CLASSIFY", "filesystem_class_unproven")); + } + Ok(class) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +fn classify_supported_local_volume_platform(_parent: &Path) -> Result { + Err(unsupported("CLASSIFY", "platform_unsupported")) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn reject_removable_volume(parent: &Path) -> Result<(), GfError> { + let disks = Disks::new_with_refreshed_list(); + let disk = disks + .list() + .iter() + .filter(|disk| parent.starts_with(disk.mount_point())) + .max_by_key(|disk| disk.mount_point().components().count()) + .ok_or_else(|| unsupported("CLASSIFY", "device_identity_unknown"))?; + if disk.is_removable() { + return Err(unsupported("CLASSIFY", "volume_removable")); + } + Ok(()) +} + +struct ProbeDirectory { + path: PathBuf, + handle: File, + identity: graphforge_filesystem::FileIdentity, +} + +impl ProbeDirectory { + fn revalidate(&self, phase: &'static str) -> Result<(), GfError> { + let named = std::fs::symlink_metadata(&self.path) + .map_err(|_| unsupported(phase, "private_directory_missing"))?; + let opened = self + .handle + .metadata() + .map_err(|_| unsupported(phase, "private_directory_handle_unreadable"))?; + if is_link_or_reparse(&named) + || !named.is_dir() + || !opened.is_dir() + || graphforge_filesystem::path_identity(&self.path) + .map_err(|_| unsupported(phase, "private_directory_identity_unavailable"))? + != self.identity + || file_identity(&self.handle)? != self.identity + { + return Err(unsupported(phase, "private_directory_identity_changed")); + } + Ok(()) + } +} + +fn open_probe_directory(path: &Path) -> Result { + let named = std::fs::symlink_metadata(path) + .map_err(|_| unsupported("CREATE", "private_directory_missing"))?; + if is_link_or_reparse(&named) || !named.is_dir() { + return Err(unsupported("CREATE", "private_directory_substituted")); + } + let handle = open_directory_handle(path) + .map_err(|_| unsupported("CREATE", "private_directory_open_failed"))?; + let opened = handle + .metadata() + .map_err(|_| unsupported("CREATE", "private_directory_handle_unreadable"))?; + let identity = graphforge_filesystem::path_identity(path) + .map_err(|_| unsupported("CREATE", "private_directory_identity_unavailable"))?; + if !opened.is_dir() || file_identity(&handle)? != identity { + return Err(unsupported( + "CREATE", + "private_directory_substituted_during_open", + )); + } + Ok(ProbeDirectory { + path: path.to_path_buf(), + handle, + identity, + }) +} + +#[cfg(unix)] +fn open_directory_handle(path: &Path) -> std::io::Result { + use rustix::fs::{Mode, OFlags, open}; + + let handle = open( + path, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + Ok(File::from(handle)) +} + +#[cfg(windows)] +fn open_directory_handle(path: &Path) -> std::io::Result { + use std::os::windows::fs::OpenOptionsExt as _; + + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + OpenOptions::new() + .read(true) + .write(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) +} + +#[cfg(all(not(unix), not(windows)))] +fn open_directory_handle(_path: &Path) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "directory identity handles are unsupported", + )) +} + +fn create_private_probe_directory( + parent: &Path, + _probe_name: &str, + probe_root: &Path, +) -> Result { + graphforge_filesystem::create_private_directory(probe_root) + .map_err(|_| unsupported("CREATE", "private_directory_create_failed"))?; + let probe = open_probe_directory(probe_root)?; + sync_directory(parent).map_err(|_| unsupported("CREATE", "parent_flush_failed"))?; + probe.revalidate("CREATE")?; + Ok(probe) +} + +fn run_probe(parent: &Path, probe: &ProbeDirectory, fault: ProbeFault) -> Result<(), GfError> { + probe.revalidate("CREATE")?; + if !same_volume_paths(parent, &probe.path)? { + return Err(unsupported("CREATE", "private_directory_cross_volume")); + } + + let lock_path = probe.path.join("lock"); + let mut lock = create_new_file(probe, "lock")?; + lock.write_all(PROBE_BYTES_A) + .map_err(|_| unsupported("WRITE", "lock_file_write_failed"))?; + hit(fault, ProbeFault::Write, "WRITE")?; + lock.sync_all() + .map_err(|_| unsupported("FILE_FLUSH", "lock_file_flush_failed"))?; + hit(fault, ProbeFault::FileFlush, "FILE_FLUSH")?; + verify_stable_identity(&lock, &lock_path, parent)?; + + crate::file_lock::lock_exclusive(&lock) + .map_err(|_| unsupported("LOCK", "exclusive_lock_failed"))?; + let contender = open_regular_non_link(probe, "lock")?; + if crate::file_lock::try_lock_shared(&contender) + .map_err(|_| unsupported("LOCK", "contention_check_failed"))? + { + let _ = crate::file_lock::unlock(&contender); + return Err(unsupported("LOCK", "exclusive_lock_not_enforced")); + } + verify_stable_identity(&lock, &lock_path, parent)?; + crate::file_lock::unlock(&lock).map_err(|_| unsupported("LOCK", "exclusive_unlock_failed"))?; + + crate::file_lock::lock_shared(&lock).map_err(|_| unsupported("LOCK", "shared_lock_failed"))?; + crate::file_lock::lock_shared(&contender) + .map_err(|_| unsupported("LOCK", "second_shared_lock_failed"))?; + let exclusive_contender = open_regular_non_link(probe, "lock")?; + if crate::file_lock::try_lock_exclusive(&exclusive_contender) + .map_err(|_| unsupported("LOCK", "shared_contention_check_failed"))? + { + let _ = crate::file_lock::unlock(&exclusive_contender); + return Err(unsupported("LOCK", "shared_lock_not_enforced")); + } + crate::file_lock::unlock(&contender) + .and_then(|()| crate::file_lock::unlock(&lock)) + .map_err(|_| unsupported("LOCK", "shared_unlock_failed"))?; + hit(fault, ProbeFault::Lock, "LOCK")?; + + let (target, target_identity, target_path) = replace_probe_file(probe, fault)?; + probe.revalidate("DIRECTORY_FLUSH")?; + probe + .handle + .sync_all() + .map_err(|_| unsupported("DIRECTORY_FLUSH", "probe_flush_failed"))?; + hit(fault, ProbeFault::DirectoryFlush, "DIRECTORY_FLUSH")?; + + // The open handle must keep the old identity while the pathname now names + // the replacement. This proves stable locked/open file identity across the + // exact replacement primitive publication will consume. + probe.revalidate("IDENTITY")?; + hit(fault, ProbeFault::Identity, "IDENTITY")?; + if file_identity(&target)? != target_identity { + return Err(unsupported("IDENTITY", "open_identity_changed")); + } + let mut published = open_regular_non_link(probe, "published")?; + if file_identity(&published)? == target_identity { + return Err(unsupported("IDENTITY", "pathname_identity_not_replaced")); + } + let mut bytes = Vec::new(); + published + .read_to_end(&mut bytes) + .map_err(|_| unsupported("IDENTITY", "replacement_read_failed"))?; + if bytes != PROBE_BYTES_B { + return Err(unsupported("IDENTITY", "replacement_bytes_mismatch")); + } + verify_stable_identity(&published, &target_path, parent)?; + drop(target); + sync_directory(parent).map_err(|_| unsupported("DIRECTORY_FLUSH", "parent_flush_failed")) +} + +fn replace_probe_file( + probe: &ProbeDirectory, + fault: ProbeFault, +) -> Result<(File, graphforge_filesystem::FileIdentity, PathBuf), GfError> { + probe.revalidate("REPLACE")?; + let mut initial = create_new_file(probe, "initial")?; + initial + .write_all(PROBE_BYTES_A) + .and_then(|()| initial.sync_all()) + .map_err(|_| unsupported("REPLACE", "initial_file_flush_failed"))?; + drop(initial); + graphforge_filesystem::install_new_file( + &probe.handle, + std::ffi::OsStr::new("initial"), + std::ffi::OsStr::new("published"), + ) + .map_err(|_| unsupported("REPLACE", "atomic_create_failed"))?; + + let target_path = probe.path.join("published"); + let target = open_regular_non_link(probe, "published")?; + let target_identity = file_identity(&target)?; + let replacement_path = probe.path.join("replacement"); + let mut replacement = create_new_file(probe, "replacement")?; + replacement + .write_all(PROBE_BYTES_B) + .and_then(|()| replacement.sync_all()) + .map_err(|_| unsupported("REPLACE", "replacement_file_flush_failed"))?; + drop(replacement); + hit(fault, ProbeFault::Replace, "REPLACE")?; + + let replacement_result = if fault == ProbeFault::ReplaceUnknown { + let source_before = graphforge_filesystem::path_identity(&replacement_path) + .map_err(|_| unsupported("REPLACE", "source_identity_unavailable"))?; + let target_before = graphforge_filesystem::path_identity(&target_path) + .map_err(|_| unsupported("REPLACE", "target_identity_unavailable"))?; + graphforge_filesystem::replace_file( + &probe.handle, + std::ffi::OsStr::new("replacement"), + std::ffi::OsStr::new("published"), + ) + .map_err(|_| unsupported("REPLACE", "fault_setup_replace_failed"))?; + Err(graphforge_filesystem::classify_failed_replacement( + std::io::Error::other("injected OS failure after namespace mutation"), + source_before, + target_before, + graphforge_filesystem::path_identity(&replacement_path).ok(), + graphforge_filesystem::path_identity(&target_path).ok(), + )) + } else { + graphforge_filesystem::replace_file( + &probe.handle, + std::ffi::OsStr::new("replacement"), + std::ffi::OsStr::new("published"), + ) + }; + match replacement_result { + Ok(()) => Ok((target, target_identity, target_path)), + Err(graphforge_filesystem::ReplaceFileError::NotReplaced(_)) => { + Err(unsupported("REPLACE", "atomic_replace_not_applied")) + } + Err(graphforge_filesystem::ReplaceFileError::StateUnknown(_)) => { + Err(unsupported("REPLACE", "atomic_replace_state_unknown")) + } + } +} + +fn create_new_file(probe: &ProbeDirectory, name: &str) -> Result { + let file = open_probe_file(probe, name, true) + .map_err(|_| unsupported("CREATE", "exclusive_file_create_failed"))?; + let metadata = file + .metadata() + .map_err(|_| unsupported("CREATE", "created_file_metadata_failed"))?; + if !metadata.is_file() + || graphforge_filesystem::file_link_count(&file) + .map_err(|_| unsupported("CREATE", "created_file_link_count_unavailable"))? + != 1 + { + return Err(unsupported("CREATE", "created_file_identity_invalid")); + } + Ok(file) +} + +fn open_regular_non_link(probe: &ProbeDirectory, name: &str) -> Result { + let path = probe.path.join(name); + let metadata = std::fs::symlink_metadata(&path) + .map_err(|_| unsupported("IDENTITY", "path_metadata_failed"))?; + if is_link_or_reparse(&metadata) + || !metadata.is_file() + || graphforge_filesystem::path_link_count(&path) + .map_err(|_| unsupported("IDENTITY", "path_link_count_unavailable"))? + != 1 + { + return Err(unsupported("IDENTITY", "path_link_or_special")); + } + let file = open_probe_file(probe, name, false) + .map_err(|_| unsupported("IDENTITY", "file_open_failed"))?; + if file_identity(&file)? + != graphforge_filesystem::path_identity(&path) + .map_err(|_| unsupported("IDENTITY", "path_identity_unavailable"))? + { + return Err(unsupported("IDENTITY", "file_substituted_during_open")); + } + Ok(file) +} + +#[cfg(unix)] +fn open_probe_file(probe: &ProbeDirectory, name: &str, create: bool) -> std::io::Result { + use rustix::fs::{Mode, OFlags, openat}; + + let mut flags = OFlags::RDWR | OFlags::NOFOLLOW | OFlags::CLOEXEC; + if create { + flags |= OFlags::CREATE | OFlags::EXCL; + } + openat(&probe.handle, name, flags, Mode::RUSR | Mode::WUSR) + .map(File::from) + .map_err(std::io::Error::from) +} + +#[cfg(windows)] +fn open_probe_file(probe: &ProbeDirectory, name: &str, create: bool) -> std::io::Result { + let mut options = OpenOptions::new(); + options.read(true).write(true); + if create { + options.create_new(true); + } + options.open(probe.path.join(name)) +} + +#[cfg(all(not(unix), not(windows)))] +fn open_probe_file(_probe: &ProbeDirectory, _name: &str, _create: bool) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "probe files are unsupported", + )) +} + +fn verify_stable_identity(file: &File, path: &Path, parent: &Path) -> Result<(), GfError> { + let named = std::fs::symlink_metadata(path) + .map_err(|_| unsupported("IDENTITY", "named_metadata_failed"))?; + if is_link_or_reparse(&named) + || !named.is_file() + || graphforge_filesystem::file_link_count(file) + .map_err(|_| unsupported("IDENTITY", "opened_link_count_unavailable"))? + != 1 + || graphforge_filesystem::path_link_count(path) + .map_err(|_| unsupported("IDENTITY", "named_link_count_unavailable"))? + != 1 + || file_identity(file)? + != graphforge_filesystem::path_identity(path) + .map_err(|_| unsupported("IDENTITY", "path_identity_unavailable"))? + || !same_volume_paths(parent, path)? + { + return Err(unsupported("IDENTITY", "stable_identity_unproven")); + } + Ok(()) +} + +fn cleanup_probe(parent: &Path, probe: ProbeDirectory, fault: ProbeFault) -> Result<(), GfError> { + hit(fault, ProbeFault::Cleanup, "CLEANUP")?; + probe.revalidate("CLEANUP")?; + let mut entries = Vec::new(); + for entry in std::fs::read_dir(&probe.path) + .map_err(|_| unsupported("CLEANUP", "private_directory_unreadable"))? + { + if entries.len() >= usize::try_from(MAX_PROBE_FILES).unwrap_or(usize::MAX) { + return Err(unsupported("CLEANUP", "private_entry_limit_exceeded")); + } + entries.push(entry.map_err(|_| unsupported("CLEANUP", "private_entry_unreadable"))?); + } + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + let name = entry.file_name(); + if name != "lock" && name != "initial" && name != "published" && name != "replacement" { + return Err(unsupported("CLEANUP", "private_entry_unknown")); + } + let metadata = std::fs::symlink_metadata(entry.path()) + .map_err(|_| unsupported("CLEANUP", "private_entry_metadata_failed"))?; + if is_link_or_reparse(&metadata) + || !metadata.is_file() + || graphforge_filesystem::path_link_count(&entry.path()) + .map_err(|_| unsupported("CLEANUP", "entry_link_count_unavailable"))? + != 1 + { + return Err(unsupported("CLEANUP", "private_entry_link_or_special")); + } + let size_limit = if name == "lock" || name == "initial" { + PROBE_BYTES_A.len() + } else if name == "replacement" { + PROBE_BYTES_B.len() + } else { + PROBE_BYTES_A.len().max(PROBE_BYTES_B.len()) + }; + if metadata.len() > u64::try_from(size_limit).unwrap_or(u64::MAX) { + return Err(unsupported("CLEANUP", "private_entry_size_exceeded")); + } + std::fs::remove_file(entry.path()) + .map_err(|_| unsupported("CLEANUP", "private_entry_remove_failed"))?; + } + probe.revalidate("CLEANUP")?; + let path = probe.path.clone(); + drop(probe.handle); + std::fs::remove_dir(path) + .map_err(|_| unsupported("CLEANUP", "private_directory_remove_failed"))?; + sync_directory(parent).map_err(|_| unsupported("CLEANUP", "parent_flush_failed")) +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> std::io::Result<()> { + File::open(path)?.sync_all() +} + +#[cfg(windows)] +fn sync_directory(path: &Path) -> std::io::Result<()> { + use std::os::windows::fs::OpenOptionsExt as _; + + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + OpenOptions::new() + .write(true) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path)? + .sync_all() +} + +#[cfg(all(not(unix), not(windows)))] +fn sync_directory(_path: &Path) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "directory flush is unsupported", + )) +} + +#[cfg(windows)] +fn is_link_or_reparse(metadata: &std::fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt as _; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + metadata.file_type().is_symlink() + || (metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT) != 0 +} + +#[cfg(not(windows))] +fn is_link_or_reparse(metadata: &std::fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +fn file_identity(file: &File) -> Result { + graphforge_filesystem::file_identity(file) + .map_err(|_| unsupported("IDENTITY", "opened_identity_unavailable")) +} + +fn same_volume_paths(left: &Path, right: &Path) -> Result { + let left = graphforge_filesystem::path_identity(left) + .map_err(|_| unsupported("IDENTITY", "left_volume_identity_unavailable"))?; + let right = graphforge_filesystem::path_identity(right) + .map_err(|_| unsupported("IDENTITY", "right_volume_identity_unavailable"))?; + Ok(left.volume_serial == right.volume_serial) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProbeFault { + None, + Classify, + Lock, + Write, + FileFlush, + Replace, + ReplaceUnknown, + DirectoryFlush, + Identity, + Cleanup, +} + +fn hit(actual: ProbeFault, expected: ProbeFault, phase: &'static str) -> Result<(), GfError> { + if actual == expected { + Err(unsupported(phase, "injected_failure")) + } else { + Ok(()) + } +} + +fn unsupported(phase: &'static str, cause: &'static str) -> GfError { + GfError::Project { + code: ProjectErrorCode::UnsupportedFilesystem, + message: format!("phase={phase} outcome=rejected cause={cause}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn canonical_tempdir() -> tempfile::TempDir { + let base = std::env::temp_dir().canonicalize().unwrap(); + tempfile::tempdir_in(base).unwrap() + } + + #[test] + fn native_probe_is_bounded_content_free_and_cleans_up() { + let parent = canonical_tempdir(); + let target = parent.path().join("project"); + let evidence = filesystem_durability_preflight(&target).unwrap(); + assert!(matches!( + evidence.filesystem_class.as_str(), + "apfs" | "ext" | "ext2" | "ext3" | "ext4" | "xfs" | "btrfs" | "ntfs" | "refs" + )); + assert_eq!(evidence.files_created, 3); + assert_eq!(evidence.bytes_written, MAX_PROBE_BYTES); + assert!(!target.exists()); + assert_eq!(std::fs::read_dir(parent.path()).unwrap().count(), 0); + } + + #[test] + fn every_injected_phase_is_typed_and_never_mutates_target() { + for fault in [ + ProbeFault::Classify, + ProbeFault::Lock, + ProbeFault::Write, + ProbeFault::FileFlush, + ProbeFault::Replace, + ProbeFault::ReplaceUnknown, + ProbeFault::DirectoryFlush, + ProbeFault::Identity, + ProbeFault::Cleanup, + ] { + let parent = canonical_tempdir(); + let target = parent.path().join("project"); + let error = filesystem_durability_preflight_inner(&target, fault).unwrap_err(); + assert_eq!(error.code(), "GF_UNSUPPORTED_FILESYSTEM", "{fault:?}"); + assert!(!target.exists(), "{fault:?}"); + let entries = std::fs::read_dir(parent.path()).unwrap().count(); + if fault == ProbeFault::Cleanup { + assert_eq!(entries, 1, "cleanup failure retains one bounded sibling"); + filesystem_durability_preflight(&target).unwrap(); + assert_eq!( + std::fs::read_dir(parent.path()).unwrap().count(), + 0, + "a later admission reconciles the bounded stale probe" + ); + } else { + assert_eq!(entries, 0, "{fault:?}"); + } + } + } + + const LOCK_TEST_COOKIE: &str = "graphforge-779-native-lock-test"; + + #[test] + fn subprocess_lock_contender() { + if std::env::var("GF_779_LOCK_COOKIE").as_deref() != Ok(LOCK_TEST_COOKIE) { + return; + } + let path = PathBuf::from(std::env::var_os("GF_779_LOCK_PATH").unwrap()); + let file = OpenOptions::new() + .read(true) + .write(true) + .open(path) + .unwrap(); + assert!(!crate::file_lock::try_lock_shared(&file).unwrap()); + } + + #[test] + fn subprocess_crash_lock_holder() { + if std::env::var("GF_779_CRASH_COOKIE").as_deref() != Ok(LOCK_TEST_COOKIE) { + return; + } + let path = PathBuf::from(std::env::var_os("GF_779_LOCK_PATH").unwrap()); + let ready = PathBuf::from(std::env::var_os("GF_779_READY_PATH").unwrap()); + let file = OpenOptions::new() + .read(true) + .write(true) + .open(path) + .unwrap(); + crate::file_lock::lock_exclusive(&file).unwrap(); + let mut signal = File::create(ready).unwrap(); + signal.write_all(b"locked").unwrap(); + signal.sync_all().unwrap(); + std::process::abort(); + } + + #[test] + fn exclusive_lock_excludes_a_separate_process() { + let directory = canonical_tempdir(); + let path = directory.path().join("lock"); + let file = File::create(&path).unwrap(); + crate::file_lock::lock_exclusive(&file).unwrap(); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "filesystem_admission::tests::subprocess_lock_contender", + "--nocapture", + ]) + .env("GF_779_LOCK_COOKIE", LOCK_TEST_COOKIE) + .env("GF_779_LOCK_PATH", &path) + .status() + .unwrap(); + assert!(status.success()); + crate::file_lock::unlock(&file).unwrap(); + } + + #[test] + fn operating_system_releases_lock_after_process_crash() { + use wait_timeout::ChildExt as _; + + let directory = canonical_tempdir(); + let path = directory.path().join("lock"); + let ready = directory.path().join("ready"); + File::create(&path).unwrap(); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "filesystem_admission::tests::subprocess_crash_lock_holder", + "--nocapture", + ]) + .env("GF_779_CRASH_COOKIE", LOCK_TEST_COOKIE) + .env("GF_779_LOCK_PATH", &path) + .env("GF_779_READY_PATH", &ready) + .spawn() + .unwrap(); + let status = child + .wait_timeout(std::time::Duration::from_secs(10)) + .unwrap() + .expect("crash helper must terminate"); + assert!(!status.success()); + assert_eq!(std::fs::read(ready).unwrap(), b"locked"); + let file = OpenOptions::new() + .read(true) + .write(true) + .open(path) + .unwrap(); + assert!(crate::file_lock::try_lock_exclusive(&file).unwrap()); + crate::file_lock::unlock(&file).unwrap(); + } + + #[cfg(unix)] + #[test] + fn direct_target_link_is_rejected_without_touching_its_destination() { + use std::os::unix::fs::symlink; + + let parent = canonical_tempdir(); + let destination = parent.path().join("destination"); + std::fs::create_dir(&destination).unwrap(); + let target = parent.path().join("project"); + symlink(&destination, &target).unwrap(); + let error = filesystem_durability_preflight(&target).unwrap_err(); + assert_eq!(error.code(), "GF_UNSUPPORTED_FILESYSTEM"); + assert_eq!(std::fs::read_dir(&destination).unwrap().count(), 0); + } + + #[cfg(windows)] + #[test] + fn ancestor_junction_is_rejected_without_touching_its_destination() { + let parent = canonical_tempdir(); + let destination = parent.path().join("destination"); + let junction = parent.path().join("junction"); + std::fs::create_dir(&destination).unwrap(); + let status = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&junction) + .arg(&destination) + .status() + .unwrap(); + assert!(status.success()); + + let error = filesystem_durability_preflight(junction.join("project")).unwrap_err(); + assert_eq!(error.code(), "GF_UNSUPPORTED_FILESYSTEM"); + assert!(error.to_string().contains("ancestor_link_or_special")); + assert_eq!(std::fs::read_dir(&destination).unwrap().count(), 0); + } + + #[test] + fn ambiguous_replacement_state_is_typed_and_reconciled_by_cleanup() { + let parent = canonical_tempdir(); + let target = parent.path().join("project"); + let error = + filesystem_durability_preflight_inner(&target, ProbeFault::ReplaceUnknown).unwrap_err(); + assert_eq!(error.code(), "GF_UNSUPPORTED_FILESYSTEM"); + assert!(error.to_string().contains("atomic_replace_state_unknown")); + assert_eq!(std::fs::read_dir(parent.path()).unwrap().count(), 0); + filesystem_durability_preflight(&target).unwrap(); + } +} diff --git a/crates/graphforge-storage/src/lib.rs b/crates/graphforge-storage/src/lib.rs index 79010bb0..46ce95ee 100644 --- a/crates/graphforge-storage/src/lib.rs +++ b/crates/graphforge-storage/src/lib.rs @@ -11,6 +11,8 @@ #![forbid(unsafe_code)] mod file_lock; +#[doc(hidden)] +pub mod filesystem_admission; pub mod adjacency; pub mod adjacency_delta; diff --git a/docs/adr/0017-unified-release-version.md b/docs/adr/0017-unified-release-version.md index 1bd453da..1349a8e8 100644 --- a/docs/adr/0017-unified-release-version.md +++ b/docs/adr/0017-unified-release-version.md @@ -50,7 +50,7 @@ into independently versioned products. Every first-party artifact in one GraphForge release uses the same exact Semantic Version: -- all 15 public `graphforge-*` crates on crates.io; +- all 16 public `graphforge-*` crates on crates.io; - `graphforge` on PyPI; - `@curatelabs/graphforge` and its five native npm platform packages; - `@curatelabs/graphforge-cli`; diff --git a/docs/development/bazel-bootstrap.md b/docs/development/bazel-bootstrap.md index 8b7d1581..cc991d58 100644 --- a/docs/development/bazel-bootstrap.md +++ b/docs/development/bazel-bootstrap.md @@ -83,11 +83,11 @@ Credentials / OIDC stay outside cacheable Bazel actions (publish workflows uncha `project-skills/` remains a pure distribution tree (no `BUILD.bazel` payload); skills are declared via root `//:project_skills_bundle` / `//:project-skills/manifest.json`. -### Package coverage (17 workspace members) +### Package coverage (18 workspace members) | Class | Count | Status after #8 | | --- | ---: | --- | -| Ordinary `lib` mapped | 15 | foundation + runtime + CLI | +| Ordinary `lib` mapped | 16 | foundation + runtime + CLI | | Binding cdylibs mapped | 2 | PyO3 + napi-rs (#7) | | Integration-test mapped | 59 | `//:integration_tests` (+ BDD harness) | | CLI `bin` mapped | 1 | `//crates/graphforge-cli:gf` | diff --git a/docs/development/bazel-migration-ac-evidence.md b/docs/development/bazel-migration-ac-evidence.md index 0219aa08..b73584ba 100644 --- a/docs/development/bazel-migration-ac-evidence.md +++ b/docs/development/bazel-migration-ac-evidence.md @@ -38,7 +38,7 @@ Developer guide: [bazel.md](bazel.md). | #1 AC | Status | Child | Evidence pointer | | --- | --- | --- | --- | | Checked-in migration ledger for all Cargo targets and every CI/release build command | Met | #12 (+ updates #11–#6) | [bazel-migration-ledger.md](bazel-migration-ledger.md); `tools/bazel/parity/migration_target_map.json`; `scripts/ci/bazel-migration-ledger-check.py` | -| Bazel builds all 17 first-party packages without shelling out to Cargo for ordinary compilation or tests | Met | #11–#9, #8, #7 | [bazel-bootstrap.md](bazel-bootstrap.md); `//:first_party_libs`, `//:binding_cdylibs`, `//:ci_rust_tests`; merge SHAs above | +| Bazel builds all 18 first-party packages without shelling out to Cargo for ordinary compilation or tests | Met | #11–#9, #8, #7, #779 | [bazel-bootstrap.md](bazel-bootstrap.md); `//:first_party_libs`, `//:binding_cdylibs`, `//:ci_rust_tests`; merge SHAs above | | All 53 Rust integration tests, crate unit tests, doctest equivalents, BDD, snapshots, public-surface gates under mapped Bazel test graph | Met | #8, #6 | Ledger + `//:integration_tests` / `//:unit_tests` / `//:snapshot_tests` / `//:bdd_tests` / `//:ci_rust_tests`; [bazel-migration-parity.md](bazel-migration-parity.md) | | Bazel-built Python wheels and Node packages pass clean-install, no-fallback, parity, persistence/reopen, structured-error suites | Met | #7, #6, #720 | PEP 427 wheel naming + synthetic Node `version()` loader in `scripts/ci/assemble_bazel_binding_packages.py`; Binding RC `--out dist` (#760 / `71687b4d377a4293ae6b86176c9876cda4167722`); unit proof in `scripts/ci/test-assemble-bazel-binding-packages.py`; `//:python_wheel_smoke` / `//:node_package_smoke` | | Linux, macOS, Windows, and supported Node cross-target release evidence remains complete | Met | #6 | `tools/bazel/release/release_platforms.json`; `//platforms:*`; Binding RC contract unchanged | diff --git a/docs/development/bazel-migration-ledger.md b/docs/development/bazel-migration-ledger.md index 289be49a..36f6899b 100644 --- a/docs/development/bazel-migration-ledger.md +++ b/docs/development/bazel-migration-ledger.md @@ -12,14 +12,14 @@ Performance baseline: [bazel-migration-baseline.md](bazel-migration-baseline.md) | Freeze date (UTC) | 2026-08-06 | | Inventory SHA | `6e8b8e3fdc1ecd960eacf14a73e5be7b54fcef3c` | | Authoritative source | `cargo metadata --format-version=1 --no-deps` | -| Workspace packages | 17 | -| Cargo metadata targets | **100** | -| Bazel modeling claimed complete? | **Yes** — all 100 Cargo targets mapped (#10–#6 + #338 + #336 + #752 + #753); retained tools justified in exceptions | +| Workspace packages | 18 | +| Cargo metadata targets | **101** | +| Bazel modeling claimed complete? | **Yes** — all 101 Cargo targets mapped (#10–#6 + #338 + #336 + #752 + #753 + #779); retained tools justified in exceptions | | Machine-readable map | `tools/bazel/parity/migration_target_map.json` (fail-closed via `scripts/ci/bazel-migration-ledger-check.py`) | | Bootstrap note | See [bazel-bootstrap.md](bazel-bootstrap.md); parity evidence [bazel-migration-parity.md](bazel-migration-parity.md) | Issue #1 historically cited ~71 Cargo targets / ~53 integration-test binaries. -This freeze uses the **current authoritative** metadata count (**100** targets; +This freeze uses the **current authoritative** metadata count (**101** targets; **66** integration-test binaries). Later slices must update rows, not silently ignore new targets. @@ -27,13 +27,13 @@ not silently ignore new targets. | Class | Count | Notes | | --- | ---: | --- | -| `lib` | 15 | First-party libraries (unit/doctest surface rides these targets under `cargo test --lib` / doctests) | +| `lib` | 16 | First-party libraries (unit/doctest surface rides these targets under `cargo test --lib` / doctests) | | `integration-test` | 66 | `tests/*.rs` integration binaries | | `cdylib` | 2 | PyO3 + napi-rs native libs | | `bin` | 1 | CLI (`gf`) | | `custom-build` | 2 | `build.rs` scripts | | `example` | 11 | API examples mapped as `//crates/graphforge-api:` (#6) | -| **Total** | **100** | | +| **Total** | **101** | | ### Unit tests and doctests @@ -341,7 +341,7 @@ Retired PR sticky key pattern (do not reintroduce without rollback docs): | `scripts/ci/test-binding-release-candidate.py` | 805 | `f"{workflow.name} uses the unsupported napi artifacts --dir option"` | | `scripts/ci/test-binding-release-candidate.py` | 809 | `assert "exec napi build --platform --release" not in publish_text` | | `Makefile` | 39 | `publish-dry-run-python: ## Local maturin sdist packaging check (not TestPyPI upload)` | -| `Makefile` | 41 | `publish-dry-run-cargo: ## cargo package --list for all 15 crates.io packages in plan order` | +| `Makefile` | 41 | `publish-dry-run-cargo: ## cargo package --list for all 16 crates.io packages in plan order` | | `Makefile` | 66 | `cargo test -p graphforge-core --test bdd` | | `Makefile` | 84 | `echo " maturin develop --release -m crates/graphforge-bindings-py/Cargo.toml"; \` | | `Makefile` | 104 | `coverage-python: ## Run unit tests with Python wrapper coverage (requires maturin develop)` | diff --git a/docs/development/clean-environment-verification.md b/docs/development/clean-environment-verification.md index 301d9b01..67f27501 100644 --- a/docs/development/clean-environment-verification.md +++ b/docs/development/clean-environment-verification.md @@ -5,7 +5,7 @@ environments, using only public registries, install and exercise GraphForge release artifacts. This is **not** Binding RC evidence (local same-SHA wheels/addons) and **not** a -substitute for section 6 publication (#2794). If public `0.5.0` packages are missing, +substitute for section 6 publication (#2794). If public `0.5.2` packages are missing, verification must **fail closed** — do not check off children against unpublished artifacts. @@ -16,7 +16,7 @@ artifacts. | `pip` | #180 | `pip install graphforge==` + documented quickstart E2E | | `npm` / `cli` | #183 | Install `@curatelabs/graphforge@` and `@curatelabs/graphforge-cli@` + smoke execution | | `skills` | #182 | Install `@curatelabs/graphforge-agent-skills@` + offline `compatibility --json` | -| `cargo` | #185 | Add all 15 `graphforge-*` crates at `` and compile a clean consumer | +| `cargo` | #185 | Add all 16 `graphforge-*` crates at `` and compile a clean consumer | | `reopen` | #184 | Create/close/reopen project; Arrow rows survive reopen | | `urls` | #186 | Published docs, licensing, and package/registry URLs resolve (human HTML pages optional when CDNs block bots) | | `checksums` | #187 | Registry digests match `graphforge-release-record-v1` | @@ -38,20 +38,20 @@ tracker (#167) is intentionally post-release and does not block or auto-close #1 ```bash # Fail closed if public artifacts are missing (expected before section 6 completes) -python3 scripts/ci/clean-env-verify.py preflight --version 0.5.0 +python3 scripts/ci/clean-env-verify.py preflight --version 0.5.2 # Unit tests (no install success claims; includes live unpublished preflight) python3 scripts/ci/test-clean-env-verify.py make clean-env-verify-check # After publication — full local run (writes evidence JSON) -make clean-env-verify VERSION=0.5.0 \ +make clean-env-verify VERSION=0.5.2 \ RELEASE_RECORD=path/to/release-record.json \ OUTPUT=build/clean-env-evidence.json # Or per lane python3 scripts/ci/clean-env-verify.py run \ - --version 0.5.0 --lane pip --lane reopen \ + --version 0.5.2 --lane pip --lane reopen \ --output build/clean-env-pip.json ``` @@ -59,8 +59,8 @@ CI: workflow_dispatch **Clean Environment Verify** (`.github/workflows/clean-env-verify.yml`). Inputs: `version`, `lanes`, and optional `release_record_path`. Upload the evidence artifact to the matching child issues. -GraphForge v0.5.0 publishes 15 Rust packages under `graphforge-*`. The harness -probes all 15 by default and the `cargo` lane creates a clean consumer, adds +The current GraphForge release publishes 16 Rust packages under `graphforge-*`. +The harness probes all 16 by default and the `cargo` lane creates a clean consumer, adds the exact release version of every crate, and runs `cargo check`. ## Release record schema @@ -70,15 +70,15 @@ the exact release version of every crate, and runs `cargo check`. ```json { "schema": "graphforge-release-record-v1", - "version": "0.5.0", - "tag": "v0.5.0", + "version": "0.5.2", + "tag": "v0.5.2", "commit_sha": "<40-hex>", "artifacts": [ { "surface": "pypi", "name": "graphforge", - "version": "0.5.0", - "filename": "graphforge-0.5.0-….whl", + "version": "0.5.2", + "filename": "graphforge-0.5.2-….whl", "sha256": "<64-hex>" } ] diff --git a/docs/development/publication-order.md b/docs/development/publication-order.md index 1842d314..2277e4b6 100644 --- a/docs/development/publication-order.md +++ b/docs/development/publication-order.md @@ -1,6 +1,6 @@ -# Publication and recovery order (v0.5.1) +# Publication and recovery order (v0.5.2) -GraphForge v0.5.1 is one 24-node release: 15 crates.io crates, one PyPI +GraphForge v0.5.2 is one 25-node release: 16 crates.io crates, one PyPI project, five native npm packages, the npm main package, CLI, and agent skills. [ADR 0017](../adr/0017-unified-release-version.md) forbids a registry-specific version. Existing v0.5.0 tags, records, supplements, and published packages are @@ -123,20 +123,21 @@ write. Exhausted evidence requires a human decision. The finite order is generated by `scripts/ci/crate-publish-plan.py`: 1. `graphforge-core` -2. `graphforge-ast` -3. `graphforge-knowledge` -4. `graphforge-ontology` -5. `graphforge-provenance` -6. `graphforge-ir` -7. `graphforge-plan` -8. `graphforge-storage` -9. `graphforge-io` -10. `graphforge-rel` -11. `graphforge-search` -12. `graphforge-cypher` -13. `graphforge-exec` -14. `graphforge-api` -15. `graphforge-cli` +2. `graphforge-filesystem` +3. `graphforge-ast` +4. `graphforge-knowledge` +5. `graphforge-ontology` +6. `graphforge-provenance` +7. `graphforge-ir` +8. `graphforge-plan` +9. `graphforge-storage` +10. `graphforge-io` +11. `graphforge-rel` +12. `graphforge-search` +13. `graphforge-cypher` +14. `graphforge-exec` +15. `graphforge-api` +16. `graphforge-cli` Each invocation validates the retained `.crate` checksum before `cargo publish`. After an accepted write it observes the public version once. A verified result @@ -146,13 +147,13 @@ may unlock the next crate; pending or unsafe truth stops the finite loop. The final job uses `if: always()` and records every lane conclusion, including failure, cancellation, timeout, and skip. When the candidate is available it -re-observes all three registries and produces one stable 24-node summary. Job +re-observes all three registries and produces one stable 25-node summary. Job history is operator context only; registry state and the next safe actions come from the immutable candidate plus live registry truth. If candidate preflight failed before a manifest was available, reconciliation -still emits all 24 node identities as `indeterminate` and identifies the -candidate blocker. The workflow is green only when all 24 nodes are publicly +still emits all 25 node identities as `indeterminate` and identifies the +candidate blocker. The workflow is green only when all 25 nodes are publicly `verified`. The summary is retained for 30 days and contains no credentials, headers, cookies, tokens, or raw registry bodies. @@ -192,4 +193,4 @@ boundary explicit. Maintainers still make these decisions: v0.5.1 tag and GitHub Release; - decide any registry-specific yank/deprecation if reconciliation finds a conflict; and -- close the human release tracker only after the 24-node summary is complete. +- close the human release tracker only after the 25-node summary is complete. diff --git a/docs/development/release-artifact-record.md b/docs/development/release-artifact-record.md index 3305fe10..7c1e5c40 100644 --- a/docs/development/release-artifact-record.md +++ b/docs/development/release-artifact-record.md @@ -14,7 +14,7 @@ in [`publication-order.md`](publication-order.md). `graphforge-release-candidate-v2` has one root `version` and no per-node version field. The public node set is fixed: -- 15 `graphforge-*` crates on crates.io; +- 16 `graphforge-*` crates on crates.io; - `graphforge` on PyPI (three tested wheels and one source distribution); - five native npm packages and `@curatelabs/graphforge`; - `@curatelabs/graphforge-cli` and @@ -39,7 +39,7 @@ Candidate bytes are routed into four non-overlapping groups: | --- | --- | | `python` | Three tested wheels and one sdist | | `npm` | Five native packages, main package, CLI, and agent skills | -| `crates` | All 15 `.crate` archives | +| `crates` | All 16 `.crate` archives | | `evidence` | Five tested Node addons plus dry-run and legal reports | The small manifest lives beside those partitions. Each group declares its @@ -135,7 +135,7 @@ imports the native Python module, validates the full eight-package npm inventory, then installs only the host-compatible native tarball with main/CLI/skills offline, loads the Node native binding through the main package, executes the CLI and agent-skills entrypoints, and validates all -15 crate archives and their exact dependency graph. Only a passing report is +16 crate archives and their exact dependency graph. Only a passing report is added to the evidence partition; the temporary manifest is then removed and the final manifest is recorded over the now-complete inventory. @@ -172,7 +172,7 @@ Historical v0.5.0 records remain immutable. ## Sequential recovery proof `scripts/ci/release_rehearsal.py` also produces a stable reconciliation report -for all 24 public nodes. Its sequential simulator accepts only actions emitted +for all 25 public nodes. Its sequential simulator accepts only actions emitted by the pure recovery planner, applies one supplied live observation at a time, and re-plans from the updated registry truth. This proves dependency order and partial recovery before workflow parallelism is introduced. @@ -200,7 +200,7 @@ Before a write the lane persists a sanitized immutable attempt record; after a successful registry response it persists an accepted receipt and performs one public observation. Later recovery runs load both, so cancellation, timeout, or propagation delay cannot become permission for a second write. There is no polling loop. The `always()` -reconciliation job then observes all 24 nodes and combines those states with +reconciliation job then observes all 25 nodes and combines those states with job conclusions for operator context. See [`publication-order.md`](publication-order.md) for the recovery and human stop decisions. diff --git a/docs/development/release-process.md b/docs/development/release-process.md index bef11eb9..21c64f9c 100644 --- a/docs/development/release-process.md +++ b/docs/development/release-process.md @@ -32,7 +32,7 @@ the exact retained bytes must pass: - complete archive inventory and required-file validation; - one-version and first-party dependency validation; - Python, Node/native, CLI, and agent-skills offline consumers; -- all 15 crate package and dependency checks; and +- all 16 crate package and dependency checks; and - license and notice validation. A checksum match proves byte identity, not artifact completeness. @@ -68,7 +68,7 @@ Publication is planner-driven: - Crates publish in their checked dependency order. - Every lane re-observes public registry truth immediately before a write. - Accepted or ambiguous write attempts never authorize a duplicate write. -- The final reconciliation always runs and covers all 24 public nodes. +- The final reconciliation always runs and covers all 25 public nodes. Job history is operator context only. Recovery state comes from the immutable candidate, durable write evidence, retained artifacts, and live registry truth. diff --git a/docs/engineering/PUBLISHING.md b/docs/engineering/PUBLISHING.md index f079875d..870dcacc 100644 --- a/docs/engineering/PUBLISHING.md +++ b/docs/engineering/PUBLISHING.md @@ -72,7 +72,7 @@ pnpm docs:build # Publication tooling — authoritative order: # docs/development/publication-order.md python3 scripts/ci/crate-publish-plan.py check -# Cargo: package the complete 15-crate graph in dependency order. +# Cargo: package the complete 16-crate graph in dependency order. make publish-dry-run-cargo # Python: maturin / TestPyPI clean-install checks # Node / CLI / skills: npm publish --dry-run diff --git a/docs/engineering/TESTING.md b/docs/engineering/TESTING.md index 343f01f6..c5681b3a 100644 --- a/docs/engineering/TESTING.md +++ b/docs/engineering/TESTING.md @@ -114,10 +114,10 @@ artifacts are publication evidence — see `AGENTS.md` § Issue close. That is fast feedback, not multi-OS certification. - When Rust surfaces change, Test Suite runs authoritative Bazel tests (`Bazel Bootstrap` → `//:ci_rust_tests`) plus Cargo fmt/clippy, and also runs - `Windows graphforge-storage Locks` - (`cargo test -p graphforge-storage project_generation::tests:: --lib` on - `blacksmith-4vcpu-windows-2025`) for the `#[cfg(windows)]` project-root lock - unit tests that Linux Bazel CI cannot execute. + native filesystem publication/admission tests on + `blacksmith-4vcpu-windows-2025` and `blacksmith-12vcpu-macos-15`. Windows + also retains the `graphforge-storage` project-root lock unit tests that Linux + Bazel CI cannot execute. Both host-native jobs are aggregated by `CI Gate`. - Repository policy always validates workflow syntax, the classifier, domain dependency directions, license compliance, and the ledgers that back later release gates (without running those heavy matrices on every PR). diff --git a/legal/THIRD_PARTY_NOTICES.md b/legal/THIRD_PARTY_NOTICES.md index 550e359b..a38316e5 100644 --- a/legal/THIRD_PARTY_NOTICES.md +++ b/legal/THIRD_PARTY_NOTICES.md @@ -17,8 +17,8 @@ python3 scripts/generate_third_party_notices.py ## License overview -- Apache License 2.0 (312) -- MIT License (72) +- Apache License 2.0 (323) +- MIT License (73) - Unicode License v3 (19) - BSD 3-Clause "New" or "Revised" License (7) - ISC License (4) @@ -1611,18 +1611,27 @@ Software. Used by: - windows 0.53.0 +- windows 0.61.3 +- windows-collections 0.2.0 - windows-core 0.53.0 +- windows-core 0.61.2 - windows-core 0.62.2 +- windows-future 0.2.1 - windows-implement 0.60.2 - windows-interface 0.59.3 +- windows-link 0.1.3 - windows-link 0.2.1 +- windows-numerics 0.2.0 - windows-result 0.1.2 +- windows-result 0.3.4 - windows-result 0.4.1 +- windows-strings 0.4.2 - windows-strings 0.5.1 - windows-sys 0.52.0 - windows-sys 0.59.0 - windows-sys 0.61.2 - windows-targets 0.52.6 +- windows-threading 0.1.0 - windows_aarch64_gnullvm 0.52.6 - windows_aarch64_msvc 0.52.6 - windows_i686_gnu 0.52.6 @@ -6631,6 +6640,8 @@ Used by: - libc 0.2.189 - miniz_oxide 0.8.9 - num-conv 0.2.2 +- objc2-core-foundation 0.3.2 +- objc2-io-kit 0.3.2 - oneshot 0.1.13 - paste 1.0.15 - pin-project 1.1.13 @@ -8772,6 +8783,37 @@ SOFTWARE. ## MIT License +Used by: +- sysinfo 0.37.2 + +``` +The MIT License (MIT) + +Copyright (c) 2015 Guillaume Gomez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +-------------------------------------------------------------------------------- + +## MIT License + Used by: - twox-hash 2.1.2 diff --git a/packages/cli/THIRD_PARTY_NOTICES.md b/packages/cli/THIRD_PARTY_NOTICES.md index 550e359b..a38316e5 100644 --- a/packages/cli/THIRD_PARTY_NOTICES.md +++ b/packages/cli/THIRD_PARTY_NOTICES.md @@ -17,8 +17,8 @@ python3 scripts/generate_third_party_notices.py ## License overview -- Apache License 2.0 (312) -- MIT License (72) +- Apache License 2.0 (323) +- MIT License (73) - Unicode License v3 (19) - BSD 3-Clause "New" or "Revised" License (7) - ISC License (4) @@ -1611,18 +1611,27 @@ Software. Used by: - windows 0.53.0 +- windows 0.61.3 +- windows-collections 0.2.0 - windows-core 0.53.0 +- windows-core 0.61.2 - windows-core 0.62.2 +- windows-future 0.2.1 - windows-implement 0.60.2 - windows-interface 0.59.3 +- windows-link 0.1.3 - windows-link 0.2.1 +- windows-numerics 0.2.0 - windows-result 0.1.2 +- windows-result 0.3.4 - windows-result 0.4.1 +- windows-strings 0.4.2 - windows-strings 0.5.1 - windows-sys 0.52.0 - windows-sys 0.59.0 - windows-sys 0.61.2 - windows-targets 0.52.6 +- windows-threading 0.1.0 - windows_aarch64_gnullvm 0.52.6 - windows_aarch64_msvc 0.52.6 - windows_i686_gnu 0.52.6 @@ -6631,6 +6640,8 @@ Used by: - libc 0.2.189 - miniz_oxide 0.8.9 - num-conv 0.2.2 +- objc2-core-foundation 0.3.2 +- objc2-io-kit 0.3.2 - oneshot 0.1.13 - paste 1.0.15 - pin-project 1.1.13 @@ -8772,6 +8783,37 @@ SOFTWARE. ## MIT License +Used by: +- sysinfo 0.37.2 + +``` +The MIT License (MIT) + +Copyright (c) 2015 Guillaume Gomez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +-------------------------------------------------------------------------------- + +## MIT License + Used by: - twox-hash 2.1.2 diff --git a/scripts/ci/check-domain-dependencies.py b/scripts/ci/check-domain-dependencies.py index 6585ba81..a3c14f80 100755 --- a/scripts/ci/check-domain-dependencies.py +++ b/scripts/ci/check-domain-dependencies.py @@ -16,6 +16,7 @@ "graphforge-core", "graphforge-cypher", "graphforge-exec", + "graphforge-filesystem", "graphforge-io", "graphforge-ir", "graphforge-ontology", diff --git a/scripts/ci/clean-env-verify.py b/scripts/ci/clean-env-verify.py index 304a689f..9514b839 100644 --- a/scripts/ci/clean-env-verify.py +++ b/scripts/ci/clean-env-verify.py @@ -29,10 +29,11 @@ RELEASE_RECORD_SCHEMA = "graphforge-release-record-v1" RELEASE_CANDIDATE_SCHEMA = "graphforge-release-candidate-v2" RELEASE_RECORD_SCHEMAS = (RELEASE_RECORD_SCHEMA, RELEASE_CANDIDATE_SCHEMA) -DEFAULT_VERSION = "0.5.0" +DEFAULT_VERSION = "0.5.2" DEFAULT_DOCS_BASE = "https://docs.graphforge.sh" DEFAULT_CRATES = ( "graphforge-core", + "graphforge-filesystem", "graphforge-ast", "graphforge-knowledge", "graphforge-ontology", diff --git a/scripts/ci/release_candidate_manifest.py b/scripts/ci/release_candidate_manifest.py index ff923b6a..7521d096 100644 --- a/scripts/ci/release_candidate_manifest.py +++ b/scripts/ci/release_candidate_manifest.py @@ -30,6 +30,7 @@ } CRATES = ( "graphforge-core", + "graphforge-filesystem", "graphforge-ast", "graphforge-knowledge", "graphforge-ontology", diff --git a/scripts/ci/test-binding-release-candidate.py b/scripts/ci/test-binding-release-candidate.py index 012fd91e..5fff831c 100644 --- a/scripts/ci/test-binding-release-candidate.py +++ b/scripts/ci/test-binding-release-candidate.py @@ -135,6 +135,71 @@ def workflow_step(section: str, marker: str) -> str: return remainder if end < 0 else remainder[:end] +def workflow_jobs(text: str) -> dict[str, str]: + """Split a workflow into top-level job ID to job-body mappings.""" + lines = text.splitlines() + try: + jobs_index = next(index for index, line in enumerate(lines) if line.rstrip() == "jobs:") + except StopIteration as exc: + raise AssertionError("workflow is missing a top-level jobs: mapping") from exc + jobs: dict[str, str] = {} + current: str | None = None + body: list[str] = [] + for line in lines[jobs_index + 1 :]: + indent = len(line) - len(line.lstrip()) + if indent == 2 and line.rstrip().endswith(":") and not line.lstrip().startswith("- "): + if current is not None: + jobs[current] = "\n".join(body) + current = line.strip()[:-1] + body = [] + continue + if current is None: + continue + if line.strip() and indent < 2: + break + body.append(line) + if current is not None: + jobs[current] = "\n".join(body) + assert jobs, "workflow jobs: mapping is empty" + return jobs + + +def job_needs(job_body: str) -> set[str]: + """Return the active top-level needs entries for one job.""" + lines = job_body.splitlines() + for index, line in enumerate(lines): + if not line.strip().startswith("needs:"): + continue + value = line.strip().split(":", 1)[1].strip() + if value: + if value.startswith("[") and value.endswith("]"): + return { + item.strip().strip("'\"") + for item in value[1:-1].split(",") + if item.strip() + } + return {value.strip("'\"")} + indent = len(line) - len(line.lstrip()) + needed: set[str] = set() + for follow in lines[index + 1 :]: + if not follow.strip(): + continue + follow_indent = len(follow) - len(follow.lstrip()) + if follow_indent <= indent: + break + item = follow.strip() + if item.startswith("- "): + needed.add(item[2:].strip().strip("'\"")) + return needed + return set() + + +def job_runs_command(job_body: str, command: str) -> bool: + """Require one complete folded or literal run command in a job.""" + normalized = " ".join(job_body.split()) + return " ".join(command.split()) in normalized + + def validate_python_evidence_policy(workflow_text: str) -> None: """Reject drift from the cross-platform, read-only-wheel evidence contract.""" prepare_step = "Prepare writable Python RC evidence directory" @@ -578,25 +643,56 @@ def main() -> None: assert "native_builder: bazel" in python_job assert "native_builder: maturin" in python_job test_workflow_text = (ROOT / ".github/workflows/test.yml").read_text() - windows_locks_job = required_section( - test_workflow_text, - " windows-graphforge-storage-locks:\n", - " ci-gate:\n", - ) + test_jobs = workflow_jobs(test_workflow_text) + windows_locks_job = test_jobs["windows-graphforge-storage-locks"] assert_active_lines( windows_locks_job, "runs-on: blacksmith-4vcpu-windows-2025", "needs: changes", "if: needs.changes.outputs.rust == 'true'", - "cargo test -p graphforge-storage project_generation::tests:: --lib", - "--no-fail-fast", ) - _, ci_gate_found, ci_gate = test_workflow_text.partition(" ci-gate:\n") - assert ci_gate_found, "missing workflow marker: ci-gate:" + assert job_needs(windows_locks_job) == {"changes"} + assert job_runs_command( + windows_locks_job, + "cargo test -p graphforge-storage project_generation::tests:: --lib --no-fail-fast", + ) + assert job_runs_command( + windows_locks_job, + "cargo test -p graphforge-storage filesystem_admission::tests:: --lib --no-fail-fast", + ) + assert job_runs_command( + windows_locks_job, + "cargo test -p graphforge-filesystem --lib --no-fail-fast", + ) + macos_durability_job = test_jobs["macos-graphforge-storage-durability"] assert_active_lines( + macos_durability_job, + "runs-on: blacksmith-12vcpu-macos-15", + "needs: changes", + "if: needs.changes.outputs.rust == 'true'", + ) + assert job_needs(macos_durability_job) == {"changes"} + assert job_runs_command( + macos_durability_job, + "cargo test -p graphforge-storage filesystem_admission::tests:: --lib --no-fail-fast", + ) + assert job_runs_command( + macos_durability_job, + "cargo test -p graphforge-filesystem --lib --no-fail-fast", + ) + ci_gate = test_jobs["ci-gate"] + assert { + "windows-graphforge-storage-locks", + "macos-graphforge-storage-durability", + } <= job_needs(ci_gate) + assert job_runs_command( + ci_gate, + 'scripts/ci/require-gates.sh "${{ needs.changes.result }}"', + ) + assert job_runs_command( ci_gate, - "- windows-graphforge-storage-locks", - '"${{ needs.windows-graphforge-storage-locks.result }}"', + '"${{ needs.windows-graphforge-storage-locks.result }}" ' + '"${{ needs.macos-graphforge-storage-durability.result }}"', ) assert "macos-latest" not in rc_workflow_text assert "macos-15-intel" not in rc_workflow_text diff --git a/scripts/ci/test-ci-storage-policy.py b/scripts/ci/test-ci-storage-policy.py index 2987a704..5f990e6a 100644 --- a/scripts/ci/test-ci-storage-policy.py +++ b/scripts/ci/test-ci-storage-policy.py @@ -108,9 +108,10 @@ def uses_approved(uses: str | None, action: str, *tags: str) -> bool: ) EXPECTED_DEPENDENCY_KEYS = Counter( { - # test.yml: policy + rust-lint + python/node binding + windows locks (5); + # test.yml: policy + rust-lint + python/node binding + Windows/macOS + # durability (6); # Binding RC: 3. PR Cargo sticky disks retired after #4 cutover. - "${{ runner.os }}-cargo-registry-v1-${{ hashFiles('Cargo.lock') }}": 8, + "${{ runner.os }}-cargo-registry-v1-${{ hashFiles('Cargo.lock') }}": 9, "${{ runner.os }}-snap-ego-facebook-v1": 1, "${{ runner.os }}-fuzz-${{ hashFiles('fuzz/Cargo.toml', '**/Cargo.lock') }}": 1, } @@ -586,8 +587,26 @@ def validate_ci_gate_cutover(text: str) -> None: def main() -> None: texts = {path: path.read_text(encoding="utf-8") for path in sorted(WORKFLOWS.glob("*.y*ml"))} - validate_test_suite_trigger(texts[WORKFLOWS / "test.yml"]) - validate_ci_gate_cutover(texts[WORKFLOWS / "test.yml"]) + test_suite = texts[WORKFLOWS / "test.yml"] + validate_test_suite_trigger(test_suite) + validate_ci_gate_cutover(test_suite) + jobs = workflow_jobs(test_suite) + for job_id, runner in ( + ("windows-graphforge-storage-locks", "blacksmith-4vcpu-windows-2025"), + ("macos-graphforge-storage-durability", "blacksmith-12vcpu-macos-15"), + ): + body = jobs[job_id] + assert f"runs-on: {runner}" in body + assert job_runs_command(body, "cargo test -p graphforge-filesystem --lib --no-fail-fast") + assert job_runs_command(body, "filesystem_admission::tests:: --lib --no-fail-fast") + gate = jobs["ci-gate"] + gate_dependencies = job_needs(gate) + for job_id in ( + "windows-graphforge-storage-locks", + "macos-graphforge-storage-durability", + ): + assert job_id in gate_dependencies + assert f"needs.{job_id}.result" in gate artifact_uploads: list[str] = [] artifact_downloads: list[str] = [] diff --git a/scripts/ci/test-clean-env-verify.py b/scripts/ci/test-clean-env-verify.py index 5726606b..e5953adf 100644 --- a/scripts/ci/test-clean-env-verify.py +++ b/scripts/ci/test-clean-env-verify.py @@ -18,7 +18,7 @@ sys.modules[SPEC.name] = cev # required for dataclasses under Python 3.9 SPEC.loader.exec_module(cev) -assert len(cev.DEFAULT_CRATES) == 15 +assert len(cev.DEFAULT_CRATES) == 16 assert cev.DEFAULT_CRATES[0] == "graphforge-core" assert cev.DEFAULT_CRATES[-1] == "graphforge-cli" assert cev.LANE_ISSUES["cargo"] == 185 diff --git a/scripts/ci/test-crate-publish-plan.py b/scripts/ci/test-crate-publish-plan.py index 88458d34..57e644a7 100755 --- a/scripts/ci/test-crate-publish-plan.py +++ b/scripts/ci/test-crate-publish-plan.py @@ -68,16 +68,17 @@ def run(*args: str) -> subprocess.CompletedProcess[str]: assert "graphforge-cli" in names # Relative order samples assert names.index("graphforge-ast") < names.index("graphforge-ir") +assert names.index("graphforge-filesystem") < names.index("graphforge-storage") assert names.index("graphforge-storage") < names.index("graphforge-api") checked = run("check") assert checked.returncode == 0, checked.stderr -assert "15 crates" in checked.stdout +assert "16 crates" in checked.stdout dry = run("dry-run-commands") assert dry.returncode == 0, dry.stderr commands = [line for line in dry.stdout.splitlines() if line] -assert len(commands) == 15, commands +assert len(commands) == 16, commands assert commands[0].startswith("cargo publish -p graphforge-core ") assert commands[-1].startswith("cargo publish -p graphforge-cli ") diff --git a/scripts/ci/test-domain-dependencies.py b/scripts/ci/test-domain-dependencies.py index f57ff3f0..fb15d271 100755 --- a/scripts/ci/test-domain-dependencies.py +++ b/scripts/ci/test-domain-dependencies.py @@ -33,7 +33,8 @@ def run(packages: list[dict[str, Any]]) -> subprocess.CompletedProcess[str]: base = [ package("graphforge-core", []), - package("graphforge-storage", ["graphforge-core"]), + package("graphforge-filesystem", []), + package("graphforge-storage", ["graphforge-core", "graphforge-filesystem"]), package("graphforge-exec", ["graphforge-core", "graphforge-storage"]), package("graphforge-provenance", ["graphforge-core"]), package("graphforge-knowledge", ["graphforge-core"]), diff --git a/scripts/ci/test-release-candidate.py b/scripts/ci/test-release-candidate.py index 59a11ec1..0e2a20b5 100644 --- a/scripts/ci/test-release-candidate.py +++ b/scripts/ci/test-release-candidate.py @@ -187,11 +187,15 @@ def create_candidate( ) for name in manifest_module.CRATES: crate_root = f"{name}-{VERSION}" - dependency = ( - "" - if name == "graphforge-core" - else (f'graphforge-core = {{ version = "{VERSION}" }}\n') - ) + if name in {"graphforge-core", "graphforge-filesystem"}: + dependency = "" + elif name == "graphforge-storage": + dependency = ( + f'graphforge-core = {{ version = "{VERSION}" }}\n' + f'graphforge-filesystem = {{ version = "{VERSION}" }}\n' + ) + else: + dependency = f'graphforge-core = {{ version = "{VERSION}" }}\n' members = { f"{crate_root}/Cargo.toml": ( f'[package]\nname = "{name}"\nversion = "{VERSION}"\n' @@ -250,7 +254,11 @@ def main() -> None: root = Path(temp) manifest_path, artifacts, manifest = create_candidate(root) validated = release_candidate.validate(manifest_path, artifacts, SHA, VERSION) - assert len(validated["nodes"]) == 24 + assert len(validated["nodes"]) == 25 + assert { + "from": "crates:graphforge-storage", + "requires": "crates:graphforge-filesystem", + } in validated["dependencies"] assert len(release_candidate.npm_paths(validated)) == 8 assert all( [value.split("-", 1)[0] for value in item["integrities"]] == ["sha256", "sha512"] diff --git a/scripts/ci/test-release-publish-preflight.py b/scripts/ci/test-release-publish-preflight.py index 0f1d6fb8..05e3b8c6 100644 --- a/scripts/ci/test-release-publish-preflight.py +++ b/scripts/ci/test-release-publish-preflight.py @@ -177,7 +177,7 @@ def load_module(): assert f"- {job}" in summary assert "release_rehearsal.py reconcile" in summary assert "Release-Reconciliation-${{ github.run_id }}" in summary -assert ".complete == true and (.nodes | length) == 24" in summary +assert ".complete == true and (.nodes | length) == 25" in summary assert "sleep" not in workflow assert "continue-on-error" not in workflow diff --git a/scripts/ci/test-release-registry.py b/scripts/ci/test-release-registry.py index cb307edd..1056610c 100644 --- a/scripts/ci/test-release-registry.py +++ b/scripts/ci/test-release-registry.py @@ -206,7 +206,7 @@ def main() -> None: result = plan(manifest, all_verified) assert result["actions"] == [] assert result["download_groups"] == [] - assert result["summary"]["verified"] == 24 + assert result["summary"]["verified"] == 25 for node_id in ( "pypi:graphforge", diff --git a/scripts/ci/test-release-rehearsal.py b/scripts/ci/test-release-rehearsal.py index 74275794..2d0f1bf1 100644 --- a/scripts/ci/test-release-rehearsal.py +++ b/scripts/ci/test-release-rehearsal.py @@ -111,7 +111,7 @@ def fake_python(manifest, _artifacts, _root): ) assert report["status"] == "passed" assert report["registry_writes"] == 0 - assert report["checks"]["candidate_completeness"]["nodes"] == 24 + assert report["checks"]["candidate_completeness"]["nodes"] == 25 node_check = report["checks"]["node_cli_skills_clean_consumer"] assert node_check["loaded_version"] == candidate_fixture.VERSION host_native = rehearsal._compatible_native_npm_name() @@ -122,7 +122,7 @@ def fake_python(manifest, _artifacts, _root): "@curatelabs/graphforge-agent-skills", host_native, ] - assert len(report["checks"]["rust_packages"]["packages"]) == 15 + assert len(report["checks"]["rust_packages"]["packages"]) == 16 assert not any(word in json.dumps(report).lower() for word in rehearsal.FORBIDDEN_TEXT) with tempfile.TemporaryDirectory() as temporary: @@ -168,14 +168,14 @@ def test_sequential_reconciliation() -> None: availability = _availability() absent = _all(manifest, {"status": 404}) transitions = _sequential_happy_path(manifest) - assert len(transitions) == 24 + assert len(transitions) == 25 report = rehearsal.simulate_sequential( manifest, absent, availability, transitions, simulated_at=NOW ) assert report["complete"] is True - assert report["summary"]["nodes"] == 24 - assert report["summary"]["verified"] == 24 - assert len(report["events"]) == 24 + assert report["summary"]["nodes"] == 25 + assert report["summary"]["verified"] == 25 + assert len(report["events"]) == 25 assert all(event["sequence"] == index for index, event in enumerate(report["events"], 1)) all_verified = registry_fixture.observation_set(manifest) diff --git a/scripts/license_check.py b/scripts/license_check.py index 21cb3ae0..d4ec0277 100644 --- a/scripts/license_check.py +++ b/scripts/license_check.py @@ -36,6 +36,7 @@ "graphforge-core", "graphforge-cypher", "graphforge-exec", + "graphforge-filesystem", "graphforge-io", "graphforge-ir", "graphforge-knowledge", diff --git a/scripts/publish_dry_run.py b/scripts/publish_dry_run.py index 5e4c1ce2..dc0398f1 100644 --- a/scripts/publish_dry_run.py +++ b/scripts/publish_dry_run.py @@ -37,6 +37,7 @@ # Keep in sync with CRATES_IO_EXCLUDED there: no binding implementation crates. FALLBACK_CARGO_ORDER = ( "graphforge-core", + "graphforge-filesystem", "graphforge-ast", "graphforge-knowledge", "graphforge-ontology", diff --git a/scripts/verify_package_licenses.py b/scripts/verify_package_licenses.py index 2ff8040c..f56555f9 100644 --- a/scripts/verify_package_licenses.py +++ b/scripts/verify_package_licenses.py @@ -29,6 +29,7 @@ # Crates intended for crates.io (bindings ship via PyPI/npm). CARGO_PUBLISH_CRATES = ( "graphforge-core", + "graphforge-filesystem", "graphforge-ast", "graphforge-knowledge", "graphforge-ontology", diff --git a/tests/unit/test_publish_dry_run.py b/tests/unit/test_publish_dry_run.py index b0bba853..a962a19f 100644 --- a/tests/unit/test_publish_dry_run.py +++ b/tests/unit/test_publish_dry_run.py @@ -14,7 +14,7 @@ def test_cargo_order_contains_complete_public_surface() -> None: order, _source = publish_dry_run.cargo_publish_order() - assert len(order) == 15 + assert len(order) == 16 assert order[0] == "graphforge-core" assert order[-1] == "graphforge-cli" assert "graphforge-bindings-py" not in order diff --git a/tools/bazel/drift/cargo_feature_fingerprint.json b/tools/bazel/drift/cargo_feature_fingerprint.json index a2b154ab..968cbfeb 100644 --- a/tools/bazel/drift/cargo_feature_fingerprint.json +++ b/tools/bazel/drift/cargo_feature_fingerprint.json @@ -1,6 +1,6 @@ { "schema": "graphforge.cargo-feature-fingerprint.v1", - "sha256": "3c01e947ac827ff997813fff2ed788ec256d501d457deb399acd2f188f24fd62", + "sha256": "fe37804afbbaa557546676cb2bf6394bfa1ba376c452603d83e63557e53ba2ec", "entries": [ { "name": "graphforge-api", @@ -890,6 +890,48 @@ } ] }, + { + "name": "graphforge-filesystem", + "version": "0.5.2", + "features": [], + "dependencies": [ + { + "name": "rustix", + "req": "^1.1", + "features": [ + "fs" + ], + "optional": false, + "uses_default_features": true, + "kind": null, + "target": "cfg(unix)" + }, + { + "name": "tempfile", + "req": "^3", + "features": [], + "optional": false, + "uses_default_features": true, + "kind": "dev", + "target": null + }, + { + "name": "windows-sys", + "req": "^0.61", + "features": [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_IO" + ], + "optional": false, + "uses_default_features": true, + "kind": null, + "target": "cfg(windows)" + } + ] + }, { "name": "graphforge-io", "version": "0.5.2", @@ -1621,6 +1663,15 @@ "kind": null, "target": null }, + { + "name": "graphforge-filesystem", + "req": "^0.5.2", + "features": [], + "optional": false, + "uses_default_features": true, + "kind": null, + "target": null + }, { "name": "graphforge-ir", "req": "^0.5.2", @@ -1666,6 +1717,17 @@ "kind": null, "target": null }, + { + "name": "rustix", + "req": "^1.1", + "features": [ + "fs" + ], + "optional": false, + "uses_default_features": true, + "kind": null, + "target": "cfg(unix)" + }, { "name": "serde", "req": "^1", @@ -1695,6 +1757,17 @@ "kind": null, "target": null }, + { + "name": "sysinfo", + "req": "^0.37.2", + "features": [ + "disk" + ], + "optional": false, + "uses_default_features": false, + "kind": null, + "target": null + }, { "name": "tempfile", "req": "^3", diff --git a/tools/bazel/parity/migration_target_map.json b/tools/bazel/parity/migration_target_map.json index 2ab221fd..7267e303 100644 --- a/tools/bazel/parity/migration_target_map.json +++ b/tools/bazel/parity/migration_target_map.json @@ -1,7 +1,7 @@ { "schema": "graphforge.bazel-migration-target-map.v1", "issue": 6, - "cargo_target_count": 100, + "cargo_target_count": 101, "targets": [ { "package": "graphforge-api", @@ -813,6 +813,16 @@ "exception_id": null, "notes": "#8" }, + { + "package": "graphforge-filesystem", + "target": "graphforge_filesystem", + "class": "lib", + "source": "crates/graphforge-filesystem/src/lib.rs", + "status": "mapped", + "bazel_label": "//crates/graphforge-filesystem:graphforge_filesystem", + "exception_id": null, + "notes": "#779; unit tests `//crates/graphforge-filesystem:graphforge_filesystem_test`" + }, { "package": "graphforge-io", "target": "graphforge_io", From 6a473201ae6dcf32ae6102d0ad7803fd0199d634 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:10:08 -0600 Subject: [PATCH 2/3] fix(storage): classify Windows volumes natively --- crates/graphforge-filesystem/src/lib.rs | 111 +++++++++++++++++- .../src/filesystem_admission.rs | 39 ++++-- scripts/ci/test-binding-release-candidate.py | 4 +- tests/unit/test_set_release_version.py | 9 +- 4 files changed, 145 insertions(+), 18 deletions(-) diff --git a/crates/graphforge-filesystem/src/lib.rs b/crates/graphforge-filesystem/src/lib.rs index d88dfc6d..b3e3802a 100644 --- a/crates/graphforge-filesystem/src/lib.rs +++ b/crates/graphforge-filesystem/src/lib.rs @@ -17,6 +17,18 @@ pub struct FileIdentity { pub file_id: [u8; 16], } +/// Native Windows volume facts needed by the durability admission policy. +#[cfg(windows)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WindowsVolumeInformation { + /// Filesystem name reported by the mounted volume (`NTFS`, `ReFS`, ...). + pub filesystem_name: String, + /// Whether the volume reports the read-only filesystem flag. + pub read_only: bool, + /// Whether Windows classifies the volume root as a fixed local drive. + pub fixed: bool, +} + /// Create a durability-probe directory that is private to the current user. /// /// Unix uses mode `0700`. Windows installs a protected DACL that grants full @@ -45,6 +57,15 @@ pub fn path_link_count(path: &Path) -> io::Result { path_link_count_platform(path) } +/// Query Windows volume facts from the native mount root containing `path`. +/// +/// This accepts canonical extended-length paths such as `\\?\C:\...` and +/// follows mount-point boundaries through `GetVolumePathNameW`. +#[cfg(windows)] +pub fn windows_volume_information(path: &Path) -> io::Result { + windows::volume_information(path) +} + /// Failure classification for an attempted atomic replacement. #[derive(Debug)] pub enum ReplaceFileError { @@ -452,13 +473,21 @@ mod windows { use windows_sys::Win32::Storage::FileSystem::{ BY_HANDLE_FILE_INFORMATION, CreateDirectoryW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FILE_NAME_NORMALIZED, FILE_SHARE_DELETE, - FILE_SHARE_READ, FILE_SHARE_WRITE, FileIdInfo, GetFileInformationByHandle, - GetFileInformationByHandleEx, GetFinalPathNameByHandleW, ReplaceFileW, VOLUME_NAME_DOS, + FILE_SHARE_READ, FILE_SHARE_WRITE, FileIdInfo, GetDriveTypeW, GetFileInformationByHandle, + GetFileInformationByHandleEx, GetFinalPathNameByHandleW, GetVolumeInformationW, + GetVolumePathNameW, ReplaceFileW, VOLUME_NAME_DOS, }; #[cfg(test)] use super::classify_failed_replacement; - use super::{FileIdentity, ReplaceFileError, verify_regular_metadata}; + use super::{ + FileIdentity, ReplaceFileError, WindowsVolumeInformation, verify_regular_metadata, + }; + + const DRIVE_FIXED: u32 = 3; + const FILE_READ_ONLY_VOLUME: u32 = 0x0008_0000; + const EXTENDED_PATH_CAPACITY: usize = 32_768; + const FILESYSTEM_NAME_CAPACITY: usize = 256; pub(super) fn replace_file( directory: &File, @@ -614,6 +643,62 @@ mod windows { Ok(()) } + pub(super) fn volume_information(path: &Path) -> io::Result { + let path = wide(path.as_os_str())?; + let mut volume_root = vec![0u16; EXTENDED_PATH_CAPACITY]; + // SAFETY: `path` is a NUL-terminated UTF-16 input and `volume_root` + // is writable for the exact capacity supplied to the native call. + let found = unsafe { + GetVolumePathNameW( + path.as_ptr(), + volume_root.as_mut_ptr(), + u32::try_from(volume_root.len()).expect("extended path capacity fits u32"), + ) + }; + if found == 0 { + return Err(io::Error::last_os_error()); + } + let root_length = volume_root + .iter() + .position(|unit| *unit == 0) + .ok_or_else(|| io::Error::other("native volume root was not terminated"))?; + volume_root.truncate(root_length + 1); + + let mut filesystem_flags = 0u32; + let mut filesystem_name = vec![0u16; FILESYSTEM_NAME_CAPACITY]; + // SAFETY: `volume_root` is the NUL-terminated mount root returned by + // Windows. Optional outputs are null and both supplied outputs point + // to initialized writable storage of the advertised sizes. + let described = unsafe { + GetVolumeInformationW( + volume_root.as_ptr(), + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut filesystem_flags, + filesystem_name.as_mut_ptr(), + u32::try_from(filesystem_name.len()).expect("filesystem name capacity fits u32"), + ) + }; + if described == 0 { + return Err(io::Error::last_os_error()); + } + let name_length = filesystem_name + .iter() + .position(|unit| *unit == 0) + .ok_or_else(|| io::Error::other("native filesystem name was not terminated"))?; + let filesystem_name = String::from_utf16(&filesystem_name[..name_length]) + .map_err(|_| io::Error::other("native filesystem name was invalid UTF-16"))?; + // SAFETY: `volume_root` remains a valid NUL-terminated root path. + let drive_type = unsafe { GetDriveTypeW(volume_root.as_ptr()) }; + Ok(WindowsVolumeInformation { + filesystem_name, + read_only: (filesystem_flags & FILE_READ_ONLY_VOLUME) != 0, + fixed: drive_type == DRIVE_FIXED, + }) + } + fn verify_windows_regular(path: &Path) -> io::Result<()> { let metadata = std::fs::symlink_metadata(path)?; verify_regular_metadata(&metadata)?; @@ -828,6 +913,26 @@ mod windows { let metadata = std::fs::symlink_metadata(&junction).unwrap(); assert!(super::super::is_link_or_reparse(&metadata)); } + + #[test] + fn canonical_extended_drive_path_has_native_volume_information() { + use std::path::{Component, Prefix}; + + let parent = tempfile::tempdir().unwrap(); + let canonical = parent.path().canonicalize().unwrap(); + assert!(matches!( + canonical.components().next(), + Some(Component::Prefix(prefix)) + if matches!(prefix.kind(), Prefix::VerbatimDisk(_)) + )); + let information = volume_information(&canonical).unwrap(); + assert!(information.fixed); + assert!(!information.read_only); + assert!(matches!( + information.filesystem_name.to_ascii_lowercase().as_str(), + "ntfs" | "refs" + )); + } } } diff --git a/crates/graphforge-storage/src/filesystem_admission.rs b/crates/graphforge-storage/src/filesystem_admission.rs index 8233ee67..fb537ee0 100644 --- a/crates/graphforge-storage/src/filesystem_admission.rs +++ b/crates/graphforge-storage/src/filesystem_admission.rs @@ -19,6 +19,7 @@ use std::time::Instant; use graphforge_core::{GfError, ProjectErrorCode}; use sha2::{Digest as _, Sha256}; +#[cfg(any(target_os = "linux", target_os = "macos"))] use sysinfo::Disks; const PROBE_BYTES_A: &[u8] = b"graphforge-filesystem-probe/a\n"; @@ -292,20 +293,15 @@ fn classify_supported_local_volume_platform(parent: &Path) -> Result Result { - let disks = Disks::new_with_refreshed_list(); - let disk = disks - .list() - .iter() - .filter(|disk| parent.starts_with(disk.mount_point())) - .max_by_key(|disk| disk.mount_point().components().count()) - .ok_or_else(|| unsupported("CLASSIFY", "volume_unknown"))?; - if disk.is_read_only() { + let information = graphforge_filesystem::windows_volume_information(parent) + .map_err(|_| unsupported("CLASSIFY", "native_volume_query_failed"))?; + if information.read_only { return Err(unsupported("CLASSIFY", "volume_read_only")); } - if disk.is_removable() { - return Err(unsupported("CLASSIFY", "volume_removable")); + if !information.fixed { + return Err(unsupported("CLASSIFY", "volume_not_fixed_local")); } - let class = disk.file_system().to_string_lossy().to_ascii_lowercase(); + let class = information.filesystem_name.to_ascii_lowercase(); if !matches!(class.as_str(), "ntfs" | "refs") { return Err(unsupported("CLASSIFY", "filesystem_class_unproven")); } @@ -979,6 +975,27 @@ mod tests { assert_eq!(std::fs::read_dir(&destination).unwrap().count(), 0); } + #[cfg(windows)] + #[test] + fn canonical_extended_drive_path_completes_full_native_admission() { + use std::path::{Component, Prefix}; + + let parent = canonical_tempdir(); + assert!(matches!( + parent.path().components().next(), + Some(Component::Prefix(prefix)) + if matches!(prefix.kind(), Prefix::VerbatimDisk(_)) + )); + let target = parent.path().join("project"); + let evidence = filesystem_durability_preflight(&target).unwrap(); + assert!(matches!( + evidence.filesystem_class.as_str(), + "ntfs" | "refs" + )); + assert!(!target.exists()); + assert_eq!(std::fs::read_dir(parent.path()).unwrap().count(), 0); + } + #[test] fn ambiguous_replacement_state_is_typed_and_reconciled_by_cleanup() { let parent = canonical_tempdir(); diff --git a/scripts/ci/test-binding-release-candidate.py b/scripts/ci/test-binding-release-candidate.py index 5fff831c..51dfd9b8 100644 --- a/scripts/ci/test-binding-release-candidate.py +++ b/scripts/ci/test-binding-release-candidate.py @@ -174,9 +174,7 @@ def job_needs(job_body: str) -> set[str]: if value: if value.startswith("[") and value.endswith("]"): return { - item.strip().strip("'\"") - for item in value[1:-1].split(",") - if item.strip() + item.strip().strip("'\"") for item in value[1:-1].split(",") if item.strip() } return {value.strip("'\"")} indent = len(line) - len(line.lstrip()) diff --git a/tests/unit/test_set_release_version.py b/tests/unit/test_set_release_version.py index 420ee36d..84483524 100644 --- a/tests/unit/test_set_release_version.py +++ b/tests/unit/test_set_release_version.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +import tomllib SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "set_release_version.py" SPEC = importlib.util.spec_from_file_location("set_release_version", SCRIPT) @@ -38,7 +39,13 @@ def test_expected_mapping() -> None: def test_current_tree_is_aligned() -> None: - assert len(set_release_version.cargo_lock_versions()) == 17 + lock_versions = set_release_version.cargo_lock_versions() + assert len(lock_versions) == 18 + manifest_packages = { + tomllib.loads(path.read_text(encoding="utf-8"))["package"]["name"] + for path in set_release_version.crate_manifests() + } + assert set(lock_versions) == manifest_packages assert set_release_version.check_aligned() == [] compatibility = json.loads(set_release_version.SKILLS_COMPATIBILITY.read_text(encoding="utf-8")) current = set_release_version.read_current() From 6d0124197341bdcbe7d1e43aebba464cee6cdc12 Mon Sep 17 00:00:00 2001 From: David Spencer <1526975+DecisionNerd@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:26:59 -0600 Subject: [PATCH 3/3] fix(storage): harden native publication policy --- .github/workflows/test.yml | 20 +- crates/graphforge-filesystem/src/lib.rs | 546 +++++++++++++++--- .../src/filesystem_admission.rs | 112 ++-- docs/adr/0013-project-generation-protocol.md | 62 +- .../0018-acknowledged-durability-isolation.md | 61 +- .../0019-authoritative-graph-delta-journal.md | 4 +- ...ntfs-write-through-namespace-durability.md | 101 ++++ docs/adr/README.md | 1 + .../book/architecture/concurrency-recovery.md | 32 +- docs/engineering/adrs/README.md | 1 + docs/guides/repository-integration.md | 12 +- docs/reference/api.md | 6 +- scripts/ci/durability-isolation-gate.py | 38 +- scripts/ci/test-binding-release-candidate.py | 285 +++++---- scripts/ci/test-ci-storage-policy.py | 200 ++++--- scripts/ci/test-durability-isolation-gate.py | 14 +- scripts/ci/workflow_policy.py | 171 ++++++ .../durability-isolation-matrix.json | 22 +- tests/unit/test_set_release_version.py | 8 +- 19 files changed, 1289 insertions(+), 407 deletions(-) create mode 100644 docs/adr/0020-ntfs-write-through-namespace-durability.md create mode 100644 scripts/ci/workflow_policy.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a48553b0..0328ca0e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -865,16 +865,16 @@ jobs: cargo test -p graphforge-storage project_generation::tests:: --lib --no-fail-fast - - name: Run Windows native filesystem admission tests + - name: Run Windows exact filesystem primitive tests shell: bash run: >- - cargo test -p graphforge-storage - filesystem_admission::tests:: --lib --no-fail-fast + cargo test -p graphforge-filesystem --lib --no-fail-fast - - name: Run Windows exact filesystem primitive tests + - name: Run Windows native filesystem admission tests shell: bash run: >- - cargo test -p graphforge-filesystem --lib --no-fail-fast + cargo test -p graphforge-storage + filesystem_admission::tests:: --lib --no-fail-fast - name: Run Windows durability certification unit tests shell: bash @@ -973,11 +973,13 @@ jobs: python3 scripts/ci/bazel-cache-perf.py --mode policy python3 scripts/ci/test-bazel-cache-perf.py + - name: Prepare Bazel evidence directory + run: mkdir -p dist + - name: Authoritative Bazel Rust tests + first-party libs/CLI/resources + shell: bash run: | - set -euo pipefail # Do not set --remote_cache; Blacksmith injects repository cache. - mkdir -p dist bazelisk test --config=ci //:ci_rust_tests 2>&1 | tee dist/bazel-ci-rust-tests.log bazelisk build --config=ci \ //:bazel_smoke \ @@ -1025,12 +1027,14 @@ jobs: sudo ln -sf /usr/local/bin/bazelisk /usr/local/bin/bazel bazelisk version + - name: Prepare Bazel diagnostic evidence directory + run: mkdir -p dist + - name: Same-SHA Cargo/Bazel dual-build parity (diagnostic, one release cycle) run: | # Cargo is no longer authoritative under CI Gate (#4). Keep same-SHA # parity as a diagnostic for one release cycle; see cutover rollback doc. # Do not set --remote_cache; Blacksmith injects repository cache. - mkdir -p dist python3 scripts/ci/cargo-bazel-parity-check.py \ --mode all \ --write-evidence "dist/cargo-bazel-parity-evidence.json" diff --git a/crates/graphforge-filesystem/src/lib.rs b/crates/graphforge-filesystem/src/lib.rs index b3e3802a..fb39e23e 100644 --- a/crates/graphforge-filesystem/src/lib.rs +++ b/crates/graphforge-filesystem/src/lib.rs @@ -8,12 +8,12 @@ use std::fs::File; use std::io; use std::path::Path; -/// Stable filesystem identity suitable for NTFS/ReFS and Unix filesystems. +/// Stable filesystem identity suitable for Windows and Unix filesystems. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct FileIdentity { /// Native volume/device identity. pub volume_serial: u64, - /// Full native file identity (128-bit on ReFS; zero-extended inode on Unix). + /// Full native file identity (128-bit on Windows; zero-extended inode on Unix). pub file_id: [u8; 16], } @@ -69,10 +69,8 @@ pub fn windows_volume_information(path: &Path) -> io::Result io::Result<()> { - // Windows rename does not replace an existing destination. The explicit - // precheck supplies a stable AlreadyExists class; the OS operation remains - // the race-free authority. + // The native handle-scoped rename is the race-free no-replace authority; + // identity reconciliation supplies a stable AlreadyExists class. windows::install_new_file(directory, source_name, target_name) } @@ -459,7 +461,7 @@ mod windows { use std::os::windows::io::AsRawHandle as _; use std::path::{Path, PathBuf}; - use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::Foundation::{GENERIC_WRITE, LocalFree}; #[cfg(test)] use windows_sys::Win32::Security::Authorization::{ ConvertSecurityDescriptorToStringSecurityDescriptorW, GetNamedSecurityInfoW, SE_FILE_OBJECT, @@ -471,69 +473,85 @@ mod windows { use windows_sys::Win32::Security::{DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION}; use windows_sys::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES}; use windows_sys::Win32::Storage::FileSystem::{ - BY_HANDLE_FILE_INFORMATION, CreateDirectoryW, FILE_FLAG_BACKUP_SEMANTICS, - FILE_FLAG_OPEN_REPARSE_POINT, FILE_ID_INFO, FILE_NAME_NORMALIZED, FILE_SHARE_DELETE, - FILE_SHARE_READ, FILE_SHARE_WRITE, FileIdInfo, GetDriveTypeW, GetFileInformationByHandle, - GetFileInformationByHandleEx, GetFinalPathNameByHandleW, GetVolumeInformationW, - GetVolumePathNameW, ReplaceFileW, VOLUME_NAME_DOS, + BY_HANDLE_FILE_INFORMATION, CreateDirectoryW, DELETE, FILE_FLAG_BACKUP_SEMANTICS, + FILE_FLAG_OPEN_REPARSE_POINT, FILE_FLAG_WRITE_THROUGH, FILE_ID_INFO, FILE_NAME_NORMALIZED, + FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_SHARE_DELETE, FILE_SHARE_READ, + FILE_SHARE_WRITE, FileIdInfo, FileRenameInfo, FileRenameInfoEx, GetDriveTypeW, + GetFileInformationByHandle, GetFileInformationByHandleEx, GetFinalPathNameByHandleW, + GetVolumeInformationW, GetVolumePathNameW, SetFileInformationByHandle, VOLUME_NAME_DOS, }; #[cfg(test)] use super::classify_failed_replacement; use super::{ - FileIdentity, ReplaceFileError, WindowsVolumeInformation, verify_regular_metadata, + FileIdentity, ReplaceFileError, WindowsVolumeInformation, is_link_or_reparse, + verify_regular_metadata, }; const DRIVE_FIXED: u32 = 3; const FILE_READ_ONLY_VOLUME: u32 = 0x0008_0000; const EXTENDED_PATH_CAPACITY: usize = 32_768; const FILESYSTEM_NAME_CAPACITY: usize = 256; + const FILE_RENAME_REPLACE_IF_EXISTS_FLAG: u32 = 0x0000_0001; + const FILE_RENAME_POSIX_SEMANTICS_FLAG: u32 = 0x0000_0002; pub(super) fn replace_file( directory: &File, source_name: &OsStr, target_name: &OsStr, ) -> Result<(), ReplaceFileError> { - let directory_path = directory_path(directory).map_err(ReplaceFileError::NotReplaced)?; + let (_directory_guard, directory_path) = + guarded_directory_path(directory).map_err(ReplaceFileError::NotReplaced)?; let source_path = directory_path.join(source_name); let target_path = directory_path.join(target_name); - verify_windows_regular(&source_path).map_err(ReplaceFileError::NotReplaced)?; - verify_windows_regular(&target_path).map_err(ReplaceFileError::NotReplaced)?; - let source_before = identity(&source_path).map_err(ReplaceFileError::NotReplaced)?; - let target_before = identity(&target_path).map_err(ReplaceFileError::NotReplaced)?; - let source = wide(source_path.as_os_str()).map_err(ReplaceFileError::NotReplaced)?; - let target = wide(target_path.as_os_str()).map_err(ReplaceFileError::NotReplaced)?; - // SAFETY: both strings are owned, NUL-terminated UTF-16 buffers for - // the duration of the call. Optional backup/exclusion pointers are - // null as required when unused. ReplaceFileW has no supported flags. - let succeeded = unsafe { - ReplaceFileW( - target.as_ptr(), - source.as_ptr(), - std::ptr::null(), - 0, - std::ptr::null_mut(), - std::ptr::null_mut(), - ) - }; - if succeeded != 0 { - return if identity(&target_path).ok() == Some(source_before) && !source_path.exists() { - Ok(()) - } else { - Err(ReplaceFileError::StateUnknown(io::Error::other( - "replacement success state did not reconcile", - ))) - }; + let source = open_rename_handle(&source_path).map_err(ReplaceFileError::NotReplaced)?; + verify_open_regular(&source).map_err(ReplaceFileError::NotReplaced)?; + source.sync_all().map_err(ReplaceFileError::NotReplaced)?; + let source_before = file_identity(&source).map_err(ReplaceFileError::NotReplaced)?; + if identity(&source_path).map_err(ReplaceFileError::NotReplaced)? != source_before { + return Err(ReplaceFileError::NotReplaced(io::Error::other( + "rename source identity changed during open", + ))); + } + + let target = open_identity_handle(&target_path).map_err(ReplaceFileError::NotReplaced)?; + verify_open_regular(&target).map_err(ReplaceFileError::NotReplaced)?; + let target_before = file_identity(&target).map_err(ReplaceFileError::NotReplaced)?; + if identity(&target_path).map_err(ReplaceFileError::NotReplaced)? != target_before { + return Err(ReplaceFileError::NotReplaced(io::Error::other( + "rename target identity changed during open", + ))); + } + + let result = rename_handle(&source, target_path.as_os_str(), true); + let opened_source_after = file_identity(&source).ok(); + let opened_target_after = file_identity(&target).ok(); + let source_after = identity(&source_path).ok(); + let target_after = identity(&target_path).ok(); + if result.is_ok() + && opened_source_after == Some(source_before) + && opened_target_after == Some(target_before) + && source_after.is_none() + && target_after == Some(source_before) + { + return Ok(()); + } + if result.is_ok() { + return Err(ReplaceFileError::StateUnknown(io::Error::other( + "replacement success state did not reconcile", + ))); + } + let error = result.expect_err("failed rename result was checked"); + if opened_source_after != Some(source_before) || opened_target_after != Some(target_before) + { + return Err(ReplaceFileError::StateUnknown(error)); } - let error = io::Error::last_os_error(); - let source_after = identity(&source_path); - let target_after = identity(&target_path); Err(super::classify_failed_replacement( error, source_before, target_before, - source_after.ok(), - target_after.ok(), + source_after, + target_after, )) } @@ -542,26 +560,187 @@ mod windows { source_name: &OsStr, target_name: &OsStr, ) -> io::Result<()> { - let directory_path = directory_path(directory)?; - let source = directory_path.join(source_name); - let target = directory_path.join(target_name); - verify_windows_regular(&source)?; - match std::fs::symlink_metadata(&target) { - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Ok(_) => { + install_new_file_before_rename(directory, source_name, target_name, || {}) + } + + fn install_new_file_before_rename( + directory: &File, + source_name: &OsStr, + target_name: &OsStr, + before_rename: impl FnOnce(), + ) -> io::Result<()> { + let (_directory_guard, directory_path) = guarded_directory_path(directory)?; + let source_path = directory_path.join(source_name); + let target_path = directory_path.join(target_name); + let source = open_rename_handle(&source_path)?; + verify_open_regular(&source)?; + source.sync_all()?; + let source_identity = file_identity(&source)?; + if identity(&source_path)? != source_identity { + return Err(io::Error::other( + "rename source identity changed during open", + )); + } + + before_rename(); + let result = rename_handle(&source, target_path.as_os_str(), false); + let opened_after = file_identity(&source).ok(); + let source_after = identity(&source_path).ok(); + let target_after = identity(&target_path).ok(); + if result.is_ok() + && opened_after == Some(source_identity) + && source_after.is_none() + && target_after == Some(source_identity) + { + return Ok(()); + } + if result.is_ok() { + return Err(io::Error::other("atomic creation state did not reconcile")); + } + if opened_after != Some(source_identity) || source_after != Some(source_identity) { + return Err(io::Error::other( + "atomic creation failure state requires reconciliation", + )); + } + let error = result.expect_err("failed rename result was checked"); + if matches!(error.raw_os_error(), Some(80) | Some(183)) { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "target exists", + )); + } + Err(error) + } + + fn guarded_directory_path(directory: &File) -> io::Result<(File, PathBuf)> { + let supplied_identity = file_identity(directory)?; + let supplied_path = directory_path(directory)?; + let guard = std::fs::OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(&supplied_path)?; + let guard_metadata = guard.metadata()?; + if is_link_or_reparse(&guard_metadata) + || !guard_metadata.is_dir() + || file_identity(&guard)? != supplied_identity + { + return Err(io::Error::other( + "publication directory identity changed while acquiring guard", + )); + } + let guarded_path = directory_path(&guard)?; + Ok((guard, guarded_path)) + } + + fn open_rename_handle(path: &Path) -> io::Result { + std::fs::OpenOptions::new() + .access_mode(GENERIC_WRITE | DELETE | FILE_READ_ATTRIBUTES) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH) + .open(path) + } + + fn rename_handle( + source: &File, + target_path: &OsStr, + replace_if_exists: bool, + ) -> io::Result<()> { + let mut rename = RenameInformation::new(target_path, replace_if_exists)?; + // SAFETY: `rename` owns an aligned, initialized FILE_RENAME_INFO buffer + // for the duration of the call. The absolute target path was resolved + // from the retained directory handle, and the source was opened with + // FILE_FLAG_WRITE_THROUGH, so on NTFS the rename metadata uses the + // documented write-through path. Replacement uses POSIX semantics so + // the retained old-target handle remains valid while new name opens + // resolve to the replacement. + let information_class = if replace_if_exists { + FileRenameInfoEx + } else { + FileRenameInfo + }; + let renamed = unsafe { + SetFileInformationByHandle( + source.as_raw_handle(), + information_class, + rename.as_mut_ptr().cast(), + rename.byte_len, + ) + }; + if renamed == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + + struct RenameInformation { + words: Vec, + byte_len: u32, + } + + impl RenameInformation { + fn new(target_name: &OsStr, replace_if_exists: bool) -> io::Result { + let target = target_name.encode_wide().collect::>(); + if target.is_empty() { return Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "target exists", + io::ErrorKind::InvalidInput, + "target name is empty", )); } - Err(error) => return Err(error), + let name_bytes = target + .len() + .checked_mul(std::mem::size_of::()) + .and_then(|length| u32::try_from(length).ok()) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "target name too long") + })?; + let required_bytes = std::mem::size_of::() + .checked_add(usize::try_from(name_bytes).unwrap_or(usize::MAX)) + .and_then(|length| length.checked_add(std::mem::size_of::())) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "target name too long") + })?; + let word_bytes = std::mem::size_of::(); + let mut words = vec![0usize; required_bytes.div_ceil(word_bytes)]; + let allocated_bytes = words + .len() + .checked_mul(word_bytes) + .and_then(|length| u32::try_from(length).ok()) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "target name too long") + })?; + let information = words.as_mut_ptr().cast::(); + // SAFETY: `words` is zero-initialized, pointer-aligned, and at + // least sizeof(FILE_RENAME_INFO) plus the UTF-16 name and its NUL. + // FileNameLength excludes the retained zero terminator. + unsafe { + if replace_if_exists { + (*information).Anonymous.Flags = + FILE_RENAME_REPLACE_IF_EXISTS_FLAG | FILE_RENAME_POSIX_SEMANTICS_FLAG; + } else { + (*information).Anonymous.ReplaceIfExists = false; + } + // SetFileInformationByHandle resolves a Win32 relative path + // against the process current directory. Use the absolute + // target path derived from the retained directory handle. + (*information).RootDirectory = std::ptr::null_mut(); + (*information).FileNameLength = name_bytes; + std::ptr::copy_nonoverlapping( + target.as_ptr(), + std::ptr::addr_of_mut!((*information).FileName).cast::(), + target.len(), + ); + } + Ok(Self { + words, + byte_len: allocated_bytes, + }) } - let source_identity = identity(&source)?; - std::fs::rename(&source, &target)?; - if identity(&target)? != source_identity || source.exists() { - return Err(io::Error::other("atomic creation state did not reconcile")); + + fn as_mut_ptr(&mut self) -> *mut FILE_RENAME_INFO { + self.words.as_mut_ptr().cast() } - Ok(()) } pub(super) fn directory_path(directory: &File) -> io::Result { @@ -590,9 +769,15 @@ mod windows { FILE_NAME_NORMALIZED | VOLUME_NAME_DOS, ) }; - if written == 0 || usize::try_from(written).unwrap_or(usize::MAX) >= buffer.len() { + if written == 0 { return Err(io::Error::last_os_error()); } + if usize::try_from(written).unwrap_or(usize::MAX) >= buffer.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "normalized directory path exceeded its allocated buffer", + )); + } buffer.truncate(usize::try_from(written).unwrap_or_default()); Ok(PathBuf::from(std::ffi::OsString::from_wide(&buffer))) } @@ -699,11 +884,9 @@ mod windows { }) } - fn verify_windows_regular(path: &Path) -> io::Result<()> { - let metadata = std::fs::symlink_metadata(path)?; - verify_regular_metadata(&metadata)?; - let file = open_identity_handle(path)?; - let information = information(&file)?; + fn verify_open_regular(file: &File) -> io::Result<()> { + verify_regular_metadata(&file.metadata()?)?; + let information = information(file)?; if information.nNumberOfLinks != 1 { return Err(io::Error::other("replacement path is hard linked")); } @@ -915,7 +1098,7 @@ mod windows { } #[test] - fn canonical_extended_drive_path_has_native_volume_information() { + fn canonical_extended_drive_path_reports_native_volume_information() { use std::path::{Component, Prefix}; let parent = tempfile::tempdir().unwrap(); @@ -928,10 +1111,165 @@ mod windows { let information = volume_information(&canonical).unwrap(); assert!(information.fixed); assert!(!information.read_only); - assert!(matches!( - information.filesystem_name.to_ascii_lowercase().as_str(), - "ntfs" | "refs" - )); + assert!(!information.filesystem_name.is_empty()); + } + + #[test] + fn write_through_source_handle_performs_replacement_rename() { + let directory = tempfile::tempdir().unwrap(); + let source_path = directory.path().join("source"); + let target_path = directory.path().join("target"); + std::fs::write(&source_path, b"new").unwrap(); + std::fs::write(&target_path, b"old").unwrap(); + let source = open_rename_handle(&source_path).unwrap(); + source.sync_all().unwrap(); + let source_identity = file_identity(&source).unwrap(); + let old_target = open_identity_handle(&target_path).unwrap(); + let old_target_identity = file_identity(&old_target).unwrap(); + + rename_handle(&source, target_path.as_os_str(), true).unwrap(); + + assert_eq!(file_identity(&source).unwrap(), source_identity); + assert_eq!(file_identity(&old_target).unwrap(), old_target_identity); + assert!(!source_path.exists()); + assert_eq!(identity(&target_path).unwrap(), source_identity); + assert_eq!(std::fs::read(target_path).unwrap(), b"new"); + } + + #[test] + fn rename_information_buffer_meets_win32_layout_contract() { + let target_path = OsStr::new(r"C:\durability-probe\published"); + let target = target_path.encode_wide().collect::>(); + let mut rename = RenameInformation::new(target_path, false).unwrap(); + let information = rename.as_mut_ptr(); + + assert_eq!( + information.addr() % std::mem::align_of::(), + 0 + ); + assert!( + usize::try_from(rename.byte_len).unwrap() + >= std::mem::size_of::() + + target.len() * std::mem::size_of::() + + std::mem::size_of::() + ); + // SAFETY: `rename` owns the initialized buffer and the assertion + // above proves room for the encoded name plus its zero terminator. + unsafe { + assert_eq!((*information).FileNameLength as usize, target.len() * 2); + assert!((*information).RootDirectory.is_null()); + assert!(!(*information).Anonymous.ReplaceIfExists); + let file_name = std::ptr::addr_of!((*information).FileName).cast::(); + assert_eq!(std::slice::from_raw_parts(file_name, target.len()), target); + assert_eq!(*file_name.add(target.len()), 0); + } + + let mut replacement = RenameInformation::new(target_path, true).unwrap(); + // SAFETY: `replacement` owns a live initialized buffer. + unsafe { + assert_eq!( + (*replacement.as_mut_ptr()).Anonymous.Flags, + FILE_RENAME_REPLACE_IF_EXISTS_FLAG | FILE_RENAME_POSIX_SEMANTICS_FLAG + ); + } + } + + #[test] + fn contender_created_after_source_open_is_never_replaced() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("source"); + let target = directory.path().join("target"); + std::fs::write(&source, b"source").unwrap(); + let source_before = identity(&source).unwrap(); + let handle = super::super::tests::directory_handle(directory.path()); + let error = install_new_file_before_rename( + &handle, + OsStr::new("source"), + OsStr::new("target"), + || std::fs::write(&target, b"contender").unwrap(), + ) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read(&source).unwrap(), b"source"); + assert_eq!(identity(&source).unwrap(), source_before); + assert_eq!(std::fs::read(&target).unwrap(), b"contender"); + assert_ne!(identity(&target).unwrap(), source_before); + } + + #[test] + fn absolute_target_does_not_resolve_against_process_current_directory() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("source"); + std::fs::write(&source, b"source").unwrap(); + let cwd_decoy = tempfile::Builder::new() + .prefix("graphforge-rename-decoy-") + .tempdir_in(std::env::current_dir().unwrap()) + .unwrap(); + let target_name = cwd_decoy.path().file_name().unwrap(); + let target = directory.path().join(target_name); + let handle = super::super::tests::directory_handle(directory.path()); + + install_new_file(&handle, OsStr::new("source"), target_name).unwrap(); + + assert_eq!(std::fs::read(target).unwrap(), b"source"); + assert!(cwd_decoy.path().is_dir()); + } + + #[test] + fn internal_directory_guard_blocks_anchor_rename() { + let parent = tempfile::tempdir().unwrap(); + let directory = parent.path().join("probe"); + let moved = parent.path().join("moved"); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join("source"), b"source").unwrap(); + let caller = std::fs::OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(&directory) + .unwrap(); + + install_new_file_before_rename( + &caller, + OsStr::new("source"), + OsStr::new("target"), + || assert!(std::fs::rename(&directory, &moved).is_err()), + ) + .unwrap(); + + assert_eq!(std::fs::read(directory.join("target")).unwrap(), b"source"); + assert!(!moved.exists()); + } + + #[test] + fn directory_guard_rejects_junction_before_publication() { + let parent = tempfile::tempdir().unwrap(); + let target_directory = parent.path().join("target-directory"); + let junction = parent.path().join("junction"); + std::fs::create_dir(&target_directory).unwrap(); + let source = target_directory.join("source"); + let published = target_directory.join("published"); + std::fs::write(&source, b"source").unwrap(); + let status = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&junction) + .arg(&target_directory) + .status() + .unwrap(); + assert!(status.success()); + let caller = std::fs::OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(&junction) + .unwrap(); + + let error = install_new_file(&caller, OsStr::new("source"), OsStr::new("published")) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::Other); + assert_eq!(std::fs::read(source).unwrap(), b"source"); + assert!(!published.exists()); } } } @@ -940,7 +1278,7 @@ mod windows { mod tests { use super::*; - fn directory_handle(path: &Path) -> File { + pub(super) fn directory_handle(path: &Path) -> File { #[cfg(unix)] return File::open(path).unwrap(); @@ -948,9 +1286,12 @@ mod tests { { use std::os::windows::fs::OpenOptionsExt as _; const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; return std::fs::OpenOptions::new() .read(true) .write(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) .open(path) .unwrap(); @@ -989,6 +1330,55 @@ mod tests { assert_eq!(std::fs::read(&second).unwrap(), b"other"); } + #[cfg(windows)] + #[test] + fn concurrent_no_replace_install_has_exactly_one_winner() { + use std::sync::{Arc, Barrier}; + + let directory = tempfile::tempdir().unwrap(); + let first = directory.path().join("first"); + let second = directory.path().join("second"); + let target = directory.path().join("target"); + std::fs::write(&first, b"first").unwrap(); + std::fs::write(&second, b"second").unwrap(); + let barrier = Arc::new(Barrier::new(3)); + let mut contenders = Vec::new(); + for name in ["first", "second"] { + let path = directory.path().to_path_buf(); + let barrier = Arc::clone(&barrier); + contenders.push(std::thread::spawn(move || { + let handle = directory_handle(&path); + barrier.wait(); + install_new_file(&handle, OsStr::new(name), OsStr::new("target")) + })); + } + barrier.wait(); + let results = contenders + .into_iter() + .map(|thread| thread.join().unwrap()) + .collect::>(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|result| { + result + .as_ref() + .is_err_and(|error| error.kind() == io::ErrorKind::AlreadyExists) + }) + .count(), + 1 + ); + let target_bytes = std::fs::read(&target).unwrap(); + assert!(target_bytes == b"first" || target_bytes == b"second"); + let loser = if target_bytes == b"first" { + second + } else { + first + }; + assert!(loser.exists()); + } + #[test] fn hard_linked_inputs_are_rejected() { let directory = tempfile::tempdir().unwrap(); diff --git a/crates/graphforge-storage/src/filesystem_admission.rs b/crates/graphforge-storage/src/filesystem_admission.rs index fb537ee0..a13f6488 100644 --- a/crates/graphforge-storage/src/filesystem_admission.rs +++ b/crates/graphforge-storage/src/filesystem_admission.rs @@ -100,8 +100,8 @@ fn filesystem_durability_preflight_inner( let probe_result = run_probe(&parent, &probe, fault); let cleanup_result = cleanup_probe(&parent, probe, fault); - cleanup_result?; probe_result?; + cleanup_result?; Ok(FilesystemAdmissionEvidence { filesystem_class, @@ -295,14 +295,27 @@ fn classify_supported_local_volume_platform(parent: &Path) -> Result Result { let information = graphforge_filesystem::windows_volume_information(parent) .map_err(|_| unsupported("CLASSIFY", "native_volume_query_failed"))?; - if information.read_only { + classify_windows_volume( + &information.filesystem_name, + information.read_only, + information.fixed, + ) +} + +#[cfg(any(test, target_os = "windows"))] +fn classify_windows_volume( + filesystem_name: &str, + read_only: bool, + fixed: bool, +) -> Result { + if read_only { return Err(unsupported("CLASSIFY", "volume_read_only")); } - if !information.fixed { + if !fixed { return Err(unsupported("CLASSIFY", "volume_not_fixed_local")); } - let class = information.filesystem_name.to_ascii_lowercase(); - if !matches!(class.as_str(), "ntfs" | "refs") { + let class = filesystem_name.to_ascii_lowercase(); + if class != "ntfs" { return Err(unsupported("CLASSIFY", "filesystem_class_unproven")); } Ok(class) @@ -426,7 +439,8 @@ fn create_private_probe_directory( graphforge_filesystem::create_private_directory(probe_root) .map_err(|_| unsupported("CREATE", "private_directory_create_failed"))?; let probe = open_probe_directory(probe_root)?; - sync_directory(parent).map_err(|_| unsupported("CREATE", "parent_flush_failed"))?; + complete_namespace_barrier(parent) + .map_err(|_| unsupported("CREATE", "parent_namespace_barrier_failed"))?; probe.revalidate("CREATE")?; Ok(probe) } @@ -475,12 +489,14 @@ fn run_probe(parent: &Path, probe: &ProbeDirectory, fault: ProbeFault) -> Result hit(fault, ProbeFault::Lock, "LOCK")?; let (target, target_identity, target_path) = replace_probe_file(probe, fault)?; - probe.revalidate("DIRECTORY_FLUSH")?; - probe - .handle - .sync_all() - .map_err(|_| unsupported("DIRECTORY_FLUSH", "probe_flush_failed"))?; - hit(fault, ProbeFault::DirectoryFlush, "DIRECTORY_FLUSH")?; + probe.revalidate("NAMESPACE_DURABILITY")?; + complete_namespace_barrier(&probe.path) + .map_err(|_| unsupported("NAMESPACE_DURABILITY", "probe_namespace_barrier_failed"))?; + hit( + fault, + ProbeFault::NamespaceDurability, + "NAMESPACE_DURABILITY", + )?; // The open handle must keep the old identity while the pathname now names // the replacement. This proves stable locked/open file identity across the @@ -503,7 +519,8 @@ fn run_probe(parent: &Path, probe: &ProbeDirectory, fault: ProbeFault) -> Result } verify_stable_identity(&published, &target_path, parent)?; drop(target); - sync_directory(parent).map_err(|_| unsupported("DIRECTORY_FLUSH", "parent_flush_failed")) + complete_namespace_barrier(parent) + .map_err(|_| unsupported("NAMESPACE_DURABILITY", "parent_namespace_barrier_failed")) } fn replace_probe_file( @@ -626,8 +643,15 @@ fn open_probe_file(probe: &ProbeDirectory, name: &str, create: bool) -> std::io: #[cfg(windows)] fn open_probe_file(probe: &ProbeDirectory, name: &str, create: bool) -> std::io::Result { + use std::os::windows::fs::OpenOptionsExt as _; + + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_FLAG_WRITE_THROUGH: u32 = 0x8000_0000; let mut options = OpenOptions::new(); - options.read(true).write(true); + options + .read(true) + .write(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH); if create { options.create_new(true); } @@ -709,32 +733,35 @@ fn cleanup_probe(parent: &Path, probe: ProbeDirectory, fault: ProbeFault) -> Res drop(probe.handle); std::fs::remove_dir(path) .map_err(|_| unsupported("CLEANUP", "private_directory_remove_failed"))?; - sync_directory(parent).map_err(|_| unsupported("CLEANUP", "parent_flush_failed")) + complete_namespace_barrier(parent) + .map_err(|_| unsupported("CLEANUP", "parent_namespace_barrier_failed")) } #[cfg(unix)] -fn sync_directory(path: &Path) -> std::io::Result<()> { +fn complete_namespace_barrier(path: &Path) -> std::io::Result<()> { File::open(path)?.sync_all() } #[cfg(windows)] -fn sync_directory(path: &Path) -> std::io::Result<()> { - use std::os::windows::fs::OpenOptionsExt as _; - - const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; - const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - OpenOptions::new() - .write(true) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) - .open(path)? - .sync_all() +fn complete_namespace_barrier(path: &Path) -> std::io::Result<()> { + // NTFS persists rename metadata through the write-through staging handle. + // Directory FlushFileBuffers is not a documented Windows durability + // barrier; here we only revalidate that the namespace parent is ordinary. + let metadata = std::fs::symlink_metadata(path)?; + if metadata.is_dir() && !is_link_or_reparse(&metadata) { + Ok(()) + } else { + Err(std::io::Error::other( + "namespace parent is linked or not a directory", + )) + } } #[cfg(all(not(unix), not(windows)))] -fn sync_directory(_path: &Path) -> std::io::Result<()> { +fn complete_namespace_barrier(_path: &Path) -> std::io::Result<()> { Err(std::io::Error::new( std::io::ErrorKind::Unsupported, - "directory flush is unsupported", + "namespace durability barrier is unsupported", )) } @@ -773,7 +800,7 @@ enum ProbeFault { FileFlush, Replace, ReplaceUnknown, - DirectoryFlush, + NamespaceDurability, Identity, Cleanup, } @@ -809,7 +836,7 @@ mod tests { let evidence = filesystem_durability_preflight(&target).unwrap(); assert!(matches!( evidence.filesystem_class.as_str(), - "apfs" | "ext" | "ext2" | "ext3" | "ext4" | "xfs" | "btrfs" | "ntfs" | "refs" + "apfs" | "ext" | "ext2" | "ext3" | "ext4" | "xfs" | "btrfs" | "ntfs" )); assert_eq!(evidence.files_created, 3); assert_eq!(evidence.bytes_written, MAX_PROBE_BYTES); @@ -817,6 +844,24 @@ mod tests { assert_eq!(std::fs::read_dir(parent.path()).unwrap().count(), 0); } + #[test] + fn windows_classifier_accepts_only_fixed_writable_ntfs() { + assert_eq!( + classify_windows_volume("NTFS", false, true).unwrap(), + "ntfs" + ); + for (class, read_only, fixed, cause) in [ + ("ReFS", false, true, "filesystem_class_unproven"), + ("FAT32", false, true, "filesystem_class_unproven"), + ("NTFS", true, true, "volume_read_only"), + ("NTFS", false, false, "volume_not_fixed_local"), + ] { + let error = classify_windows_volume(class, read_only, fixed).unwrap_err(); + assert_eq!(error.code(), "GF_UNSUPPORTED_FILESYSTEM"); + assert!(error.to_string().contains(cause), "{error}"); + } + } + #[test] fn every_injected_phase_is_typed_and_never_mutates_target() { for fault in [ @@ -826,7 +871,7 @@ mod tests { ProbeFault::FileFlush, ProbeFault::Replace, ProbeFault::ReplaceUnknown, - ProbeFault::DirectoryFlush, + ProbeFault::NamespaceDurability, ProbeFault::Identity, ProbeFault::Cleanup, ] { @@ -988,10 +1033,7 @@ mod tests { )); let target = parent.path().join("project"); let evidence = filesystem_durability_preflight(&target).unwrap(); - assert!(matches!( - evidence.filesystem_class.as_str(), - "ntfs" | "refs" - )); + assert_eq!(evidence.filesystem_class, "ntfs"); assert!(!target.exists()); assert_eq!(std::fs::read_dir(parent.path()).unwrap().count(), 0); } diff --git a/docs/adr/0013-project-generation-protocol.md b/docs/adr/0013-project-generation-protocol.md index 4ef8c54c..a7bcf6c2 100644 --- a/docs/adr/0013-project-generation-protocol.md +++ b/docs/adr/0013-project-generation-protocol.md @@ -5,7 +5,8 @@ **Build target:** v0.5.0 **Related:** ADR 0012 (domain ownership), ADR 0018 (acknowledged durability and -isolation), ADR 0019 (authoritative graph delta journal) +isolation), ADR 0019 (authoritative graph delta journal), ADR 0020 (NTFS +write-through namespace durability amendment) The public acknowledgement boundary, isolation honesty rules, and anomaly coverage matrix are frozen by @@ -15,7 +16,8 @@ graph delta runs inside a generation-owned `graph/` tree are frozen by inventory-listed generation bytes and never replace `CURRENT` as commit authority. This ADR remains the normative publication protocol; semantic changes to acknowledgement or recovery authority require an amending ADR rather -than silent edits here. +than silent edits here. [ADR 0020](0020-ntfs-write-through-namespace-durability.md) +explicitly amends the Windows filesystem and acknowledgement clauses below. ## Context @@ -42,16 +44,22 @@ a project: when a process exits; 2. same-directory atomic file creation and replacement; 3. file data-and-metadata flush; -4. directory-entry flush for every directory whose entries change; and +4. a platform-native namespace durability barrier for every changed entry; and 5. stable file identity while an open handle is locked. The supported implementations are: - POSIX local filesystems providing `fcntl`/`flock`, same-filesystem `rename(2)`, file `fsync(2)`, and directory `fsync(2)`; -- Windows local NTFS/ReFS volumes providing `LockFileEx`, `FlushFileBuffers`, - atomic same-volume replacement (`ReplaceFileW`, with atomic first creation), - and a flushable directory handle. +- Windows fixed writable local NTFS volumes providing `LockFileEx`, file + `FlushFileBuffers`, and `FILE_FLAG_WRITE_THROUGH` staging handles renamed via + `SetFileInformationByHandle` / `FILE_RENAME_INFO`. `ReplaceIfExists = FALSE` + supplies atomic first creation and `TRUE` supplies atomic replacement. ReFS + is unsupported/unproven. + +The platform-native namespace durability barrier is POSIX directory `fsync(2)` +or the NTFS write-through handle rename specified by ADR 0020. Windows +directory `FlushFileBuffers` and `ReplaceFileW` are not durability authority. The implementation identifies the backing filesystem/volume and runs a create-lock-flush-replace-flush probe in a private sibling under the proposed @@ -162,7 +170,8 @@ results. An absent path, or an explicitly supplied empty directory, may become a new container. Creation performs the filesystem preflight, creates the tree, writes -and flushes `FORMAT`, and flushes every created directory bottom-up. For an +and flushes `FORMAT`, and completes platform-native namespace barriers for each +created entry bottom-up. For an absent path, the complete private root is atomically installed from a sibling. For an explicitly empty existing directory, initialization occurs in place while holding an exclusive parent-scoped creation lock whose name is the @@ -233,9 +242,9 @@ PREPARING -> STAGED -> VALIDATED -> DURABLE -> PUBLISHED ``` The journal aids cleanup and diagnostics but never selects a generation. -Journal replacement itself uses write, file flush, atomic replace, and -directory flush. Its failure can leave an earlier valid journal state without -changing commit authority. +Journal replacement itself uses write, file flush, atomic replace, and the +platform-native namespace durability barrier. Its failure can leave an earlier +valid journal state without changing commit authority. The default writer performs these ordered operations while holding `writer.lock`. An optimistic writer performs steps 1 through 4 under its @@ -254,14 +263,15 @@ continues only when it still names the pinned parent: staged UUID index, run composite cross-domain validation, and persist the `VALIDATED` journal. No validation may read through `CURRENT` again. 5. **DURABLE.** Write and flush `lease.lock`, write and flush - `manifest.json`, reread and verify every participant and the manifest, flush - `participants/` directories from leaves upward, flush the generation - directory, promote an optimistic attempt by atomic rename into - `generations//`, flush both changed parent directories and - `generations/`, then persist the `DURABLE` journal. + `manifest.json`, reread and verify every participant and the manifest, + complete platform-native namespace durability barriers from leaves upward, + promote an optimistic attempt by atomic rename into + `generations//`, complete the barriers for both changed + parents and `generations/`, then persist the `DURABLE` journal. 6. **Publish.** Write the exact new `CURRENT` bytes to a sibling private file, flush it, atomically replace `CURRENT` (or atomically create it for the first - generation), and flush the project root. + generation), and complete the project-root platform-native namespace + durability barrier. 7. **PUBLISHED.** Persist the `PUBLISHED` journal. The writer may now release the parent reader lease, perform conservative cleanup, and release `writer.lock`. @@ -276,10 +286,13 @@ attempt is marked `ABORTED`, its private directory is removed, and `GF_WRITE_CONFLICT` is returned without promoting a generation. Domain-level rebase policy belongs above this storage protocol. -The root directory flush is required to make the pointer replacement durable -against power loss. If the process stops between pointer replacement and root -flush, reopen uses whichever complete `CURRENT` the filesystem presents; it -does not infer intent from the journal. +The project-root platform-native namespace durability barrier is required to +make the pointer replacement durable against power loss. On POSIX this is the +root directory `fsync(2)`. On Windows NTFS it is the `CURRENT` rename through +the flushed `FILE_FLAG_WRITE_THROUGH` staging handle; no directory-handle flush +is claimed. If the process stops before the barrier completes, reopen uses +whichever complete `CURRENT` the filesystem presents and never infers intent +from the journal. ### Validation and failure behavior @@ -349,8 +362,9 @@ For every other generation, cleanup: 3. acquires the candidate `lease.lock` exclusively without waiting; 4. recomputes reachability; 5. atomically moves the candidate into `trash/`; -6. flushes `generations/` and `trash/`; and -7. deletes the trash entry and flushes `trash/`. +6. completes platform-native namespace barriers for `generations/` and + `trash/`; and +7. deletes the trash entry and completes the `trash/` barrier. A busy lease skips the candidate. A crash before the move leaves it intact; a crash after the move makes it invisible to readers and eligible for deletion @@ -421,8 +435,8 @@ identity after locking to prevent path substitution. - Readers have repeatable snapshots without blocking publication. - Crashed readers and writers need no wall-clock stale-owner heuristic. - Recovery is deterministic because it never elects a generation. -- Windows and POSIX implementations must meet the same contract or fail before - mutation. +- Windows NTFS and POSIX implementations must meet their documented + platform-native barriers or fail before mutation; ReFS is unsupported. - Existing fixed-path `RewriteBatch` and standalone generation counters are transitional internals to be replaced and related knowledge-layer issues. diff --git a/docs/adr/0018-acknowledged-durability-isolation.md b/docs/adr/0018-acknowledged-durability-isolation.md index 9d08501c..5ec747ca 100644 --- a/docs/adr/0018-acknowledged-durability-isolation.md +++ b/docs/adr/0018-acknowledged-durability-isolation.md @@ -4,7 +4,8 @@ **Date:** 2026-08-15 **Build target:** v0.5.x (M6 foundations) **Related:** ADR 0013 (publication protocol), ADR 0014 (checkpoints), -ADR 0015 (write modes), issues #747–#756, adjacent M5 interchange #738/#742/#745 +ADR 0015 (write modes), ADR 0020 (NTFS write-through namespace durability +amendment), issues #747–#756, adjacent M5 interchange #738/#742/#745 ## Context @@ -34,7 +35,8 @@ project writes. Machine-readable coverage lives in Semantic changes to acknowledgement, recovery authority, filesystem scope, or isolation outcomes require a new ADR that amends or supersedes this one. Silent -doc or code drift is forbidden. +doc or code drift is forbidden. ADR 0020 is that explicit amendment for the +Windows filesystem scope and platform-native namespace durability barrier. ### Acknowledgement boundary @@ -43,25 +45,26 @@ all of the following have completed on a supported filesystem: 1. every staged participant file has been written, closed, and file-flushed; 2. `manifest.json` has been written and file-flushed, and the generation tree - (participants directories upward through the generation directory) has been - directory-flushed; + has completed its platform-native namespace durability barriers; 3. for optimistic attempts, the private attempt directory has been atomically promoted into `generations//` with the required parent - directory flushes; + namespace durability barriers; 4. the exact new `CURRENT` bytes have been written to a sibling, file-flushed, - atomically replaced or created, **and** the project-root directory entry has - been flushed. - -Step 4's project-root directory flush is part of acknowledgement. Atomic -`CURRENT` replacement alone is the visibility linearization point for new -readers, but acknowledgement of durability against power loss additionally -requires that root directory flush. Journals are never acknowledgement -authority. - -If the process dies after `CURRENT` replacement but before the root directory -flush, reopen accepts whichever exact valid `CURRENT` the filesystem presents. -It does not infer intent from journals, directory scans, timestamps, or UUID -order. + atomically replaced or created through the supported platform primitive, + **and** the project-root platform-native namespace durability barrier has + completed. + +Step 4's platform-native namespace durability barrier is part of +acknowledgement. Atomic `CURRENT` replacement alone is the visibility +linearization point for new readers. POSIX additionally requires project-root +directory `fsync(2)`; Windows NTFS performs the rename through the flushed +`FILE_FLAG_WRITE_THROUGH` staging handle and does not claim a directory-handle +`FlushFileBuffers` barrier. Journals are never acknowledgement authority. + +If the process dies after `CURRENT` replacement but before the applicable +barrier completes, reopen accepts whichever exact valid `CURRENT` the +filesystem presents. It does not infer intent from journals, directory scans, +timestamps, or UUID order. ### Platform and filesystem scope @@ -70,14 +73,14 @@ Durable projects may be created or mutated only after fail-closed preflight of: 1. exclusive and shared advisory locks released by the OS on process exit; 2. same-directory atomic file creation and replacement; 3. file data-and-metadata flush; -4. directory-entry flush for every changed directory; and +4. a platform-native namespace durability barrier for every changed entry; and 5. stable file identity while an open handle is locked. -Supported implementations remain those named by ADR 0013: POSIX local -filesystems with `fcntl`/`flock`, same-filesystem `rename(2)`, and file plus -directory `fsync(2)`; and Windows local NTFS/ReFS with `LockFileEx`, -`FlushFileBuffers`, atomic same-volume replacement, and a flushable directory -handle. +Supported implementations, as amended by ADR 0020, are POSIX local filesystems +with `fcntl`/`flock`, same-filesystem `rename(2)`, and file plus directory +`fsync(2)`; and fixed writable Windows local NTFS with `LockFileEx`, flushed +`FILE_FLAG_WRITE_THROUGH` staging handles, and same-handle +`SetFileInformationByHandle` rename. ReFS is unsupported/unproven. Network, userspace, removable, cross-device, symlink-mediated, or unknown filesystems are rejected with `GF_UNSUPPORTED_FILESYSTEM` before the project @@ -158,10 +161,12 @@ generation MUST use this vocabulary: - **stage** — write private participants without moving `CURRENT`; - **validate** — domain and composite checks against pinned parent plus staged bytes; -- **durable generation** — flushed participants + flushed manifest + flushed - generation tree (and optimistic promotion when applicable); +- **durable generation** — flushed participants + flushed manifest + completed + platform-native namespace barriers for the generation tree (and optimistic + promotion when applicable); - **linearize** — atomic `CURRENT` replacement or first creation; -- **acknowledge** — linearize plus project-root directory flush; +- **acknowledge** — linearize plus the project-root platform-native namespace + durability barrier; - **publish / published** — acknowledged-durable success visible to new opens; - **abort** — abandon staged work without moving `CURRENT`; - **recover** — reopen/classification that never elects authority from journals @@ -190,7 +195,7 @@ lock metadata beyond machine-owned IDs. | Alternative | Reason | | --- | --- | -| Treat `CURRENT` replacement alone as acknowledgement | Omits the root directory flush required against power loss | +| Treat `CURRENT` replacement alone as acknowledgement | Omits the platform-native namespace durability barrier required against power loss | | Claim SSI because readers pin snapshots | Write-skew remains possible under optimistic property merge | | Best-effort mode on network filesystems | Flush and replacement semantics are not proven | | Let journals elect authority after crash | Turns advisory cleanup into an election protocol | diff --git a/docs/adr/0019-authoritative-graph-delta-journal.md b/docs/adr/0019-authoritative-graph-delta-journal.md index ff3ae7d4..cf1b4147 100644 --- a/docs/adr/0019-authoritative-graph-delta-journal.md +++ b/docs/adr/0019-authoritative-graph-delta-journal.md @@ -73,8 +73,8 @@ Each `.gfdr` file is one immutable run. Records are length-prefixed frames with: Acknowledged runs are durable only through the ADR 0013 / ADR 0018 publication contract (participant and generation flushes, atomic `CURRENT` replacement, and -project-root directory flush). The journal never becomes a second commit -pointer. +the project-root platform-native namespace durability barrier as amended by ADR +0020). The journal never becomes a second commit pointer. ### Publication and small-write rule diff --git a/docs/adr/0020-ntfs-write-through-namespace-durability.md b/docs/adr/0020-ntfs-write-through-namespace-durability.md new file mode 100644 index 00000000..98b054d7 --- /dev/null +++ b/docs/adr/0020-ntfs-write-through-namespace-durability.md @@ -0,0 +1,101 @@ +# ADR 0020: NTFS write-through namespace durability + +**Status:** Accepted +**Date:** 2026-08-16 +**Build target:** v0.5.x (M6 native filesystem admission) +**Decision approval:** Maintainer-approved on 2026-08-16 +**Amends:** ADR 0013 (Windows publication primitive), ADR 0018 (Windows +acknowledgement and filesystem scope) +**Related:** issue #779, parent #776, lifecycle integration #780 + +## Context + +ADR 0013 and ADR 0018 treated `FlushFileBuffers` on a Windows directory handle +as equivalent to POSIX directory `fsync(2)` and listed both NTFS and ReFS as +supported. Microsoft documents neither claim. It does document NTFS metadata +write-through when a file is opened with `FILE_FLAG_WRITE_THROUGH`, and +handle-scoped rename through `SetFileInformationByHandle` and +`FILE_RENAME_INFO`. + +GraphForge needs one honest namespace-durability primitive for its bounded +filesystem probe. It must retain race-free no-replace behavior, atomic +replacement, and deterministic reconciliation without weakening +`graphforge-storage`'s unsafe-code prohibition. + +## Options considered + +1. **Keep directory `FlushFileBuffers` and NTFS/ReFS support.** This preserves + the old prose but relies on an undocumented directory durability guarantee. +2. **Use `ReplaceFileW` or `MoveFileExW` flags.** `ReplaceFileW` has no supported + write-through flag, and `MOVEFILE_WRITE_THROUGH` documents write-through for + copy-and-delete moves rather than the same-volume rename contract required + here. +3. **Use NTFS write-through staging handles and handle-scoped rename.** This is + the documented NTFS path and keeps namespace mutation tied to the exact + flushed source identity. +4. **Claim ReFS by analogy with NTFS.** ReFS exposes compatible identity and + rename APIs, but no authoritative acknowledgement-time persistence contract + has been established for this protocol. + +## Decision + +GraphForge supports Windows durability only on fixed, writable local **NTFS** +volumes whose storage stack honestly honors write-through completion. ReFS is +stable unsupported/unproven and returns `GF_UNSUPPORTED_FILESYSTEM` before the +project root is mutated. + +For each probed file publication, GraphForge: + +1. creates or reopens the private staging file with + `FILE_FLAG_WRITE_THROUGH` and without following reparse points; +2. writes and flushes the file contents; +3. opens and identity-verifies a directory guard without `FILE_SHARE_DELETE`, + then holds it through rename and reconciliation so the directory anchor + cannot be renamed or substituted; +4. retains the exact source handle and calls `SetFileInformationByHandle` with + `FILE_RENAME_INFO` using the full normalized target path derived from that + directory guard and a null `RootDirectory`; +5. uses `FileRenameInfo` with `ReplaceIfExists = FALSE` for atomic first + creation and `FileRenameInfoEx` with `FILE_RENAME_REPLACE_IF_EXISTS | + FILE_RENAME_POSIX_SEMANTICS` for atomic replacement, retaining the old + target handle while making new opens of the target name resolve to the + replacement; and +6. reconciles the retained source identity, retained target identity when + replacing, and both source and target names after success or any reported + error. + +`ReplaceFileW` and `FlushFileBuffers` on a directory handle are not durability +authority. POSIX keeps file `fsync(2)` plus directory `fsync(2)`. The shared +contract term is therefore **platform-native namespace durability barrier**: +POSIX directory `fsync(2)`, or the NTFS write-through handle rename above. + +Unsafe Windows FFI remains isolated in `graphforge-filesystem`. +`graphforge-storage` consumes only its safe Rust interface. Routing all project +lifecycle call sites through these primitives belongs to #780; #779 proves the +native backend and fail-closed admission contract. + +## Consequences + +- Windows support is narrower but evidence-backed: NTFS only, with ReFS and all + other classes rejected as unsupported/unproven. +- The no-replace operation remains race-free because the operating system, not + a pathname precheck, decides destination existence. +- Replacement and no-replace failures cannot be reported as clean failures + until source/target identities and names reconcile; otherwise the result is + state-unknown and callers must reconcile authority. +- POSIX acknowledgement remains unchanged and still requires directory + `fsync(2)` for changed entries. +- GraphForge cannot prove durability if a drive, controller, hypervisor, or + filesystem falsely acknowledges write-through completion. Such storage is + outside the supported contract even when the volume reports `NTFS`. + +## Required verification + +- deterministic classifier coverage accepts only fixed writable NTFS and + rejects ReFS with `GF_UNSUPPORTED_FILESYSTEM`; +- Windows-native tests cover write-through handle creation, same-handle + no-replace and replacement rename, competing destinations, and identity/name + reconciliation; +- Linux/macOS tests retain file and directory `fsync` behavior; and +- CI policy requires the Windows NTFS and macOS native probe jobs under the + exact-head merge gate. diff --git a/docs/adr/README.md b/docs/adr/README.md index 87b250d2..c46b8c14 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,6 +25,7 @@ are not retained in this tree. | 0017 | [One version across core and adapters](0017-unified-release-version.md) | `0017-unified-release-version.md` | | 0018 | [Acknowledged durability and isolation contract](0018-acknowledged-durability-isolation.md) | `0018-acknowledged-durability-isolation.md` | | 0019 | [Authoritative durable graph delta journal](0019-authoritative-graph-delta-journal.md) | `0019-authoritative-graph-delta-journal.md` | +| 0020 | [NTFS write-through namespace durability](0020-ntfs-write-through-namespace-durability.md) | `0020-ntfs-write-through-namespace-durability.md` | ## Numbering diff --git a/docs/book/architecture/concurrency-recovery.md b/docs/book/architecture/concurrency-recovery.md index 0bd30546..fd8d87a1 100644 --- a/docs/book/architecture/concurrency-recovery.md +++ b/docs/book/architecture/concurrency-recovery.md @@ -22,16 +22,24 @@ continues to a complete result, while cooperative cancellation reports ## Acknowledged-durable writes Caller-visible success means the write is **acknowledged-durable** only after -participant and manifest file flushes, generation-tree directory flushes, -atomic `CURRENT` replacement or creation, **and** the project-root directory -flush. Atomic `CURRENT` replacement is the visibility linearization point for -new readers; acknowledgement against power loss additionally requires that root -directory flush. Journals never select authority. - -Supported filesystems are the fail-closed local POSIX and Windows classes named -by ADR 0013. Network, userspace, removable, cross-device, symlink-mediated, and -unknown filesystems return `GF_UNSUPPORTED_FILESYSTEM` before the project root -or `CURRENT` changes. There is no best-effort durability mode. +participant and manifest file flushes, generation-tree platform-native namespace +durability barriers, atomic `CURRENT` replacement or creation, **and** the +project-root platform-native namespace durability barrier. Atomic `CURRENT` +replacement is the visibility linearization point for new readers; +acknowledgement against power loss additionally requires that final namespace +barrier. Journals never select authority. + +POSIX supplies the namespace barrier with directory `fsync(2)`. Windows support +is limited to fixed writable local NTFS whose storage honestly honors +write-through completion: GraphForge flushes a `FILE_FLAG_WRITE_THROUGH` +staging handle and renames through that same handle with +`SetFileInformationByHandle`. Directory `FlushFileBuffers` is not claimed as a +durability barrier, and ReFS remains unsupported/unproven. See +[ADR 0020](../../adr/0020-ntfs-write-through-namespace-durability.md). + +Network, userspace, removable, cross-device, symlink-mediated, ReFS, and unknown +filesystems return `GF_UNSUPPORTED_FILESYSTEM` before the project root or +`CURRENT` changes. There is no best-effort durability mode. Recovery resolves an exact valid `CURRENT` only. Journals and directory scans are advisory cleanup input. Corrupt or ambiguous pointers fail closed as @@ -125,8 +133,8 @@ There are four CI surfaces for concurrency and durability contracts: maps crash phases and anomalies to covered evidence. Repository Policy validates the ledger without compiling Rust. Persistent-media faults that process kill cannot express (torn `CURRENT` / - manifest bytes, lost root-directory flush power-loss subsets) are modeled by - the deterministic filesystem fault oracle in + manifest bytes, lost platform-native namespace durability barrier power-loss + subsets) are modeled by the deterministic filesystem fault oracle in `crates/graphforge-storage/src/project_fault_oracle.rs`. Native POSIX and Windows subprocess failpoint matrices remain required for real API and handle behavior; the oracle is reusable by recovery, delta, compaction, and final diff --git a/docs/engineering/adrs/README.md b/docs/engineering/adrs/README.md index 0a3ae5a2..1a3dfacc 100644 --- a/docs/engineering/adrs/README.md +++ b/docs/engineering/adrs/README.md @@ -56,3 +56,4 @@ Keeper set after #2730 (mirrors [`../../adr/README.md`](../../adr/README.md)): | 0017 | One version across core and adapters | Accepted | [`../../adr/0017-unified-release-version.md`](../../adr/0017-unified-release-version.md) | | 0018 | Acknowledged durability and isolation contract | Accepted | [`../../adr/0018-acknowledged-durability-isolation.md`](../../adr/0018-acknowledged-durability-isolation.md) | | 0019 | Authoritative durable graph delta journal | Accepted | [`../../adr/0019-authoritative-graph-delta-journal.md`](../../adr/0019-authoritative-graph-delta-journal.md) | +| 0020 | NTFS write-through namespace durability | Accepted | [`../../adr/0020-ntfs-write-through-namespace-durability.md`](../../adr/0020-ntfs-write-through-namespace-durability.md) | diff --git a/docs/guides/repository-integration.md b/docs/guides/repository-integration.md index 5d82a107..1646821c 100644 --- a/docs/guides/repository-integration.md +++ b/docs/guides/repository-integration.md @@ -183,10 +183,14 @@ type, every integrity hash, and every required capability version. Any failure leaves the target without a newly published `CURRENT` (abort before linearize). A successful import stages and verifies all participants into a durable generation, then linearizes by atomically replacing or creating `CURRENT`, and -acknowledges only after the project-root directory flush so reopen recovers the -published generation. Import does not merge into or overwrite an existing -project. These stage / validate / durable generation / linearize / acknowledge / -publish / abort / recover terms are the shared publication vocabulary frozen by +acknowledges only after the project-root platform-native namespace durability +barrier so reopen recovers the published generation. POSIX uses directory +`fsync(2)`; fixed writable local NTFS uses the write-through same-handle rename +contract in [ADR 0020](../adr/0020-ntfs-write-through-namespace-durability.md), +while ReFS is unsupported/unproven. Import does not merge into or overwrite an +existing project. These stage / validate / durable generation / linearize / +acknowledge / publish / abort / recover terms are the shared publication +vocabulary frozen by [ADR 0018](../adr/0018-acknowledged-durability-isolation.md); M5 interchange issues (#738, #742, #745) consume that vocabulary rather than redefining it. diff --git a/docs/reference/api.md b/docs/reference/api.md index 42514150..bc7b961c 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -74,7 +74,11 @@ other mutation APIs retain their single-writer behavior. See in [ADR 0018](../adr/0018-acknowledged-durability-isolation.md) (including why optimistic mode admits write-skew and is not SSI). Success on a persistent project means the write is acknowledged-durable only after participant/manifest -flushes, `CURRENT` replacement, and the project-root directory flush. +flushes, `CURRENT` replacement, and the project-root +platform-native namespace durability barrier. POSIX uses directory `fsync(2)`; +fixed writable local NTFS uses a flushed write-through staging handle and +same-handle rename. ReFS is unsupported/unproven. See +[ADR 0020](../adr/0020-ntfs-write-through-namespace-durability.md). --- diff --git a/scripts/ci/durability-isolation-gate.py b/scripts/ci/durability-isolation-gate.py index cbd6ae4f..2d51c4b4 100644 --- a/scripts/ci/durability-isolation-gate.py +++ b/scripts/ci/durability-isolation-gate.py @@ -31,7 +31,7 @@ "before_current_replace", "after_current_replace", "post_linearization_api_error", - "lost_root_directory_flush_power_loss", + "lost_namespace_durability_barrier_power_loss", "torn_current_or_manifest_bytes", "recovery_on_open_interrupted_transaction", } @@ -54,6 +54,19 @@ "abort", "recover", } +REQUIRED_FILESYSTEM_SUPPORTED = { + "posix_local_fcntl_flock_rename_fsync_dir_fsync", + "windows_local_ntfs_lockfileex_write_through_setfileinformationbyhandle", +} +REQUIRED_FILESYSTEM_REJECTED = { + "network", + "userspace", + "removable", + "cross_device", + "symlink_mediated", + "windows_refs_unproven", + "unknown", +} FORBIDDEN_POSITIVE_PATTERNS = [ re.compile(r"\bACID\b"), re.compile(r"\bSSI\b"), @@ -219,6 +232,11 @@ def validate_matrix(path: Path = MATRIX_PATH) -> dict[str, Any]: raise GateError("reconciled_adrs must list ADR 0013-0015 paths") for entry in reconciled: require_repo_file(entry, "reconciled_adrs") + amending = matrix.get("amending_adrs") + if amending != ["docs/adr/0020-ntfs-write-through-namespace-durability.md"]: + raise GateError("amending_adrs must list ADR 0020") + for entry in amending: + require_repo_file(entry, "amending_adrs") acknowledgement = matrix.get("acknowledgement") if not isinstance(acknowledgement, dict): @@ -226,8 +244,9 @@ def validate_matrix(path: Path = MATRIX_PATH) -> dict[str, Any]: if acknowledgement.get("name") != "acknowledged-durable": raise GateError("acknowledgement.name must be acknowledged-durable") requires = acknowledgement.get("requires") - if not isinstance(requires, list) or "project_root_directory_flush" not in requires: - raise GateError("acknowledgement must require project_root_directory_flush") + barrier = "project_root_platform_native_namespace_durability_barrier" + if not isinstance(requires, list) or barrier not in requires: + raise GateError(f"acknowledgement must require {barrier}") if acknowledgement.get("linearization_point") != "current_atomic_replace_or_create": raise GateError("linearization_point must be current_atomic_replace_or_create") @@ -238,6 +257,12 @@ def validate_matrix(path: Path = MATRIX_PATH) -> dict[str, Any]: raise GateError("filesystem_scope.error must be GF_UNSUPPORTED_FILESYSTEM") if filesystem.get("best_effort_allowed") is not False: raise GateError("filesystem_scope.best_effort_allowed must be false") + if set(filesystem.get("supported", [])) != REQUIRED_FILESYSTEM_SUPPORTED: + raise GateError( + "filesystem_scope.supported must be exactly the proven POSIX and NTFS classes" + ) + if set(filesystem.get("rejected", [])) != REQUIRED_FILESYSTEM_REJECTED: + raise GateError("filesystem_scope.rejected must include ReFS and every unproven class") recovery = matrix.get("recovery_authority") if not isinstance(recovery, dict) or recovery.get("sole") != "exact_valid_CURRENT": @@ -310,11 +335,12 @@ def validate_matrix(path: Path = MATRIX_PATH) -> dict[str, Any]: adr, [ "acknowledged-durable", - "project-root directory flush", + "project-root platform-native namespace durability barrier", "GF_UNSUPPORTED_FILESYSTEM", "Write-skew witness", "exact, valid `CURRENT`", "Publication vocabulary", + "ADR 0020", ], "adr", ) @@ -328,6 +354,8 @@ def validate_matrix(path: Path = MATRIX_PATH) -> dict[str, Any]: "queued_writer", "optimistic_multi_writer", "graphforge-durability-isolation/1", + "platform-native namespace durability barrier", + "ADR 0020", ], "architecture_doc", ) @@ -335,8 +363,10 @@ def validate_matrix(path: Path = MATRIX_PATH) -> dict[str, Any]: api_doc, [ "ADR 0018", + "ADR 0020", "acknowledged-durable", "write-skew", + "platform-native namespace durability barrier", ], "api_doc", ) diff --git a/scripts/ci/test-binding-release-candidate.py b/scripts/ci/test-binding-release-candidate.py index 51dfd9b8..ff3f5b72 100644 --- a/scripts/ci/test-binding-release-candidate.py +++ b/scripts/ci/test-binding-release-candidate.py @@ -14,6 +14,15 @@ import sys import tempfile +from workflow_policy import ( + job_needs, + job_required_run_scalars, + job_runs_exact, + job_scalar, + normalize_run, + workflow_jobs, +) + ROOT = Path(__file__).resolve().parents[2] VALIDATOR = ROOT / "scripts/ci/validate-binding-release-candidate.py" CONTRACT = ROOT / "tests/contracts/binding-release-candidate-targets.json" @@ -135,67 +144,170 @@ def workflow_step(section: str, marker: str) -> str: return remainder if end < 0 else remainder[:end] -def workflow_jobs(text: str) -> dict[str, str]: - """Split a workflow into top-level job ID to job-body mappings.""" - lines = text.splitlines() - try: - jobs_index = next(index for index, line in enumerate(lines) if line.rstrip() == "jobs:") - except StopIteration as exc: - raise AssertionError("workflow is missing a top-level jobs: mapping") from exc - jobs: dict[str, str] = {} - current: str | None = None - body: list[str] = [] - for line in lines[jobs_index + 1 :]: - indent = len(line) - len(line.lstrip()) - if indent == 2 and line.rstrip().endswith(":") and not line.lstrip().startswith("- "): - if current is not None: - jobs[current] = "\n".join(body) - current = line.strip()[:-1] - body = [] - continue - if current is None: - continue - if line.strip() and indent < 2: - break - body.append(line) - if current is not None: - jobs[current] = "\n".join(body) - assert jobs, "workflow jobs: mapping is empty" - return jobs - - -def job_needs(job_body: str) -> set[str]: - """Return the active top-level needs entries for one job.""" - lines = job_body.splitlines() - for index, line in enumerate(lines): - if not line.strip().startswith("needs:"): +WINDOWS_DURABILITY_JOB = "windows-graphforge-storage-locks" +MACOS_DURABILITY_JOB = "macos-graphforge-storage-durability" +WINDOWS_RUNNER = "blacksmith-4vcpu-windows-2025" +MACOS_RUNNER = "blacksmith-12vcpu-macos-15" +WINDOWS_PROJECT_LOCK_COMMAND = ( + "cargo test -p graphforge-storage project_generation::tests:: --lib --no-fail-fast" +) +STORAGE_ADMISSION_COMMAND = ( + "cargo test -p graphforge-storage filesystem_admission::tests:: --lib --no-fail-fast" +) +FILESYSTEM_COMMAND = "cargo test -p graphforge-filesystem --lib --no-fail-fast" + + +def validate_native_test_workflow(workflow_text: str) -> None: + """Prove native durability jobs and their CI Gate aggregation structurally.""" + jobs = workflow_jobs(workflow_text) + windows = jobs[WINDOWS_DURABILITY_JOB] + macos = jobs[MACOS_DURABILITY_JOB] + assert job_scalar(windows, "runs-on") == WINDOWS_RUNNER + assert job_scalar(macos, "runs-on") == MACOS_RUNNER + for body in (windows, macos): + assert job_needs(body) == {"changes"} + assert job_scalar(body, "if") == "needs.changes.outputs.rust == 'true'" + assert job_runs_exact(body, STORAGE_ADMISSION_COMMAND) + assert job_runs_exact(body, FILESYSTEM_COMMAND) + assert job_runs_exact(windows, WINDOWS_PROJECT_LOCK_COMMAND) + + gate = jobs["ci-gate"] + assert {WINDOWS_DURABILITY_JOB, MACOS_DURABILITY_JOB} <= job_needs(gate) + gate_scalars = [ + scalar + for scalar in job_required_run_scalars(gate, "scripts/ci/require-gates.sh") + if normalize_run(scalar).startswith("scripts/ci/require-gates.sh ") + ] + assert len(gate_scalars) == 1, "CI Gate must have one active require-gates.sh run scalar" + gate_scalar = normalize_run(gate_scalars[0]) + assert f"needs.{WINDOWS_DURABILITY_JOB}.result" in gate_scalar + assert f"needs.{MACOS_DURABILITY_JOB}.result" in gate_scalar + + +def validate_native_workflow_negative_fixtures() -> None: + """Reject tokens hidden in comments, env, nested fields, or echo commands.""" + fixture = f"""jobs: + {WINDOWS_DURABILITY_JOB}: + runs-on: {WINDOWS_RUNNER} + needs: changes + if: needs.changes.outputs.rust == 'true' + steps: + - run: >- + {WINDOWS_PROJECT_LOCK_COMMAND} + - run: >- + {STORAGE_ADMISSION_COMMAND} + - run: >- + {FILESYSTEM_COMMAND} + {MACOS_DURABILITY_JOB}: + runs-on: {MACOS_RUNNER} + needs: changes + if: needs.changes.outputs.rust == 'true' + steps: + - run: >- + {STORAGE_ADMISSION_COMMAND} + - run: >- + {FILESYSTEM_COMMAND} + ci-gate: + runs-on: blacksmith-4vcpu-ubuntu-2404 + needs: + - {WINDOWS_DURABILITY_JOB} + - {MACOS_DURABILITY_JOB} + steps: + - run: >- + scripts/ci/require-gates.sh + "${{{{ needs.{WINDOWS_DURABILITY_JOB}.result }}}}" + "${{{{ needs.{MACOS_DURABILITY_JOB}.result }}}}" +""" + validate_native_test_workflow(fixture) + + adversarial = [ + fixture.replace( + f" runs-on: {WINDOWS_RUNNER}", + f" runs-on: wrong\n # runs-on: {WINDOWS_RUNNER}", + 1, + ), + fixture.replace( + f" - run: >-\n {FILESYSTEM_COMMAND}", + f' - run: echo "{FILESYSTEM_COMMAND}"\n env:\n' + f' CLAIMED_COMMAND: "{FILESYSTEM_COMMAND}"', + 1, + ), + fixture.replace( + " needs: changes", + " needs: wrong\n strategy:\n needs: changes", + 1, + ), + fixture.replace( + f" {STORAGE_ADMISSION_COMMAND}", + f' echo "{STORAGE_ADMISSION_COMMAND}"', + 1, + ), + fixture.replace( + f' "${{{{ needs.{WINDOWS_DURABILITY_JOB}.result }}}}"\n' + f' "${{{{ needs.{MACOS_DURABILITY_JOB}.result }}}}"', + f' "${{{{ needs.changes.result }}}}"\n' + f' - run: echo "${{{{ needs.{WINDOWS_DURABILITY_JOB}.result }}}}"\n' + f' - run: echo "${{{{ needs.{MACOS_DURABILITY_JOB}.result }}}}"', + 1, + ), + fixture.replace( + f" {FILESYSTEM_COMMAND}", + f" {FILESYSTEM_COMMAND} || true", + 1, + ), + fixture.replace( + " scripts/ci/require-gates.sh", + " scripts/ci/require-gates.sh || echo ignored", + 1, + ), + fixture.replace( + f' "${{{{ needs.{MACOS_DURABILITY_JOB}.result }}}}"', + f' "${{{{ needs.{MACOS_DURABILITY_JOB}.result }}}}" ; true', + 1, + ), + fixture.replace( + " scripts/ci/require-gates.sh", + " ! scripts/ci/require-gates.sh", + 1, + ), + fixture.replace( + f" {FILESYSTEM_COMMAND}", + f" {FILESYSTEM_COMMAND} &", + 1, + ), + fixture.replace( + " scripts/ci/require-gates.sh", + " scripts/ci/require-gates.sh ; set +o errexit", + 1, + ), + fixture.replace( + " scripts/ci/require-gates.sh", + " scripts/ci/require-gates.sh ; set +o pipefail", + 1, + ), + fixture.replace( + " scripts/ci/require-gates.sh", + " set +o errexit\n scripts/ci/require-gates.sh", + 1, + ), + fixture.replace( + f' "${{{{ needs.{MACOS_DURABILITY_JOB}.result }}}}"', + f' "${{{{ needs.{MACOS_DURABILITY_JOB}.result }}}}" && true; echo ignored', + 1, + ), + fixture.replace( + f' "${{{{ needs.{MACOS_DURABILITY_JOB}.result }}}}"', + f' "${{{{ needs.{MACOS_DURABILITY_JOB}.result }}}}"' + " ok && false; echo ignored && true", + 1, + ), + ] + for hostile in adversarial: + try: + validate_native_test_workflow(hostile) + except AssertionError: continue - value = line.strip().split(":", 1)[1].strip() - if value: - if value.startswith("[") and value.endswith("]"): - return { - item.strip().strip("'\"") for item in value[1:-1].split(",") if item.strip() - } - return {value.strip("'\"")} - indent = len(line) - len(line.lstrip()) - needed: set[str] = set() - for follow in lines[index + 1 :]: - if not follow.strip(): - continue - follow_indent = len(follow) - len(follow.lstrip()) - if follow_indent <= indent: - break - item = follow.strip() - if item.startswith("- "): - needed.add(item[2:].strip().strip("'\"")) - return needed - return set() - - -def job_runs_command(job_body: str, command: str) -> bool: - """Require one complete folded or literal run command in a job.""" - normalized = " ".join(job_body.split()) - return " ".join(command.split()) in normalized + raise AssertionError("native workflow policy accepted an adversarial fixture") def validate_python_evidence_policy(workflow_text: str) -> None: @@ -641,57 +753,8 @@ def main() -> None: assert "native_builder: bazel" in python_job assert "native_builder: maturin" in python_job test_workflow_text = (ROOT / ".github/workflows/test.yml").read_text() - test_jobs = workflow_jobs(test_workflow_text) - windows_locks_job = test_jobs["windows-graphforge-storage-locks"] - assert_active_lines( - windows_locks_job, - "runs-on: blacksmith-4vcpu-windows-2025", - "needs: changes", - "if: needs.changes.outputs.rust == 'true'", - ) - assert job_needs(windows_locks_job) == {"changes"} - assert job_runs_command( - windows_locks_job, - "cargo test -p graphforge-storage project_generation::tests:: --lib --no-fail-fast", - ) - assert job_runs_command( - windows_locks_job, - "cargo test -p graphforge-storage filesystem_admission::tests:: --lib --no-fail-fast", - ) - assert job_runs_command( - windows_locks_job, - "cargo test -p graphforge-filesystem --lib --no-fail-fast", - ) - macos_durability_job = test_jobs["macos-graphforge-storage-durability"] - assert_active_lines( - macos_durability_job, - "runs-on: blacksmith-12vcpu-macos-15", - "needs: changes", - "if: needs.changes.outputs.rust == 'true'", - ) - assert job_needs(macos_durability_job) == {"changes"} - assert job_runs_command( - macos_durability_job, - "cargo test -p graphforge-storage filesystem_admission::tests:: --lib --no-fail-fast", - ) - assert job_runs_command( - macos_durability_job, - "cargo test -p graphforge-filesystem --lib --no-fail-fast", - ) - ci_gate = test_jobs["ci-gate"] - assert { - "windows-graphforge-storage-locks", - "macos-graphforge-storage-durability", - } <= job_needs(ci_gate) - assert job_runs_command( - ci_gate, - 'scripts/ci/require-gates.sh "${{ needs.changes.result }}"', - ) - assert job_runs_command( - ci_gate, - '"${{ needs.windows-graphforge-storage-locks.result }}" ' - '"${{ needs.macos-graphforge-storage-durability.result }}"', - ) + validate_native_test_workflow(test_workflow_text) + validate_native_workflow_negative_fixtures() assert "macos-latest" not in rc_workflow_text assert "macos-15-intel" not in rc_workflow_text assert "windows-latest" not in rc_workflow_text diff --git a/scripts/ci/test-ci-storage-policy.py b/scripts/ci/test-ci-storage-policy.py index 5f990e6a..13953b7f 100644 --- a/scripts/ci/test-ci-storage-policy.py +++ b/scripts/ci/test-ci-storage-policy.py @@ -37,6 +37,17 @@ from pathlib import Path import re +from workflow_policy import ( + job_needs, + job_required_run_scalars, + job_run_contains, + job_run_scalars, + job_runs_exact, + job_scalar, + normalize_run, + workflow_jobs, +) + ROOT = Path(__file__).resolve().parents[2] _SHA_RE = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) @@ -436,83 +447,67 @@ def validate_test_suite_trigger(text: str) -> None: ) -def workflow_jobs(text: str) -> dict[str, str]: - """Split a workflow into top-level job_id -> job body (after the job key line).""" - lines = text.splitlines() - try: - jobs_index = next(index for index, line in enumerate(lines) if line.rstrip() == "jobs:") - except StopIteration as exc: - raise AssertionError("workflow is missing a top-level jobs: mapping") from exc - jobs: dict[str, str] = {} - current: str | None = None - body: list[str] = [] - for line in lines[jobs_index + 1 :]: - if not line.strip() or line.lstrip().startswith("#"): - if current is not None: - body.append(line) - continue - indent = len(line) - len(line.lstrip()) - if indent == 2 and line.rstrip().endswith(":") and not line.lstrip().startswith("- "): - if current is not None: - jobs[current] = "\n".join(body) - current = line.strip()[:-1] - body = [] +def validate_required_run_negative_fixtures() -> None: + """Required command matching rejects common shell failure suppression.""" + command = "python3 scripts/ci/cargo-bazel-parity-check.py --mode inventory" + fixture = f"""jobs: + probe: + steps: + - run: >- + {command} +""" + assert job_run_contains(workflow_jobs(fixture)["probe"], command) + for suffix in (" &> command.log", " 2>&1"): + allowed = fixture.replace(command, f"{command}{suffix}") + assert job_run_contains(workflow_jobs(allowed)["probe"], command) + for suffix, prefix in ( + (" || true", ""), + (" || echo ignored", ""), + (" ; true", ""), + ("", "! "), + (" &", ""), + (" ; set +o errexit", ""), + (" ; set +o pipefail", ""), + ("", "set +o errexit\n "), + (" && true; echo ignored", ""), + (" ok && false; echo ignored && true", ""), + ("; exit 0", ""), + ): + hostile = fixture.replace(command, f"{prefix}{command}{suffix}") + try: + accepted = job_run_contains(workflow_jobs(hostile)["probe"], command) + except AssertionError: continue - if current is None: + if not accepted: continue - if indent < 2: - break - body.append(line) - if current is not None: - jobs[current] = "\n".join(body) - assert jobs, "workflow jobs: mapping is empty" - return jobs - - -def job_display_name(job_body: str) -> str | None: - for line in job_body.splitlines(): - stripped = line.strip() - if stripped.startswith("name:"): - return stripped.split(":", 1)[1].strip().strip("'\"") - return None - - -def job_needs(job_body: str) -> set[str]: - lines = job_body.splitlines() - needed: set[str] = set() - for index, line in enumerate(lines): - stripped = line.strip() - if not stripped.startswith("needs:"): + raise AssertionError("required run policy accepted failure suppression") + for wrapper in ( + f"if false; then\n {command}\n fi", + f"if ! true; then\n {command}\n fi", + f"while false; do\n {command}\n done", + f"until true; do\n {command}\n done", + f"for item in one; do\n {command}\n done", + f"run_gate() {{\n {command}\n }}", + f"cat <<'EOF'\n {command}\n EOF", + ): + hostile = fixture.replace(command, wrapper) + try: + accepted = job_run_contains(workflow_jobs(hostile)["probe"], command) + except AssertionError: continue - value = stripped.split(":", 1)[1].strip() - if value.startswith("[") and value.endswith("]"): - inner = value[1:-1] - needed.update(part.strip().strip("'\"") for part in inner.split(",") if part.strip()) - break - if value and value not in {"|", ">"}: - needed.add(value.strip("'\"")) - break - indent = len(line) - len(line.lstrip()) - for follow in lines[index + 1 :]: - if not follow.strip(): - continue - follow_indent = len(follow) - len(follow.lstrip()) - if follow_indent <= indent: - break - item = follow.strip() - if item.startswith("- "): - needed.add(item[2:].strip().strip("'\"")) - elif item.startswith("[") and item.endswith("]"): - inner = item[1:-1] - needed.update( - part.strip().strip("'\"") for part in inner.split(",") if part.strip() - ) - break - return {item for item in needed if item} + assert not accepted + separated_jobs = f"""jobs: + first: + steps: + - run: echo first +# A top-level comment must not hide later jobs. -def job_runs_command(job_body: str, needle: str) -> bool: - return needle in job_body + probe: + steps: + - run: {command} +""" + assert job_run_contains(workflow_jobs(separated_jobs)["probe"], command) def validate_ci_gate_cutover(text: str) -> None: @@ -522,7 +517,7 @@ def validate_ci_gate_cutover(text: str) -> None: "Cargo rust-test job must stay retired after CI Gate cutover (#4)" ) for job_id, body in jobs.items(): - assert job_display_name(body) != "Rust Tests", ( + assert job_scalar(body, "name") != "Rust Tests", ( f"job {job_id!r} must not restore retired Cargo Rust Tests display name" ) sticky, _ = sticky_contracts(body) @@ -531,15 +526,15 @@ def validate_ci_gate_cutover(text: str) -> None: authoritative = [ job_id for job_id, body in jobs.items() - if job_runs_command(body, "bazelisk test //:ci_rust_tests") - or job_runs_command(body, "bazelisk test --config=ci //:ci_rust_tests") + if job_run_contains(body, "bazelisk test //:ci_rust_tests") + or job_run_contains(body, "bazelisk test --config=ci //:ci_rust_tests") ] assert len(authoritative) == 1, ( "exactly one Test Suite job must run authoritative bazelisk test //:ci_rust_tests" ) auth_job = authoritative[0] - gate_jobs = [job_id for job_id, body in jobs.items() if job_display_name(body) == "CI Gate"] + gate_jobs = [job_id for job_id, body in jobs.items() if job_scalar(body, "name") == "CI Gate"] assert len(gate_jobs) == 1, "required check context must remain exactly one CI Gate job" gate_id = gate_jobs[0] gate_body = jobs[gate_id] @@ -551,32 +546,40 @@ def validate_ci_gate_cutover(text: str) -> None: assert "bazel-diagnostics" not in needed, ( "CI Gate must not require bazel-diagnostics (non-required diagnostic lane)" ) - assert f"needs.{auth_job}.result" in gate_body, ( + gate_runs = [ + normalize_run(scalar) + for scalar in job_required_run_scalars(gate_body, "scripts/ci/require-gates.sh") + if normalize_run(scalar).startswith("scripts/ci/require-gates.sh ") + ] + assert len(gate_runs) == 1, "CI Gate must have one active require-gates.sh run scalar" + gate_run = gate_runs[0] + assert f"needs.{auth_job}.result" in gate_run, ( f"CI Gate must require {auth_job}.result via require-gates.sh" ) - assert "needs.rust-test.result" not in gate_body, ( + assert "needs.rust-test.result" not in gate_run, ( "CI Gate must not reference needs.rust-test.result" ) - assert "needs.bazel-diagnostics.result" not in gate_body, ( + assert "needs.bazel-diagnostics.result" not in gate_run, ( "CI Gate must not reference needs.bazel-diagnostics.result" ) assert "bazel-diagnostics" in jobs, "diagnostic dual-build/cache observe job must exist" diag_body = jobs["bazel-diagnostics"] - assert job_runs_command(diag_body, "cargo-bazel-parity-check.py"), ( + assert job_run_contains(diag_body, "python3 scripts/ci/cargo-bazel-parity-check.py"), ( "bazel-diagnostics must run dual-build parity" ) assert "|| echo" not in diag_body, ( "bazel-diagnostics must fail closed; no fabricated zero-hit JSON fallback" ) - assert not job_runs_command(jobs[auth_job], "cargo-bazel-parity-check.py --mode all"), ( - "authoritative bazel-bootstrap must not run dual-build parity" - ) - assert job_runs_command(jobs[auth_job], "cargo-bazel-parity-check.py --mode inventory"), ( - "authoritative bazel-bootstrap must run live suite-membership inventory" - ) + assert not job_run_contains( + jobs[auth_job], "python3 scripts/ci/cargo-bazel-parity-check.py --mode all" + ), "authoritative bazel-bootstrap must not run dual-build parity" + assert job_run_contains( + jobs[auth_job], "python3 scripts/ci/cargo-bazel-parity-check.py --mode inventory" + ), "authoritative bazel-bootstrap must run live suite-membership inventory" inventory_lines = [ line - for line in jobs[auth_job].splitlines() + for scalar in job_run_scalars(jobs[auth_job]) + for line in scalar.splitlines() if "cargo-bazel-parity-check.py --mode inventory" in line ] assert inventory_lines, "live inventory command line must be present in bazel-bootstrap" @@ -589,6 +592,7 @@ def main() -> None: texts = {path: path.read_text(encoding="utf-8") for path in sorted(WORKFLOWS.glob("*.y*ml"))} test_suite = texts[WORKFLOWS / "test.yml"] validate_test_suite_trigger(test_suite) + validate_required_run_negative_fixtures() validate_ci_gate_cutover(test_suite) jobs = workflow_jobs(test_suite) for job_id, runner in ( @@ -596,17 +600,27 @@ def main() -> None: ("macos-graphforge-storage-durability", "blacksmith-12vcpu-macos-15"), ): body = jobs[job_id] - assert f"runs-on: {runner}" in body - assert job_runs_command(body, "cargo test -p graphforge-filesystem --lib --no-fail-fast") - assert job_runs_command(body, "filesystem_admission::tests:: --lib --no-fail-fast") + assert job_scalar(body, "runs-on") == runner + assert job_runs_exact(body, "cargo test -p graphforge-filesystem --lib --no-fail-fast") + assert job_runs_exact( + body, + "cargo test -p graphforge-storage filesystem_admission::tests:: --lib --no-fail-fast", + ) gate = jobs["ci-gate"] gate_dependencies = job_needs(gate) - for job_id in ( + native_jobs = ( "windows-graphforge-storage-locks", "macos-graphforge-storage-durability", - ): + ) + gate_runs = [ + normalize_run(scalar) + for scalar in job_required_run_scalars(gate, "scripts/ci/require-gates.sh") + if normalize_run(scalar).startswith("scripts/ci/require-gates.sh ") + ] + assert len(gate_runs) == 1 + for job_id in native_jobs: assert job_id in gate_dependencies - assert f"needs.{job_id}.result" in gate + assert f"needs.{job_id}.result" in gate_runs[0] artifact_uploads: list[str] = [] artifact_downloads: list[str] = [] diff --git a/scripts/ci/test-durability-isolation-gate.py b/scripts/ci/test-durability-isolation-gate.py index 64428de2..ebf5ce17 100644 --- a/scripts/ci/test-durability-isolation-gate.py +++ b/scripts/ci/test-durability-isolation-gate.py @@ -72,7 +72,7 @@ def test_mutations_fail_closed(self) -> None: data["acknowledgement"]["requires"] = [ item for item in data["acknowledgement"]["requires"] - if item != "project_root_directory_flush" + if item != "project_root_platform_native_namespace_durability_barrier" ] path.write_text(json.dumps(data), encoding="utf-8") with self.assertRaises(GATE.GateError): @@ -84,6 +84,18 @@ def test_mutations_fail_closed(self) -> None: with self.assertRaises(GATE.GateError): GATE.validate_matrix(path) + data = copy.deepcopy(GATE.load_matrix()) + data["filesystem_scope"]["supported"].append("windows_local_refs_by_analogy") + path.write_text(json.dumps(data), encoding="utf-8") + with self.assertRaises(GATE.GateError): + GATE.validate_matrix(path) + + data = copy.deepcopy(GATE.load_matrix()) + data["filesystem_scope"]["rejected"].remove("windows_refs_unproven") + path.write_text(json.dumps(data), encoding="utf-8") + with self.assertRaises(GATE.GateError): + GATE.validate_matrix(path) + data = copy.deepcopy(GATE.load_matrix()) covered = next(item for item in data["crash_phases"] if item["coverage"] == "covered") covered["evidence"][0]["symbol"] = "missing_symbol_for_gate_test" diff --git a/scripts/ci/workflow_policy.py b/scripts/ci/workflow_policy.py new file mode 100644 index 00000000..6e49b93d --- /dev/null +++ b/scripts/ci/workflow_policy.py @@ -0,0 +1,171 @@ +"""Fail-closed structural helpers for checked-in GitHub workflow policy tests.""" + +from __future__ import annotations + +import re + + +def _unquote_scalar(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def workflow_jobs(text: str) -> dict[str, str]: + """Split a workflow into exact top-level job ID to job-body mappings.""" + lines = text.splitlines() + try: + jobs_index = next(index for index, line in enumerate(lines) if line == "jobs:") + except StopIteration as exc: + raise AssertionError("workflow is missing a top-level jobs: mapping") from exc + jobs: dict[str, str] = {} + current: str | None = None + body: list[str] = [] + for line in lines[jobs_index + 1 :]: + match = re.fullmatch(r" ([A-Za-z0-9_-]+):", line) + if match: + if current is not None: + jobs[current] = "\n".join(body) + current = match.group(1) + body = [] + continue + if current is None: + continue + if not line.strip() or line.lstrip().startswith("#"): + body.append(line) + continue + if line and not line.startswith(" "): + break + body.append(line) + if current is not None: + jobs[current] = "\n".join(body) + assert jobs, "workflow jobs: mapping is empty" + return jobs + + +def job_scalar(job_body: str, field: str) -> str | None: + """Return one active job-level scalar declared at exactly indent four.""" + prefix = f" {field}:" + matches = [ + line[len(prefix) :].strip() for line in job_body.splitlines() if line.startswith(prefix) + ] + assert len(matches) <= 1, f"job has duplicate job-level {field}: fields" + if not matches: + return None + value = matches[0] + assert value and value not in {"|", "|-", ">", ">-"}, ( + f"job-level {field}: must be one explicit scalar" + ) + return _unquote_scalar(value) + + +def job_needs(job_body: str) -> set[str]: + """Return only active job-level needs entries at indents four and six.""" + lines = job_body.splitlines() + indices = [index for index, line in enumerate(lines) if line.startswith(" needs:")] + assert len(indices) <= 1, "job has duplicate job-level needs: fields" + if not indices: + return set() + index = indices[0] + value = lines[index][len(" needs:") :].strip() + if value.startswith("[") and value.endswith("]"): + return {_unquote_scalar(item.strip()) for item in value[1:-1].split(",") if item.strip()} + if value: + return {_unquote_scalar(value)} + needed: set[str] = set() + for follow in lines[index + 1 :]: + if not follow.strip(): + continue + if follow.startswith(" - "): + needed.add(_unquote_scalar(follow[len(" - ") :].strip())) + continue + if not follow.startswith(" "): + break + raise AssertionError("job-level needs: contains a nested or malformed entry") + return needed + + +def job_run_scalars(job_body: str) -> list[str]: + """Return active step run scalars, excluding comments and nested mappings.""" + lines = job_body.splitlines() + steps = [index for index, line in enumerate(lines) if line == " steps:"] + assert len(steps) <= 1, "job has duplicate job-level steps: fields" + if not steps: + return [] + scalars: list[str] = [] + index = steps[0] + 1 + while index < len(lines): + line = lines[index] + if line.strip() and line.startswith(" ") and not line.startswith(" "): + break + match = re.fullmatch(r"(?: - | )run:\s*(.*)", line) + if not match: + index += 1 + continue + value = match.group(1).strip() + if value in {"|", "|-", ">", ">-"}: + block: list[str] = [] + index += 1 + while index < len(lines): + follow = lines[index] + if follow.strip() and not follow.startswith(" "): + break + stripped = follow.strip() + if stripped and not stripped.startswith("#"): + block.append(stripped) + index += 1 + scalars.append("\n".join(block)) + continue + if value and not value.startswith("#"): + scalars.append(value) + index += 1 + return scalars + + +def normalize_run(value: str) -> str: + """Normalize folded/literal shell layout without accepting other YAML fields.""" + return " ".join(value.split()) + + +def run_scalar_fails_closed(value: str) -> bool: + """Reject shell forms that can turn a required command failure into success.""" + if "||" in value or re.search(r"(?:^|[;\n])\s*!\s*(?!=)", value): + return False + if "&&" in value: + return False + if re.search(r"(?])&(?![&>])", value): + return False + if re.search(r"(?:^|[;\n])\s*(?:true|:)(?:\s*(?:$|[;#]))", value): + return False + if re.search(r"(?:^|[;\n])\s*(?:if|while|until)\b", value): + return False + if re.search(r"(?:^|[;&|\n])\s*exit\s+0(?:\s*(?:$|[;#]))", value): + return False + return not re.search(r"(?:^|[;&|\n])\s*set\s+\+", value) + + +def _has_command_prefix(value: str, expected: str) -> bool: + return normalize_run(value).startswith(expected) + + +def job_required_run_scalars(job_body: str, command_prefix: str) -> list[str]: + """Return active fail-closed run scalars that execute a required command.""" + expected = normalize_run(command_prefix) + claims = [scalar for scalar in job_run_scalars(job_body) if expected in normalize_run(scalar)] + for scalar in claims: + if _has_command_prefix(scalar, expected): + assert run_scalar_fails_closed(scalar), ( + f"required command may suppress failure: {command_prefix}" + ) + return [scalar for scalar in claims if _has_command_prefix(scalar, expected)] + + +def job_runs_exact(job_body: str, command: str) -> bool: + expected = normalize_run(command) + matches = job_required_run_scalars(job_body, command) + return any(normalize_run(scalar) == expected for scalar in matches) + + +def job_run_contains(job_body: str, command_prefix: str) -> bool: + """Match an active command prefix, never a comment, env value, or echo.""" + return bool(job_required_run_scalars(job_body, command_prefix)) diff --git a/tests/contracts/durability-isolation-matrix.json b/tests/contracts/durability-isolation-matrix.json index a94b5f1b..19b7a0db 100644 --- a/tests/contracts/durability-isolation-matrix.json +++ b/tests/contracts/durability-isolation-matrix.json @@ -11,6 +11,9 @@ "docs/adr/0014-workspace-checkpoints.md", "docs/adr/0015-embedded-write-modes.md" ], + "amending_adrs": [ + "docs/adr/0020-ntfs-write-through-namespace-durability.md" + ], "m5_consumer_issues": [ 738, 742, @@ -31,10 +34,10 @@ "requires": [ "participant_file_flushes", "manifest_file_flush", - "generation_directory_flushes", + "generation_platform_native_namespace_barriers", "optimistic_promotion_when_applicable", "current_atomic_replace_or_create", - "project_root_directory_flush" + "project_root_platform_native_namespace_durability_barrier" ], "linearization_point": "current_atomic_replace_or_create", "not_authority": [ @@ -47,7 +50,7 @@ "filesystem_scope": { "supported": [ "posix_local_fcntl_flock_rename_fsync_dir_fsync", - "windows_local_ntfs_refs_lockfileex_flushfilebuffers_replacefilew" + "windows_local_ntfs_lockfileex_write_through_setfileinformationbyhandle" ], "rejected": [ "network", @@ -55,6 +58,7 @@ "removable", "cross_device", "symlink_mediated", + "windows_refs_unproven", "unknown" ], "error": "GF_UNSUPPORTED_FILESYSTEM", @@ -297,7 +301,7 @@ ] }, { - "id": "lost_root_directory_flush_power_loss", + "id": "lost_namespace_durability_barrier_power_loss", "failpoints": [ "project.after_current_replace" ], @@ -770,6 +774,16 @@ "kind": "rust", "path": "crates/graphforge-storage/src/embedding_publication.rs", "symbol": "publication_filesystem_and_error_boundaries_are_fail_closed" + }, + { + "kind": "rust", + "path": "crates/graphforge-storage/src/filesystem_admission.rs", + "symbol": "windows_classifier_accepts_only_fixed_writable_ntfs" + }, + { + "kind": "rust", + "path": "crates/graphforge-filesystem/src/lib.rs", + "symbol": "write_through_source_handle_performs_replacement_rename" } ] } diff --git a/tests/unit/test_set_release_version.py b/tests/unit/test_set_release_version.py index 84483524..f6b0ab39 100644 --- a/tests/unit/test_set_release_version.py +++ b/tests/unit/test_set_release_version.py @@ -5,7 +5,11 @@ from pathlib import Path import pytest -import tomllib + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - exercised on supported Python 3.10 + import tomli as tomllib SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "set_release_version.py" SPEC = importlib.util.spec_from_file_location("set_release_version", SCRIPT) @@ -40,11 +44,11 @@ def test_expected_mapping() -> None: def test_current_tree_is_aligned() -> None: lock_versions = set_release_version.cargo_lock_versions() - assert len(lock_versions) == 18 manifest_packages = { tomllib.loads(path.read_text(encoding="utf-8"))["package"]["name"] for path in set_release_version.crate_manifests() } + assert len(lock_versions) == len(manifest_packages) assert set(lock_versions) == manifest_packages assert set_release_version.check_aligned() == [] compatibility = json.loads(set_release_version.SKILLS_COMPATIBILITY.read_text(encoding="utf-8"))