diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c27c594..6f4deb9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,6 +15,8 @@ jobs: python-version: ${{ matrix.python-version }} - run: python -m pip install -r requirements.txt - run: python -m pip install Pillow==12.2.0 PyMuPDF==1.28.0 + - name: Provision the declared build backend that the generated-sdist test imports + run: python -m pip install "setuptools>=68" wheel - run: python tools/run_validation.py - run: python -m pip install --no-deps . - run: trace-gc --version @@ -70,7 +72,121 @@ jobs: python -m unittest discover -s tests -p "test_windows_fence_diagnostics.py" -v 2>&1 | tee "$RUNNER_TEMP/windows-fence-diagnostics-tests.log" grep -Eq '^Ran 18 tests in ' "$RUNNER_TEMP/windows-fence-diagnostics-tests.log" grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/windows-fence-diagnostics-tests.log" + - name: Run the 155 native-descriptor, installation-component, restoration, token, privilege-isolation, launcher-containment, privilege-restoration, child-evidence, standalone-script, runtime-main-section, child-stop-before-restoration, launcher-consumer fixture-retention, per-fixture installation-restoration and access-denied directory-creation probe cases (one hundred thirty-nine portable, sixteen native) + if: always() + shell: bash + run: | + set -o pipefail + python -m unittest discover -s tests -p "test_windows_native_acl.py" -v 2>&1 | tee "$RUNNER_TEMP/windows-native-acl-tests.log" + grep -Eq '^Ran 155 tests in ' "$RUNNER_TEMP/windows-native-acl-tests.log" + grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/windows-native-acl-tests.log" + - name: Positive gate, supported context required - the strict v1 producer cases in a child whose own primary token has every privilege removed + if: always() + shell: bash + run: | + set -o pipefail + python -m unittest tests.test_paper_pilot_phase_trustees_v1_v4.V1FenceTrusteeTests.test_the_strict_positive_producer_path_runs_in_a_restricted_primary_token_child -v 2>&1 | tee "$RUNNER_TEMP/windows-required-positive.log" + grep -Eq '^Ran 1 test in ' "$RUNNER_TEMP/windows-required-positive.log" + grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/windows-required-positive.log" + - name: Run the forty-five native v1/v4 fence, receipt-validator and phase-ownership trustee cases as the original runner token (context-aware, the branch each case took is printed) + if: always() + shell: bash + run: | + set -o pipefail + export JRAPHYTE_BRANCH_LOG="$RUNNER_TEMP/windows-branches.jsonl" + python -m unittest discover -s tests -p "test_paper_pilot_phase_trustees_v1_v4.py" -v 2>&1 | tee "$RUNNER_TEMP/windows-phase-trustees.log" + grep -Eq '^Ran 45 tests in ' "$RUNNER_TEMP/windows-phase-trustees.log" + grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/windows-phase-trustees.log" + echo "original-token branches (supported_positive = positive qualification; unsupported_refusal = negative evidence only):" + cat "$RUNNER_TEMP/windows-branches.jsonl" + - name: Run the existing three v1 and eight v4 native fence cases as the runner identity + if: always() + shell: bash + run: | + set -o pipefail + python -m unittest discover -s tests -p "test_paper_pilot_phase_fence.py" -v 2>&1 | tee "$RUNNER_TEMP/windows-phase-v1.log" + grep -Eq '^Ran 3 tests in ' "$RUNNER_TEMP/windows-phase-v1.log" + grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/windows-phase-v1.log" + python -m unittest discover -s tests -p "test_paper_pilot_phase_cutover_guarded_v4.py" -v 2>&1 | tee "$RUNNER_TEMP/windows-phase-v4.log" + grep -Eq '^Ran 8 tests in ' "$RUNNER_TEMP/windows-phase-v4.log" + grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/windows-phase-v4.log" + - name: Run the six converter worker parent-process cases (four portable, genuine Windows venv redirector chain and exit-259 liveness native; refs #94) + if: always() + shell: bash + run: | + set -o pipefail + python -m unittest discover -s tests -p "test_paper_converter_worker_parent.py" -v 2>&1 | tee "$RUNNER_TEMP/windows-converter-worker-parent.log" + grep -Eq '^Ran 6 tests in ' "$RUNNER_TEMP/windows-converter-worker-parent.log" + grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/windows-converter-worker-parent.log" + - name: Run the eighteen sdist/wheel discovery cases (both build-backend spellings, missing and ambiguous artifacts) + if: always() + shell: bash + run: | + set -o pipefail + python -m unittest discover -s tests -p "test_sdist_discovery.py" -v 2>&1 | tee "$RUNNER_TEMP/sdist-discovery-tests.log" + grep -Eq '^Ran 18 tests in ' "$RUNNER_TEMP/sdist-discovery-tests.log" + grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/sdist-discovery-tests.log" + - name: Build the sdist and the wheel from it, run the shipped affected tests from the extracted sdist, then check the installed wheel outside the checkout + if: always() + shell: bash + run: | + set -euo pipefail + python -m pip install build "setuptools>=68" wheel + python -m build --outdir "$RUNNER_TEMP/dist" . + SDIST="$(python tools/sdist_discovery.py extract --project trace-gc --dist "$RUNNER_TEMP/dist" --into "$RUNNER_TEMP/sdist")" + WHEEL="$(python tools/sdist_discovery.py wheel --project trace-gc --dist "$RUNNER_TEMP/dist")" + ( + cd "$SDIST" + python -m unittest discover -s tests -p "test_sdist_phase_distribution.py" -v 2>&1 | tee "$RUNNER_TEMP/sdist-distribution.log" + grep -Eq '^Ran 3 tests in ' "$RUNNER_TEMP/sdist-distribution.log" + grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/sdist-distribution.log" + python -m unittest discover -s tests -p "test_sdist_discovery.py" -v 2>&1 | tee "$RUNNER_TEMP/sdist-discovery.log" + grep -Eq '^Ran 18 tests in ' "$RUNNER_TEMP/sdist-discovery.log" + grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/sdist-discovery.log" + python -m unittest discover -s tests -p "test_windows_native_acl.py" -v 2>&1 | tee "$RUNNER_TEMP/sdist-native-acl.log" + grep -Eq '^Ran 155 tests in ' "$RUNNER_TEMP/sdist-native-acl.log" + grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/sdist-native-acl.log" + python -m unittest tests.test_paper_pilot_phase_trustees_v1_v4.V1FenceTrusteeTests.test_the_strict_positive_producer_path_runs_in_a_restricted_primary_token_child -v 2>&1 | tee "$RUNNER_TEMP/sdist-required-positive.log" + grep -Eq '^Ran 1 test in ' "$RUNNER_TEMP/sdist-required-positive.log" + grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/sdist-required-positive.log" + export JRAPHYTE_BRANCH_LOG="$RUNNER_TEMP/sdist-branches.jsonl" + python -m unittest discover -s tests -p "test_paper_pilot_phase_trustees_v1_v4.py" -v 2>&1 | tee "$RUNNER_TEMP/sdist-phase-trustees.log" + grep -Eq '^Ran 45 tests in ' "$RUNNER_TEMP/sdist-phase-trustees.log" + grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/sdist-phase-trustees.log" + cat "$RUNNER_TEMP/sdist-branches.jsonl" + unset JRAPHYTE_BRANCH_LOG + python -m unittest discover -s tests -p "test_paper_pilot_phase_fence.py" -v 2>&1 | tee "$RUNNER_TEMP/sdist-phase-v1.log" + grep -Eq '^Ran 3 tests in ' "$RUNNER_TEMP/sdist-phase-v1.log" + grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/sdist-phase-v1.log" + python -m unittest discover -s tests -p "test_paper_pilot_phase_cutover_guarded_v4.py" -v 2>&1 | tee "$RUNNER_TEMP/sdist-phase-v4.log" + grep -Eq '^Ran 8 tests in ' "$RUNNER_TEMP/sdist-phase-v4.log" + grep -Eq '^OK[[:space:]]*$' "$RUNNER_TEMP/sdist-phase-v4.log" + ) + python -m venv "$RUNNER_TEMP/installed-venv" + INSTALLED="$RUNNER_TEMP/installed-venv/Scripts/python.exe" + "$INSTALLED" -m pip install -r requirements.txt + "$INSTALLED" -m pip install --no-deps "$WHEEL" + DECLARED=() + for NAME in phase_authority.py budget.py canonical.py trust.py; do + DECLARED+=(--declared-source "$NAME=$(python -c "import hashlib,sys;print(hashlib.sha256(open(sys.argv[1]+'/trace_gc/'+sys.argv[2],'rb').read()).hexdigest())" "$SDIST" "$NAME")") + done + mkdir "$RUNNER_TEMP/outside-checkout" + cd "$RUNNER_TEMP/outside-checkout" + "$INSTALLED" -I "$SDIST/tools/installed_phase_authority_check.py" --require-windows "${DECLARED[@]}" | tee "$RUNNER_TEMP/installed-phase-check.json" + python -c "import hashlib,json,os,sys;d=json.load(open(os.environ['RUNNER_TEMP']+'/installed-phase-check.json'));assert d['native_windows']=='RUN' and len(d['portable_checks'])==13 and len(d['windows_checks'])==35 and d['fixture_utility_sha256']==hashlib.sha256(open(sys.argv[1]+'/tools/windows_native_acl.py','rb').read()).hexdigest() and d['fixture_acl_sha256']==hashlib.sha256(open(sys.argv[1]+'/tools/windows_fixture_acl.py','rb').read()).hexdigest() and all(d['portable_checks'].values()) and all(d['windows_checks'].values()),d" "$SDIST" - name: Sanitized Windows trustee serialization diagnostics if: always() continue-on-error: true run: python tools/windows_fence_diagnostics.py + - name: Sanitized native directory-creation denial probe, candidate-shape matrix, privilege isolation and installation observation + if: always() + continue-on-error: true + shell: bash + run: | + set -o pipefail + python tools/windows_native_acl.py | tee "$RUNNER_TEMP/native-probe.json" + - name: Assert the standalone diagnostics script ran past its bootstrap and checkout imports on the runner + if: always() + shell: bash + run: | + python -c "import json,os;d=json.load(open(os.environ['RUNNER_TEMP']+'/native-probe.json'));assert d['os_name']=='nt' and d['completed_stages'][:4]==['bootstrap','checkout_imports','native_api','identity'] and 'error' not in d,d" diff --git a/MANIFEST.in b/MANIFEST.in index 82031d9..05e3113 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,18 @@ +# The phase-fence and Windows tests, and the helpers they import or load by path, are listed +# explicitly so the sdist does not depend on the build backend's default tests/test*.py rule. +# The wheel installs only trace_gc; src/ and tools/ are not installed packages. include src/paper_pilot_phase_cutover.py include src/paper_pilot_phase_cutover_guarded_v2.py +include src/paper_pilot_phase_cutover_guarded_v4.py +include src/paper_pilot_phase_export.py +include tools/installed_phase_authority_check.py +include tools/sdist_discovery.py +include tools/windows_native_acl.py +include tools/windows_fence_diagnostics.py +include tools/windows_fixture_acl.py +include tests/phase_trustee_harness.py +include tests/test_paper_pilot_phase_*.py +include tests/test_windows_*.py +include tests/test_sdist_phase_distribution.py +include tests/test_sdist_discovery.py +include .github/workflows/test.yml diff --git a/MANIFEST.json b/MANIFEST.json index 359630a..31fa256 100644 --- a/MANIFEST.json +++ b/MANIFEST.json @@ -3,12 +3,12 @@ "hash_algorithm": "SHA-256", "manifest_self_excluded": true, "source_only": true, - "file_count": 709, + "file_count": 718, "files": [ { "path": ".github/workflows/test.yml", - "bytes": 3589, - "sha256": "9c147d3ff67e4bc298c4376bcef076390c1a86d25ee9333ddf1d01ff861f4b23" + "bytes": 12883, + "sha256": "1b148dbe243b919564f06d96d66c4a2698e45a9b9b7772f4331abdc33824a2fd" }, { "path": ".gitignore", @@ -32,8 +32,8 @@ }, { "path": "MANIFEST.in", - "bytes": 93, - "sha256": "b4b637c3d710c1082dcdb508c73914885376c79acfef9288b070cdb0e5ddfeaf" + "bytes": 883, + "sha256": "0b3f3ec6266018a68361d3e811c8532509efa70cef8aa5f9e320adf2928b9ba0" }, { "path": "NEXT_AGENT_PROMPT.md", @@ -737,8 +737,8 @@ }, { "path": "docs/paper_pilot_phase_transition.md", - "bytes": 10022, - "sha256": "c067ae79613ece775ddea7664932a8b9bf93c75d89a7a5ab68e82fbfe534522c" + "bytes": 24745, + "sha256": "ad69ebbe19ad4d7a6f87bc7e3b3818ee091f6a606c80d6ae496f417a81414ad1" }, { "path": "docs/paper_pilot_workflow.md", @@ -2292,8 +2292,8 @@ }, { "path": "src/paper_pilot_phase_cutover.py", - "bytes": 5969, - "sha256": "e12545acd4e62d8e80179e356e1b2140424a048188e4b12a0285f34b9143cfd1" + "bytes": 9460, + "sha256": "1bca8c2c85e3b7cf871da6af26076274f1dde925d424e28ac026a7d6b2f5f458" }, { "path": "src/paper_pilot_phase_cutover_guarded_v2.py", @@ -2302,8 +2302,8 @@ }, { "path": "src/paper_pilot_phase_cutover_guarded_v4.py", - "bytes": 29079, - "sha256": "a9547fbaa0e76fbbd649366475262041f61d738f2dba1f0c8702a4a4c056abf1" + "bytes": 29146, + "sha256": "9b0e7b05c95d67015314a8b53a0f3182c66e0c0f5717c59d888440b077e1cabe" }, { "path": "src/paper_pilot_phase_export.py", @@ -2485,6 +2485,11 @@ "bytes": 14733, "sha256": "d87bcd1bac62d772a134ef4057d9c311b1cf7b30f599975ac01bbef4ef753451" }, + { + "path": "tests/phase_trustee_harness.py", + "bytes": 28549, + "sha256": "77a5c4ab973e8a6d3e99128ad92f06311fc9102c43ec04844c694a44b6f373d0" + }, { "path": "tests/test_abstract_validation.py", "bytes": 2304, @@ -2652,13 +2657,13 @@ }, { "path": "tests/test_paper_pilot_phase_cutover_guarded_v4.py", - "bytes": 14007, - "sha256": "8ce50d09266407ccff5f2ace0b1cde7d4e4b00e81939426c58b459456f801237" + "bytes": 14399, + "sha256": "7a5cbd61d2e4472f8790dbb40888b60cd616c0b5a972c23025465569710f09db" }, { "path": "tests/test_paper_pilot_phase_fence.py", - "bytes": 5050, - "sha256": "ce53724a2a31c5fe385be5f3010dae14da5babfa168e02ad936e92c54743f65f" + "bytes": 6030, + "sha256": "9b48c17fce8ea4c2e915c238cf3fcc7eb4ae8f282992519a71cfb2dadc7fe5f7" }, { "path": "tests/test_paper_pilot_phase_import_lock.py", @@ -2670,6 +2675,16 @@ "bytes": 12908, "sha256": "520c40c81f511a5134dec2acdc2eb81417848160e6def470d73b6b15ee06c872" }, + { + "path": "tests/test_paper_pilot_phase_trustees_model.py", + "bytes": 48198, + "sha256": "061fa1f60e1b7335ba878fe3616083688349758a93fe79dff1269e2de02c5373" + }, + { + "path": "tests/test_paper_pilot_phase_trustees_v1_v4.py", + "bytes": 43328, + "sha256": "f83e09133ca97d3e57e40d17621c18939835f9ed0dfae04a0b003071af44eee1" + }, { "path": "tests/test_paper_qualification_v1.py", "bytes": 80357, @@ -2935,6 +2950,16 @@ "bytes": 30226, "sha256": "85f176c3e186e20b14d623f31d57a685ab2312fab3439904dcbb74437cb0bde5" }, + { + "path": "tests/test_sdist_discovery.py", + "bytes": 12012, + "sha256": "8f1a1b5f9517c6336fb89a1d0760446135fdef266611e6b34348fe10333cf178" + }, + { + "path": "tests/test_sdist_phase_distribution.py", + "bytes": 6981, + "sha256": "7c8d4a1fbe8441c342b914d218d24e5b20b53ae8d4cf65517b524990d53aaded" + }, { "path": "tests/test_selected_reviewed_replay.py", "bytes": 12418, @@ -2965,6 +2990,11 @@ "bytes": 11977, "sha256": "438d68653cd88d60f4376f1bda7cb33e443d32059e0b86ef0f32b788ba5b4563" }, + { + "path": "tests/test_windows_native_acl.py", + "bytes": 131065, + "sha256": "a466bf28608b379ca8e4745e3f953e7354d7ed5f8a0326ef5821517b96acf5c4" + }, { "path": "tools/build_schemas.py", "bytes": 12978, @@ -2990,6 +3020,11 @@ "bytes": 3921, "sha256": "dc393fea23327063506b772157fa7ddbaecc18fee058aab9f2489959767017fc" }, + { + "path": "tools/installed_phase_authority_check.py", + "bytes": 24082, + "sha256": "de288c30423b0decc6777f4246d911943700c592988bad1b87b8a7d7fb9c0cdf" + }, { "path": "tools/legacy_hardening.py", "bytes": 8616, @@ -3030,6 +3065,11 @@ "bytes": 7493, "sha256": "aea3f70fc525b1308d2254689a43c50a7ae8708bd0599a6d9c5ed880d0775df3" }, + { + "path": "tools/sdist_discovery.py", + "bytes": 4681, + "sha256": "80f6d9f540de5a7b2fd0612044fb57af65d58f9e28f2a59f993cb327816b1835" + }, { "path": "tools/selected_reviewed_replay.py", "bytes": 18994, @@ -3055,6 +3095,11 @@ "bytes": 5451, "sha256": "de645b1b260d8e1e3cf5b37a8e5ebc10f3e925c72179e3cc1f2088ce64ce9e47" }, + { + "path": "tools/windows_native_acl.py", + "bytes": 71797, + "sha256": "36cff50790ac00d76e1fdf0425da8a25062f193cd0a28103a638135d3124c58b" + }, { "path": "trace_gc/__init__.py", "bytes": 103, @@ -3387,8 +3432,8 @@ }, { "path": "trace_gc/phase_authority.py", - "bytes": 10610, - "sha256": "b2ffc2e12be3c927f167a456acb1f95a7d2c615756131b2f889ddc97610dfee9" + "bytes": 14745, + "sha256": "0a97d6668a3860570342d3122ffeadb77e2eabf186c3361aab52db5ea614ceac" }, { "path": "trace_gc/phase_transition.py", diff --git a/docs/paper_pilot_phase_transition.md b/docs/paper_pilot_phase_transition.md index b26bc28..b7c576b 100644 --- a/docs/paper_pilot_phase_transition.md +++ b/docs/paper_pilot_phase_transition.md @@ -17,6 +17,10 @@ The Windows namespace fence limits cooperative, unchanged old launchers. It is n The guarded admission handoff requires an independently established sole owner before its first ACL change. It holds the two existing stage byte locks and the old controller byte lock, pins the original five file bytes and Windows DACLs, then denies new write and append opens on those files. It rejects any original deny rule for the owner SID, because removing its added rule after the rename would otherwise remove unrelated owner denies. It checks that the old parent DACL gains only its deny-create rule, closes the controller handle, and renames on the same volume while the stage locks remain held. The fixed old parent stays fenced; the archived files regain their original effective access rules. A durable admission receipt permits exact-input recovery after an interrupted ACL update or rename. A partially installed barrier is a hold until the same reviewed request reconciles. Neither an ACL receipt nor a file lock alone proves that all old launch authority was excluded before the first ACL update; the embedding application must provide that separate evidence. The helper refuses non-Windows execution. +Windows may serialize the owner SID in a DACL string as an SDDL alias: the Users group `S-1-5-32-545` is `BU`, and a machine's built-in Administrator (RID 500) is `LA`, which is the identity of a hosted Windows runner. The v1 fence, the guarded v4 fence, and `PhaseAuthority.verify_fence` therefore recognize their exact deny-create and per-file deny ACEs through `trace_gc.phase_authority.canonical_dacl_trustee` and its ACE helpers instead of a literal numeric-SID substring. The helper asks the local Windows security-descriptor conversion how it would spell the receipt's numeric SID, so an alias is never mapped back to a caller-selected numeric SID, and distinct account or domain SIDs that share RID 500 stay distinct. Only the trustee field is normalized: ACE type, flags, rights, object fields, order and the extra-ACE check still compare exactly, `directory_dacl_sddl` serialization is unchanged, and receipts keep the numeric `deny_sid` and the digest of the raw, un-normalized SDDL. Alias spelling is machine-specific, so a receipt is validated against the DACL text and serialization of the machine that validates it; the raw-SDDL digest pin still ties a receipt to the DACL spelling it recorded. Off Windows the alias relation cannot be established: a numeric SID fails closed with `PHASE_PLATFORM`, and non-numeric trustee text passes through without mapping. Because this changes the packaged `trace_gc/phase_authority.py`, the implementation identity (`trace_gc.paper_pilot.implementation_identity`) and every binding derived from it change; a private plan or phase binding pinned to the previous identity must be newly bound and reviewed, and an expired private window is not reopened by this public change. + +The v1 fence publishes its receipt only if the parent deny really refuses the process that is running it. After the pinned inventory check and after the exact `(D;;LC;;;sid)` deny is present (installed by the call if absent), the helper creates one uniquely named `.fence-probe-` directory directly beneath the actual fenced parent `old.parent`, with the current process's own token, and removes it again. Only a Windows error 5 (`ERROR_ACCESS_DENIED`) counts as a refusal; a creation that succeeds, any other error, or a probe directory that cannot be removed holds with `PHASE_FENCE`. The probe samples the fenced parent itself, not the receipt's control directory, whose filesystem, DACL, control flags and owner may differ. A hold leaves the old state, the archive path and the receipt exactly as they were; a deny this call added is removed with `icacls /remove:d` and the parent DACL is read back equal to its pre-call text, otherwise the hold says the added deny could not be removed and verified (re-running the same call is safe, and an interrupted call leaves the same recoverable state as an interrupted rename: deny present, old state in place). A replay never removes a deny it did not add. An old parent that already carries a different deny for the fence SID holds before any ACL change. The temporary deny on `old.parent` is a reversible effect, not zero effect. Hosted runs showed the full original process token creating a directory beneath such a deny while a privilege-stripped copy was refused; the reason is not established here and the gate names no privilege. The guarded v2 helper is unchanged, and v4 keeps its existing parent-deny property without this gate. + `src/paper_pilot_phase_cutover_guarded_v4.py` adds an optional application observer to the continuous-lock transaction. After the source-stage, phase-stage, and controller locks and pinned old file/ACL checks, but before the quiescence receipt or first ACL update, the helper calls `observer(old_root=..., archived_root=..., source_handle=..., phase_handle=..., controller_handle=..., writable_database_names=...)`. The sorted database-name tuple identifies the four stores still lacking the durable per-file deny; a replay observer can retain read handles for just those stores. The observer returns a context manager holding any approved read leases. Its lease remains open through readback of the five per-file write denies and the old-parent deny, then closes before the same-volume rename while the two stage locks stay held. Rejection creates no quiescence or barrier receipt and changes no ACL. A barrier-only or partial-deny replay requires a fresh observer under the stage locks; `controller_handle` is `None` if the controller file already has the durable deny. The application must independently bind the observer code, current process census, and signed stage approvals. The generic helper does not determine sole ownership from a caller-supplied receipt. ## Recovery and evidence @@ -29,4 +33,10 @@ The guarded admission handoff requires an independently established sole owner b - A signed release must pin the final reviewed old and new implementation identities, capsule, source-open approval, attempt manifest, fence, target layout, and activation bytes. A passing authored fixture is mechanical evidence only; it is not approval to transition a live run or to execute a model. - The new runtime changes the code identity. Recheck any code-closure source gate and preregistered empirical controls affected by that change before the first new phase action. Do not reuse an old receipt as approval for new code. -Use isolated task or temporary directories for authored fixtures. `tests/test_paper_pilot_phase_backup.py` exercises pending backup reconciliation and schema-object hashes without source PDFs or providers; `tests/test_paper_pilot_phase_fence.py` exercises an owned disposable Windows namespace fence. `tests/test_paper_pilot_phase_cutover_guarded_v2.py` exercises the guarded handoff, crash replay, owner deny preservation, parent ACL drift rejection, and a new budget constructor blocked during the close-to-rename gap. `tests/test_paper_pilot_phase_cutover_guarded_v4.py` covers the held observer interval, rejection before effects, and barrier-only/partial-deny replay. The real old-runtime export and new-runtime import must be reviewed against pinned application inputs before execution. +Use isolated task or temporary directories for authored fixtures. `tests/test_paper_pilot_phase_backup.py` exercises pending backup reconciliation and schema-object hashes without source PDFs or providers; `tests/test_paper_pilot_phase_fence.py` exercises an owned disposable Windows namespace fence. `tests/test_paper_pilot_phase_cutover_guarded_v2.py` exercises the guarded handoff, crash replay, owner deny preservation, parent ACL drift rejection, and a new budget constructor blocked during the close-to-rename gap. `tests/test_paper_pilot_phase_cutover_guarded_v4.py` covers the held observer interval, rejection before effects, and barrier-only/partial-deny replay. `tests/test_paper_pilot_phase_trustees_v1_v4.py` runs the v1 and v4 fences, the real `PhaseAuthority` constructor over a signed activation pointer (current, fenced-but-inactive, tampered and lock-contention cases through `write()`, `staging()` and `RunBudget`, with the owned database unchanged after every rejection) and the packaged receipt validator against real Windows-spelled DACLs (the Users alias `BU`, the machine RID-500 account and the runner's own identity). It covers replay, crash recovery, existing-deny rejection, live denied opens, and rejection of a distinct RID-500 SID, other trustees, masks, flags, ACE types and changed digests; it needs native Windows. The one fabricated foreign-RID-500 deny (a full SID no account database knows, so `icacls` cannot install it) is installed through `tools/windows_native_acl.py`, which parses SDDL with the numeric SID and writes the descriptor with `SetNamedSecurityInfoW`; the test then asserts the exact stored ACE list, count, type, flags, rights, trustee, owner and digest and removes the ACE with verification. The live recreation-denial assertions (`mkdir` on the closed old path) call `require_recreation_denied`, which attempts creation with a privilege-stripped token and the full original token by `os.mkdir` and `CreateDirectoryW`, runs an `AccessCheck`, lists the enabled privileges and labels the result. Only `DENIED` passes: creation by either route with the full original token is a failure whatever the privileges, `AccessCheck` or the stripped token report, and a refusal counts only when every route and token fails with `ERROR_ACCESS_DENIED` (5); a missing parent, an existing target, an invalid path, a resource error or an outcome with no Windows error is `REFUSED_WITHOUT_ACCESS_DENIED`, which also fails, and the probe must verifiably remove anything it created. The labels `FULL_TOKEN_CREATES_WHILE_BYPASS_PRIVILEGE_ENABLED` (an enabled `SeBackupPrivilege`/`SeRestorePrivilege` coincides with a creation the DACL check would refuse), `FULL_TOKEN_CREATES_UNEXPLAINED`, `DACL_GRANTS_ADD_SUBDIRECTORY` and `KERNEL_CREATE_IGNORES_DACL` are observations that fail with the sanitized report; none asserts a proven cause and every report carries `cause_established: false`. The cause of hosted runs that created the closed path despite a first `(D;;LC;;;sid)` parent ACE is not established by Linux or model evidence; the hosted `tools/windows_native_acl.py` diagnostics step and the probe report are what can establish it. Removing a descriptor-installed deny, and verifying any restoration, compares trustees by full numeric SID after the operating system's own spelling (so a deny Windows reads back as `LA` or `BU` is found, while a distinct domain RID-500 SID is never collapsed into it), keeps every other ACE in order, and verifies the DACL control and protection flags and the owner; a drift is reported as a failed cleanup, never hidden. `tests/test_windows_native_acl.py` covers these helpers (portable cases against a labelled fake of the Win32 layer, native cases on Windows). `tests/test_paper_pilot_phase_trustees_model.py` runs the same fence and phase code on every platform against a labelled model of the Windows DACL, `icacls` and SID-spelling seams, plus the exact ACE count, order, flag and rights contract (an explicit copy of an inherited ACE is never tolerated) and the off-Windows contract; it is not native Windows evidence. `tools/installed_phase_authority_check.py` runs from the installed wheel in a directory outside the checkout against declared source digests: portable provenance and off-Windows checks everywhere, and the native real-constructor and trustee checks on Windows only (35 checks, including the foreign-domain RID-500 deny installed and removed by numeric descriptor operations through the source-only `tools/windows_native_acl.py`, loaded by path without touching `sys.path` and with every `trace_gc` module still verified as the installed package), reporting `native_windows` as `RUN` or `NOT_RUN`. The real old-runtime export and new-runtime import must be reviewed against pinned application inputs before execution. + +The native helper `tools/windows_native_acl.py` also runs as a standalone script (`python tools/windows_native_acl.py`, optionally `--stages-only`); it inserts the checkout root on `sys.path` only for that script entry, never when it is loaded by path, and `SourceScriptTests` run it in a fresh interpreter from another directory. Its report names the completed stages and carries no path, account or machine SID. `installed_exactly` is the conjunction of per-component facts (DACL control and protection flags, the ordered ACEs with unexpected and missing ones, explicit and inherited tallies, and the owner), so a failed installation prints which component differed; the diagnostics install on an owned fixture stabilized before any pin. The restricted-primary child launcher runs the child in an owned kill-on-close job object: it distinguishes `WAIT_TIMEOUT` from `WAIT_FAILED`, terminates the job (falling back to the process) on timeout or failure, waits again, terminates a child whose assignment to the job failed directly with `TerminateProcess` (ending an empty job stops nothing outside it) and accepts that stop only when the final wait sees it signaled, never treats `STILL_ACTIVE` as an exit code, counts the job's remaining processes (a surviving descendant is terminated and the run is not `completed`), and keeps its handles when cleanup cannot be shown. A launch is accepted only when `launch_defects` is empty, which requires `completed` and `cleanup_verified`, no retained handles, a verified privilege restoration and no recorded error; the exit code alone is never acceptance. The one disabled privilege the launcher may enable (`SeIncreaseQuotaPrivilege`, only after Windows refuses with `ERROR_PRIVILEGE_NOT_HELD`) is restored to its captured attributes and the whole process-token inventory is read back, a mismatch being a cleanup failure. The launch report's `child_stopped` is true only when nothing was started or the process was seen signaled and the job is empty (a handle that will not close does not change it); fixture restoration and removal depend on it, not on handle or privilege cleanup: when it is not established, or the launcher raises, the standalone section leaves the child's fixture and its DACL as they are, reports `fixture_retained`, keeps the temporary root in place (the run is not clean) and relies on the kill-on-close job handle it still owns for bounded teardown. The test-suite launcher consumers follow the same rule through `ChildStopHold`: the native helper cases and the strict-producer child case in `test_paper_pilot_phase_trustees_v1_v4.py` launch through it, and a launch that is not shown stopped, or a launcher that raises, makes their teardown restore no ACL, remove nothing, detach the temporary directory's own finalizer and fail loudly, while a stopped child or a launch that failed before creating any process still restores and removes the fixture. This is a static caller contract; no orphaned Windows child or deletion race was observed. The standalone `restricted_primary_child` section separates `restoration_verified` (the fixture and this process's own probes) from `evidence_complete` (the child launched, finished in time, left nothing behind and wrote a complete report object with a known verdict and every required field); a missing, malformed, `null`, non-object, error or incomplete report is listed in `not_run`, whatever the parse returned. The standalone `installation_observation` section keeps an installation that reads back inexactly apart from a restoration that cannot be shown: only the stabilized installation gates `stabilized_install_and_restoration_exact` (the two unprotected shapes are diagnostics), while `restoration_verified` is true only when every fixture whose original descriptor was captured shows a verified restoration, an unverified or raised restoration is listed in `restoration_unverified`, and `main()` is clean only with both. The standalone `main()` is exercised at runtime with the section producers replaced: its exit code and the report's `cleanup_verified` follow that section (and each earlier section), and a producer error is recorded and fails the run. Native positive coverage and the original-token refusal are two reported gates: `JRAPHYTE_SUPPORTED_CONTEXT=required` makes the producer, receipt, constructor and current-write cases run the supported branch and fail where the context is unsupported, the strict wrapper test runs that branch inside a restricted-primary child whose own process token is the one under test, and `JRAPHYTE_BRANCH_LOG` records which branch each case took. A green run that took only the refusal branch is negative evidence and is not positive qualification. None of these diagnostics establishes a cause. + +## What each distribution ships + +The source ZIP from `tools/package_project.py` carries the public source files that the packager's current suffix and exclusion policy selects (it still excludes, for example, the tracked `docs/workflow/render.mjs`, private inputs, models and outputs), under a root directory prefix, plus `MANIFEST.json`; it is not a copy of the whole Git tree. The sdist carries `trace_gc`, `pyproject.toml`, `MANIFEST.in`, the licence and README, and, through explicit `MANIFEST.in` rules rather than a build-backend default, the phase and Windows test modules (`tests/test_paper_pilot_phase_*.py`, `tests/test_windows_*.py`, `tests/test_sdist_phase_distribution.py`) with the helpers they import or load by path (`tests/phase_trustee_harness.py`, the `src/` modules `paper_pilot_phase_cutover`, `_cutover_guarded_v2`, `_cutover_guarded_v4` and `paper_pilot_phase_export`, and `tools/installed_phase_authority_check.py`, `tools/windows_fence_diagnostics.py`, `tools/windows_fixture_acl.py`, `tools/windows_native_acl.py`, `tools/sdist_discovery.py`, `tests/test_sdist_discovery.py` and `.github/workflows/test.yml`). The declared build backend (`setuptools>=68`; 68.0.0 and 84.0.0 were exercised) already adds `tests/test*.py` by default, so the three test rules pin that behaviour rather than depend on it, while the helper rules are what the old sdist lacked. `tests/test_sdist_phase_distribution.py` builds a real sdist with the installed backend, requires every file the affected tests need to be a member, and requires every affected test module to import from the extraction; a missing module fails and nothing is skipped. The workflow does not assume a spelling of the archive: setuptools 68 writes `trace-gc-0.4.0.tar.gz` with root `trace-gc-0.4.0` and setuptools 84 writes `trace_gc-0.4.0.tar.gz`. `tools/sdist_discovery.py` requires exactly one archive (and one wheel), checks the project name, extracts with member-name checks and returns the single top-level directory, and fails on zero or several candidates; `tests/test_sdist_discovery.py` covers both spellings, the negatives and the real workflow step. The wheel installs only `trace_gc`, so it has no `src/`, `tools/` or `tests/`; the installed-wheel check runs the tool from the extracted sdist against the installed package. Linux and model runs of these distributions are not native Windows evidence: the native v1, v4, constructor, staging and installed-wheel checks run in the hosted Windows job and are NOT_RUN on Linux. diff --git a/src/paper_pilot_phase_cutover.py b/src/paper_pilot_phase_cutover.py index 947e533..56870a0 100644 --- a/src/paper_pilot_phase_cutover.py +++ b/src/paper_pilot_phase_cutover.py @@ -2,7 +2,10 @@ The caller must stop and close every historical owner before entry. This helper never terminates a process, runs a provider, edits a graph, or signs a -receipt. A reviewed release must pin the exact closed file inventory. +receipt. A reviewed release must pin the exact closed file inventory. The fence is only +published when the current process token is actually refused by the parent deny +(see ``_require_denial_enforced``); otherwise it holds before any effect on the old +state. """ from __future__ import annotations @@ -10,13 +13,16 @@ import os from pathlib import Path import subprocess +from subprocess import SubprocessError from trace_gc.canonical import bytes_digest, dumps, loads from trace_gc.errors import require -from trace_gc.phase_authority import _plain_absolute_path, directory_dacl_sddl +from trace_gc.phase_authority import (_plain_absolute_path, dacl_has_ace, dacl_has_deny_trustee, + directory_dacl_sddl) NAMES = {"checkpoint.sqlite3", "graph.sqlite3", "budget.sqlite3", "application-journal.sqlite3", "controller.lock"} +DENY_PARENT_ACE = "(D;;LC;;;{sid})" def _current_sid() -> str: @@ -28,6 +34,60 @@ def _current_sid() -> str: return result.stdout.strip() +ERROR_ACCESS_DENIED = 5 +TOKEN_NOT_REFUSED = "current process token can create beneath the parent deny" +DENIAL_UNVERIFIED = "denial of recreation could not be verified" + + +def _denial_problem(parent: Path) -> str | None: + """Create one uniquely named child directory directly beneath ``parent`` with this process's + own token and remove it again. Returns ``None`` only for an access-denied refusal. + + This is the property the fence relies on, sampled at the fenced parent itself (not a control + directory, whose filesystem, DACL, control flags and owner may differ). A deny-create ACE is + only a fence if the token that will later try to recreate the old path is refused by it. Hosted + runs showed the full original process token creating a directory under such an ACE while a + privilege-stripped copy was refused; the reason is not established, so no privilege is named + or assumed. + """ + probe = parent / (".fence-probe-" + os.urandom(8).hex()) + try: + os.mkdir(probe) + except OSError as error: + return None if getattr(error, "winerror", None) == ERROR_ACCESS_DENIED else DENIAL_UNVERIFIED + try: + os.rmdir(probe) + except OSError: + return TOKEN_NOT_REFUSED + "; the probe directory was not removed" + return TOKEN_NOT_REFUSED + + +def _require_denial_enforced(parent: Path, deny_sid: str, restore_sddl: str | None) -> None: + """Hold unless the deny on ``parent`` really refuses this process creating a child directory. + + ``restore_sddl`` is the parent DACL from before this call added the deny (``None`` when the + deny was already there, which a replay must leave in place). On a hold the deny this call added + is removed again and the DACL read back equal to ``restore_sddl``; if that cannot be shown the + hold says so and the parent is left for the owner to repair (rerunning the same call is safe). + """ + problem = _denial_problem(parent) + if problem is not None and restore_sddl is not None: + try: + removed = subprocess.run(["icacls.exe", str(parent), "/remove:d", "*" + deny_sid], + capture_output=True, text=True, timeout=30) + if removed.returncode != 0: + why = "icacls exit %d" % removed.returncode + elif directory_dacl_sddl(parent) != restore_sddl: + why = "DACL differs from its pre-state" + else: + why = None + except (OSError, SubprocessError): + why = "removal could not run" + if why is not None: + problem += "; the added deny could not be removed and verified (" + why + ")" + require(problem is None, "PHASE_FENCE", problem or "") + + def _pinned_files(root: Path, expected: dict[str, str]) -> None: _plain_absolute_path(str(root)) for name in NAMES: @@ -89,14 +149,21 @@ def fence_closed_old_phase(*, old_root: str | Path, archived_root: str | Path, else: _pinned_files(archived, expected_file_sha256) sddl = directory_dacl_sddl(old.parent) - deny = f"(D;;LC;;;{deny_sid})" - if deny not in sddl: + # Windows may spell this SID as a DACL alias (for example LA); the exact + # deny-create ACE is recognized under either spelling. + restore_sddl = None + if not dacl_has_ace(sddl, DENY_PARENT_ACE, deny_sid): require(old.exists(), "PHASE_FENCE", "archive moved without durable parent deny") + require(not dacl_has_deny_trustee(sddl, deny_sid), "PHASE_FENCE", + "old parent already carries another deny for the fence SID") + restore_sddl = sddl command = subprocess.run(["icacls.exe", str(old.parent), "/deny", "*" + deny_sid + ":(AD)"], capture_output=True, text=True, timeout=30) require(command.returncode == 0, "PHASE_FENCE", "could not deny old state recreation") sddl = directory_dacl_sddl(old.parent) - require(deny in sddl, "PHASE_FENCE", "old parent lacks exact deny-create ACE") + require(dacl_has_ace(sddl, DENY_PARENT_ACE, deny_sid), "PHASE_FENCE", + "old parent lacks exact deny-create ACE") + _require_denial_enforced(old.parent, deny_sid, restore_sddl) if old.exists(): # No COPY_ALLOWED: same-volume directory rename or HOLD. Application has # already closed every old database/lock handle; a leaked handle fails. diff --git a/src/paper_pilot_phase_cutover_guarded_v4.py b/src/paper_pilot_phase_cutover_guarded_v4.py index 0f43d95..ecadc42 100644 --- a/src/paper_pilot_phase_cutover_guarded_v4.py +++ b/src/paper_pilot_phase_cutover_guarded_v4.py @@ -17,12 +17,13 @@ import json import os from pathlib import Path -import re import subprocess from trace_gc.canonical import bytes_digest, dumps, loads from trace_gc.errors import require -from trace_gc.phase_authority import _plain_absolute_path, directory_dacl_sddl +from trace_gc.phase_authority import (_plain_absolute_path, canonical_dacl_ace, + canonical_dacl_aces, dacl_has_ace, + dacl_has_deny_trustee, directory_dacl_sddl) from src.paper_pilot_phase_cutover import NAMES, _current_sid DENY_FILE_ACE = "(D;;DCLC;;;{sid})" @@ -74,7 +75,7 @@ def _icacls(path: Path, *args: str) -> None: def _file_deny(path: Path, sid: str) -> bool: - return DENY_FILE_ACE.format(sid=sid) in directory_dacl_sddl(path) + return dacl_has_ace(directory_dacl_sddl(path), DENY_FILE_ACE, sid) def _readable_pins(root: Path, expected: dict[str, str]) -> None: @@ -95,14 +96,13 @@ def _same_effective_dacl(before: str, after: str) -> bool: before_flags = before.split("(", 1)[0].removeprefix("D:").replace("AI", "") after_flags = after.split("(", 1)[0].removeprefix("D:").replace("AI", "") return (before_flags == after_flags and - re.findall(r"\([^)]*\)", before) == - re.findall(r"\([^)]*\)", after)) + canonical_dacl_aces(before) == canonical_dacl_aces(after)) def _only_admission_dacl_added(original: str, current: str, sid: str) -> bool: - old_aces = re.findall(r"\([^)]*\)", original) - now_aces = re.findall(r"\([^)]*\)", current) - deny = DENY_FILE_ACE.format(sid=sid) + old_aces = canonical_dacl_aces(original) + now_aces = canonical_dacl_aces(current) + deny = canonical_dacl_ace(DENY_FILE_ACE.format(sid=sid)) if deny in now_aces: now_aces.remove(deny) old_flags = original.split("(", 1)[0].removeprefix("D:").replace("AI", "") @@ -111,14 +111,13 @@ def _only_admission_dacl_added(original: str, current: str, sid: str) -> bool: def _owner_deny_present(sddl: str, sid: str) -> bool: - return any(ace.startswith("(D;") and ace.endswith(";;;" + sid + ")") - for ace in re.findall(r"\([^)]*\)", sddl)) + return dacl_has_deny_trustee(sddl, sid) def _only_parent_admission_added(original: str, current: str, sid: str) -> bool: - old_aces = re.findall(r"\([^)]*\)", original) - now_aces = re.findall(r"\([^)]*\)", current) - deny = DENY_PARENT_ACE.format(sid=sid) + old_aces = canonical_dacl_aces(original) + now_aces = canonical_dacl_aces(current) + deny = canonical_dacl_ace(DENY_PARENT_ACE.format(sid=sid)) if deny in now_aces: now_aces.remove(deny) old_flags = original.split("(", 1)[0].removeprefix("D:").replace("AI", "") @@ -231,7 +230,7 @@ def fence_closed_old_phase_guarded(*, old_root: str | Path, {name: bytes_digest(value.encode()) for name, value in original.items()} == expected_file_dacl_sha256, "PHASE_FENCE", "original Windows DACL identity changed") - require(all(DENY_FILE_ACE.format(sid=deny_sid) not in value + require(all(not dacl_has_ace(value, DENY_FILE_ACE, deny_sid) for value in original.values()), "PHASE_FENCE", "unreviewed preexisting file deny") recorded = {"version": "paper-pilot-admission-barrier-v1", @@ -278,11 +277,11 @@ def fence_closed_old_phase_guarded(*, old_root: str | Path, require(_file_deny(path, deny_sid), "PHASE_FENCE", "new old-state write open not denied") parent_sddl = directory_dacl_sddl(old.parent) - if DENY_PARENT_ACE.format(sid=deny_sid) not in parent_sddl: + if not dacl_has_ace(parent_sddl, DENY_PARENT_ACE, deny_sid): _icacls(old.parent, "/deny", "*" + deny_sid + ":(AD)") - require(DENY_PARENT_ACE.format(sid=deny_sid) in - directory_dacl_sddl(old.parent), "PHASE_FENCE", - "old state recreation remains possible") + require(dacl_has_ace(directory_dacl_sddl(old.parent), DENY_PARENT_ACE, + deny_sid), "PHASE_FENCE", + "old state recreation remains possible") require(_only_parent_admission_added( recorded["original_parent_dacl_sddl"], directory_dacl_sddl(old.parent), deny_sid), @@ -317,9 +316,8 @@ def fence_closed_old_phase_guarded(*, old_root: str | Path, require(not _file_deny(path, deny_sid), "PHASE_FENCE", "archived readback still denied") _readable_pins(archived, expected_file_sha256) - require(DENY_PARENT_ACE.format(sid=deny_sid) in - directory_dacl_sddl(old.parent), "PHASE_FENCE", - "old fixed path admission reopened") + require(dacl_has_ace(directory_dacl_sddl(old.parent), DENY_PARENT_ACE, deny_sid), + "PHASE_FENCE", "old fixed path admission reopened") require(_only_parent_admission_added( recorded["original_parent_dacl_sddl"], directory_dacl_sddl(old.parent), deny_sid), diff --git a/tests/phase_trustee_harness.py b/tests/phase_trustee_harness.py new file mode 100644 index 0000000..93e9486 --- /dev/null +++ b/tests/phase_trustee_harness.py @@ -0,0 +1,631 @@ +"""Shared fixtures for the phase-fence trustee tests. This module defines no tests. + +``PhaseHarness`` builds a real ``PhaseAuthority`` (real constructor, real signed +activation pointer, real ``RunBudget`` consumer) and is used by both the native +Windows tests and the modeled tests. + +``WindowsModel`` is a *model* of the Windows seams the fences use: DACL text per +path, ``icacls`` and SID-to-SDDL spelling. It lets the real fence, receipt and +phase code run on any platform against alias-spelled DACLs. It is not Windows +evidence: only the native test classes prove behavior of the operating system. +""" +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import re +import sqlite3 +import subprocess +import sys +import tempfile +from unittest.mock import patch + +from trace_gc import phase_authority as authority +from trace_gc.budget import RunBudget +from trace_gc.canonical import bytes_digest, dumps +from trace_gc.errors import ContractError +from trace_gc.phase_authority import PhaseAuthority, binding_sha256 +from trace_gc.trust import IssuerPolicy, Signer, TrustStore + +USERS_SID = "S-1-5-32-545" # serialized by Windows as the alias BU +GUESTS_SID = "S-1-5-32-546" # an unrelated trustee, serialized as BG +ACCOUNT_PREFIX = "S-1-5-21-4000000001-4000000002-4000000003" +LOCAL_RID500_SID = ACCOUNT_PREFIX + "-500" # this (modeled) machine's Administrator +PLAIN_ACCOUNT_SID = ACCOUNT_PREFIX + "-1001" +FOREIGN_RID500_SID = "S-1-5-21-1111111111-2222222222-3333333333-500" # another domain +NEAR_RID500_SID = "S-1-5-21-4000000001-4000000002-4000000004-500" # one sub-authority off +PARENT_ACE = "(D;;LC;;;{sid})" +FILE_ACE = "(D;;DCLC;;;{sid})" +NAMES = ("checkpoint.sqlite3", "graph.sqlite3", "budget.sqlite3", + "application-journal.sqlite3", "controller.lock") +LIMITS = {"retrieval_requests": 5, "model_calls": 5, "retries": 5, + "solver_expansions": 5, "review_actions": 5, "request_bytes": 5} +RUN_ID = "authored-run" + + +def sha(text: str) -> str: + return hashlib.sha256(text.encode()).hexdigest() + + +SUPPORTED_CONTEXT_ENV = "JRAPHYTE_SUPPORTED_CONTEXT" +BRANCH_LOG_ENV = "JRAPHYTE_BRANCH_LOG" +TOKEN_GATE_PROBLEM = "current process token can create beneath the parent deny" + + +def producer_context_supported(test, base, sid: str) -> bool: + """Whether the ORIGINAL process token is refused creating a directory beneath the exact + production parent deny for ``sid``, decided by an independent native probe on a stabilized + directory inside the owned ``base`` (never by the code under test). The probe's own restoration + and cleanup must verify. With ``JRAPHYTE_SUPPORTED_CONTEXT=required`` an unsupported context + fails the test instead of selecting the refusal branch. With ``JRAPHYTE_BRANCH_LOG`` set, the + branch each case took is appended there, so a run that only took the refusal branch is visible + as such and is never mistaken for positive qualification.""" + from tools import windows_fixture_acl, windows_native_acl + native = windows_native_acl.WindowsNative() + base = Path(base) + + def make(label: str) -> Path: + path = Path(tempfile.mkdtemp(prefix="context-", dir=base)) + windows_fixture_acl.stabilize_owned_fixture(base, [(label, path)], read=native.read_dacl) + return path + done = windows_native_acl.probe_actual_fence_shape(native, make, sid) + test.assertTrue(done["verified"], "context probe did not restore: " + json.dumps( + {"restored": done["restored"], "cleanup": done["report"]["probe_cleanup_verified"]}, sort_keys=True)) + supported = done["report"]["verdict"] == "DENIED" + log = os.environ.get(BRANCH_LOG_ENV) + if log: + with open(log, "a", encoding="utf-8") as stream: + stream.write(json.dumps({"test": test.id().rsplit(".", 1)[-1], "verdict": done["report"]["verdict"], + "branch": "supported_positive" if supported else "unsupported_refusal"}, + sort_keys=True) + "\n") + if not supported and os.environ.get(SUPPORTED_CONTEXT_ENV) == "required": + test.fail("a supported context is required and the original process token is not refused: " + + json.dumps(done["report"], sort_keys=True)) + return supported + + +def assert_fence_refused_before_effects(test, invoke, *, parent, old, archive, receipt, content) -> None: + """The v1 producer refused (``PHASE_FENCE``, the token gate) and left the old state, the + parent DACL (the deny it installed to probe is removed again), the archive and the receipt + exactly as they were, with no probe directory left in the fenced parent or beside the receipt.""" + before = authority.directory_dacl_sddl(parent) + with test.assertRaises(ContractError) as caught: + invoke() + test.assertEqual((caught.exception.code, caught.exception.detail), ("PHASE_FENCE", TOKEN_GATE_PROBLEM)) + test.assertEqual(authority.directory_dacl_sddl(parent), before) + test.assertTrue(old.exists()) + test.assertFalse(Path(archive).exists()) + test.assertFalse(Path(receipt).exists()) + test.assertEqual(sorted(item.name for directory in {Path(parent), Path(receipt).parent} + for item in directory.iterdir() if item.name.startswith(".fence-")), []) + test.assertEqual({name: bytes_digest((Path(old) / name).read_bytes()) for name in content}, content) + + +def database_dump(path: Path) -> str: + """Logical content of an owned SQLite file, used to prove 'state unchanged'.""" + connection = sqlite3.connect(f"file:{Path(path).as_posix()}?mode=ro", uri=True) + try: + return "\n".join(connection.iterdump()) + finally: + connection.close() + + +class PhaseHarness: + """A real PhaseAuthority over a real signed activation pointer in an owned phase dir.""" + + def __init__(self, test, base: Path, old: Path, archived: Path): + self.test, self.old, self.archived = test, old, archived + self.phase = base / "phase" + self.phase.mkdir() + (self.phase / "phase.lock").write_bytes(b"0") + self.pointer = self.phase / "active.json" + self.signer = Signer.ephemeral("authored-phase-reviewer") + self.trust = TrustStore() + self.trust.enroll(self.signer.issuer, IssuerPolicy( + self.signer.public_key(), "authored-phase-reviewer", + frozenset({"AUTHORIZATION"}), frozenset({"PHASE_ACTIVATE"}), + frozenset({"isolated-test"}), frozenset({"LIVE"}), can_review=True)) + self.opened: list[PhaseAuthority] = [] + self.raw_pointer: bytes | None = None + test.addCleanup(self.close) + + def close(self): + for instance in self.opened: + if not instance._lock_file.closed: + instance.close() + + def binding(self, fence: Path, activation_sha: str) -> dict: + return {"phase_id": "authored-phase", "run_id": RUN_ID, + "capsule_sha256": "a" * 64, "implementation_sha256": "b" * 64, + "activation_path": str(self.pointer), "activation_sha256": activation_sha, + "activation_epoch": 1, "phase_lock_path": str(self.phase / "phase.lock"), + "fence_receipt_path": str(fence), + "fence_receipt_sha256": bytes_digest(fence.read_bytes()), + "old_root_path": str(self.old), "archived_old_root_path": str(self.archived), + "capsule_path": str(self.phase / "capsule.json"), + "authorization_path": str(self.phase / "auth.json"), + "authorization_sha256": "c" * 64} + + def authority(self, fence: Path, *, activate: bool = False) -> PhaseAuthority: + """Bind the exact signed pointer; ``activate`` publishes it, otherwise it is absent.""" + probe = PhaseAuthority(self.binding(fence, "0" * 64), self.trust) + try: + signed = self.signer.issue("AUTHORIZATION", probe.activation_payload(), + lifetime_seconds=3600) + finally: + probe.close() + self.raw_pointer = (dumps(signed) + "\n").encode("utf-8") + instance = PhaseAuthority(self.binding(fence, bytes_digest(self.raw_pointer)), self.trust) + self.opened.append(instance) + self.pointer.unlink(missing_ok=True) + if activate: + self.activate() + return instance + + def peer(self, instance: PhaseAuthority) -> PhaseAuthority: + """A second constructor over the identical binding (a concurrent opener).""" + other = PhaseAuthority(dict(instance.binding), self.trust) + self.opened.append(other) + return other + + def activate(self): + self.pointer.write_bytes(self.raw_pointer) + + def deactivate(self): + self.pointer.unlink(missing_ok=True) + + def budget_path(self, instance: PhaseAuthority) -> Path: + """A budget database carrying the persisted binding marker, as import writes it.""" + path = self.phase / "budget.sqlite3" + path.unlink(missing_ok=True) + connection = sqlite3.connect(path) + try: + connection.execute("CREATE TABLE trace_phase_binding " + "(id INTEGER PRIMARY KEY CHECK(id=1), binding_sha256 TEXT NOT NULL)") + connection.execute("INSERT INTO trace_phase_binding VALUES (1,?)", + (binding_sha256(instance.binding),)) + connection.commit() + finally: + connection.close() + return path + + def budget(self, path: Path, instance: PhaseAuthority) -> RunBudget: + ledger = RunBudget(path, RUN_ID, LIMITS, phase_authority=instance) + self.test.addCleanup(ledger.close) + return ledger + + +class ModeledAccessDenied(PermissionError): + """What the model raises where Windows reports ``ERROR_ACCESS_DENIED`` (5).""" + winerror = 5 + + +class _NtOs: + """``os`` that reports Windows, re-keys the DACL model on rename and refuses ``mkdir`` where + the modeled parent DACL denies the modeled token.""" + + def __init__(self, model): + self._model = model + self.name = "nt" + + def mkdir(self, path, *args, **kwargs): + if self._model.refuses_creation(path): + raise ModeledAccessDenied(13, "modeled access denied") + return os.mkdir(path, *args, **kwargs) + + def rename(self, source, destination): + os.rename(source, destination) + self._model.rekey(source, destination) + + def __getattr__(self, name): + return getattr(os, name) + + +class _Subprocess: + def __init__(self, model): + self._model = model + + def run(self, args, **kwargs): + return self._model.icacls(list(args)) + + +class _Msvcrt: + """Byte-lock model: LK_NBLCK fails while another handle holds the same file.""" + LK_LOCK, LK_NBLCK, LK_UNLCK = 1, 2, 0 + + def __init__(self): + self.held: dict[tuple, int] = {} + + def locking(self, fd, mode, nbytes): + stat = os.fstat(fd) + key = (stat.st_dev, stat.st_ino) + if mode == self.LK_UNLCK: + self.held.pop(key, None) + return + if key in self.held and self.held[key] != fd: + raise OSError("modeled byte lock is held") + self.held[key] = fd + + +class WindowsModel: + """Model of per-path DACL text, ``icacls`` and SID spelling; see the module docstring.""" + + ALIASES = {USERS_SID: "BU", GUESTS_SID: "BG", LOCAL_RID500_SID: "LA", "S-1-5-18": "SY"} + RIGHTS = {("AD",): "LC", ("WD", "AD"): "DCLC", ("X",): "FX", ("WD",): "DC"} + + def __init__(self, test, *, current_sid: str = USERS_SID, modules=()): + self.test, self.current_sid, self.modules = test, current_sid, modules + self.dacls: dict[str, tuple[str, list[str]]] = {} + self.msvcrt = _Msvcrt() + # True models a token (for example one holding a bypass privilege) that is not refused by + # a parent deny; the DACL text is unchanged. + self.token_bypasses_deny = False + # Directories (by resolved path) where creation is not refused although the DACL text + # denies it, to model a filesystem or control flags that differ from another directory. + self.bypass_parents: set[str] = set() + + # -- lifecycle --------------------------------------------------------- + def start(self): + patches = [patch.object(authority, "_is_windows", return_value=True, create=True), + patch.object(authority, "_windows_trustee", side_effect=self.spell, create=True), + patch.object(authority, "directory_dacl_sddl", side_effect=self.sddl), + patch.object(authority, "os", _NtOs(self)), + patch.dict(sys.modules, {"msvcrt": self.msvcrt})] + for module in self.modules: + patches += [patch.object(module, "directory_dacl_sddl", side_effect=self.sddl), + patch.object(module, "_current_sid", return_value=self.current_sid), + patch.object(module, "subprocess", _Subprocess(self)), + patch.object(module, "os", _NtOs(self))] + for item in patches: + item.start() + self.test.addCleanup(item.stop) + return self + + # -- Windows spelling -------------------------------------------------- + def spell(self, sid: str) -> str: + return self.ALIASES.get(sid, sid) + + # -- DACL store -------------------------------------------------------- + @staticmethod + def key(path) -> str: + return str(Path(path).resolve(strict=True)) + + def default(self, path: Path) -> tuple[str, list[str]]: + if Path(path).is_dir(): + return "PAI", ["(A;;FA;;;BA)", "(A;OICIID;FA;;;SY)", "(A;OICIID;FA;;;BA)"] + return "AI", ["(A;ID;FA;;;BA)", "(A;ID;FA;;;SY)"] + + def entry(self, path) -> tuple[str, list[str]]: + key = self.key(path) + if key not in self.dacls: + self.dacls[key] = self.default(Path(path)) + return self.dacls[key] + + def sddl(self, path) -> str: + flags, aces = self.entry(path) + return "D:" + flags + "".join(aces) + + def set(self, path, flags: str, aces: list[str]): + self.entry(path) + self.dacls[self.key(path)] = (flags, list(aces)) + + def refuses_creation(self, path) -> bool: + """Whether the modeled token is refused creating ``path`` under an explicit ``LC`` deny.""" + if self.token_bypasses_deny: + return False + parent = Path(path).parent + if not parent.exists() or self.key(parent) in self.bypass_parents: + return False + trustee = self.spell(self.current_sid) + return any(self.explicit(ace, "D", trustee) and "LC" in ace[1:-1].split(";")[2] + for ace in self.entry(parent)[1]) + + def rekey(self, source, destination): + source, destination = str(Path(source).resolve()), str(Path(destination).resolve()) + for key in list(self.dacls): + if key == source or key.startswith(source + os.sep): + self.dacls[destination + key[len(source):]] = self.dacls.pop(key) + + # -- icacls ------------------------------------------------------------ + @staticmethod + def inherited(ace: str) -> bool: + return "ID" in ace[1:-1].split(";")[1] + + def explicit(self, ace: str, kind: str, trustee: str) -> bool: + fields = ace[1:-1].split(";") + return fields[0] == kind and fields[5] == trustee and not self.inherited(ace) + + def icacls(self, args: list[str]) -> subprocess.CompletedProcess: + assert args[0] == "icacls.exe", args + path, verb, rest = Path(args[1]), args[2], args[3:] + flags, aces = self.entry(path) + aces = list(aces) + if verb == "/inheritance:r": + assert rest[0] == "/grant:r", rest + aces = [item for item in aces if not self.inherited(item)] + flags = "P" + flags.replace("P", "") + for spec in rest[1:]: + grantee, rights = spec.lstrip("*").split(":", 1) + inherit = "OICI" if rights.startswith("(OI)(CI)") else "" + trustee = {"S-1-5-32-544": "BA", "S-1-3-4": "OW"}.get(grantee) or self.spell(grantee) + aces = [item for item in aces if not self.explicit(item, "A", trustee)] + aces.append(f"(A;{inherit};FA;;;{trustee})") + self.dacls[self.key(path)] = (flags, aces) + return subprocess.CompletedProcess(args, 0, "", "") + sid = rest[0].lstrip("*").split(":")[0] + trustee = self.spell(sid) + if verb in ("/deny", "/grant"): + spec = rest[0].split(":", 1)[1] + inherit = "" + names = [] + for part in [item for item in spec.replace(")(", ")|(").split("|")]: + words = part.strip("()").split(",") + if words[0] in ("OI", "CI") and len(words) == 1: + inherit += words[0] + else: + names.extend(words) + rights = self.RIGHTS[tuple(names)] + ace = f"({'D' if verb == '/deny' else 'A'};{inherit};{rights};;;{trustee})" + if verb == "/deny": + aces.insert(0, ace) + else: + aces.insert(sum(1 for item in aces if not self.inherited(item)), ace) + elif verb == "/remove:d": + aces = [item for item in aces if not self.explicit(item, "D", trustee)] + elif verb == "/remove:g": + aces = [item for item in aces if not self.explicit(item, "A", trustee)] + else: + raise AssertionError("unmodeled icacls verb " + verb) + self.dacls[self.key(path)] = (flags, aces) + return subprocess.CompletedProcess(args, 0, "", "") + + +class DescriptorModel: + """The descriptor seam of ``tools/windows_native_acl.WindowsNative`` over a ``WindowsModel``. + + A model, not Windows: numeric trustees are stored in the spelling the model gives them (so a + deny installed for ``S-1-5-32-545`` reads back as ``BU``), exactly the situation a literal + ACE comparison cannot survive and a canonical one must. + """ + + OWNER = "O:BA" + + def __init__(self, model): + self.model = model + + def read_dacl(self, path) -> str: + return self.model.sddl(path) + + def read_owner(self, path) -> str: + return self.OWNER + + def apply_dacl(self, path, sddl: str) -> None: + match = re.fullmatch(r"D:([A-Z]*)((?:\([^)]*\))*)", sddl) + assert match is not None, sddl + spelled = [] + for ace in re.findall(r"\([^)]*\)", match.group(2)): + fields = ace[1:-1].split(";") + fields[5] = self.model.spell(fields[5]) + spelled.append("(" + ";".join(fields) + ")") + self.model.set(path, match.group(1), spelled) + + def canonical_trustee(self, sid: str) -> str: + return self.model.spell(sid) + + +class Sids: + """The identities one host (native or modeled) uses for the shared scenarios.""" + + def __init__(self, *, natural: str, local500: str, plain: str, near500: str): + self.users, self.guests, self.foreign500 = USERS_SID, GUESTS_SID, FOREIGN_RID500_SID + self.natural, self.local500, self.plain, self.near500 = natural, local500, plain, near500 + + +class ReceiptValidatorScenarios: + """PhaseAuthority receipt validation against a parent DACL the host spells. + + Not a TestCase: concrete classes combine it with ``unittest.TestCase`` and supply + ``harness``, ``sids``, ``parent``, ``old``, ``archived``, ``receipt``, ``files`` and + the ``raw``/``deny``/``grant``/``lift_deny``/``lift_grant`` hooks. The authority is + always the real constructor over a real signed pointer; accepted means both + ``write()`` (current) and ``staging()`` enter, rejected means both raise PHASE_FENCE. + """ + + def write_receipt(self, deny_sid, *, dacl_sha=None, files=None) -> str: + from trace_gc.canonical import loads # noqa: F401 + raw = self.raw() + body = {"version": "paper-pilot-phase-namespace-fence-v1", + "old_root_path": str(self.old), "archived_root_path": str(self.archived), + "old_parent_dacl_sha256": dacl_sha or bytes_digest(raw.encode("utf-8")), + "archived_files_sha256": files or self.files, + "deny_sid": deny_sid, "status": "OLD_PATH_FENCED_NEW_PHASE_ALLOWED"} + self.receipt.write_bytes((dumps(body) + "\n").encode("utf-8")) + return raw + + def bound(self, *, activate=True) -> PhaseAuthority: + return self.harness.authority(self.receipt, activate=activate) + + def assert_accepted(self): + instance = self.bound() + with instance.write(): + pass + with instance.staging(): + pass + instance.check() + + def assert_rejected(self, code="PHASE_FENCE"): + from trace_gc.errors import ContractError + instance = self.bound() + for scope in (instance.write, instance.staging): + with self.assertRaises(ContractError) as caught: + with scope(): + self.fail("phase scope entered despite an invalid fence") + self.assertEqual(caught.exception.code, code) + + def test_group_alias_parent_deny_is_accepted_with_raw_digest_pin(self): + from trace_gc.canonical import loads + self.deny(self.sids.users, "(AD)") + raw = self.write_receipt(self.sids.users) + self.assertIn("(D;;LC;;;BU)", raw) + self.assertNotIn(self.sids.users, raw) + self.assertEqual(loads(self.receipt.read_bytes())["old_parent_dacl_sha256"], + bytes_digest(raw.encode("utf-8"))) + self.assert_accepted() + + def test_machine_rid500_parent_deny_is_accepted(self): + self.deny(self.sids.local500, "(AD)") + self.write_receipt(self.sids.local500) + self.assert_accepted() + + def test_natural_identity_parent_deny_is_accepted(self): + self.deny(self.sids.natural, "(AD)") + self.write_receipt(self.sids.natural) + self.assert_accepted() + + def test_distinct_rid500_and_other_trustees_are_not_the_receipt_trustee(self): + self.deny(self.sids.local500, "(AD)") + for wrong in (self.sids.foreign500, self.sids.near500, self.sids.plain, + self.sids.users, self.sids.guests): + if wrong == self.sids.local500: + continue + self.write_receipt(wrong) + self.assert_rejected() + self.write_receipt(self.sids.local500) + self.assert_accepted() + + def test_foreign_rid500_deny_is_not_the_local_account_deny(self): + self.deny(self.sids.foreign500, "(AD)") + self.write_receipt(self.sids.local500) + self.assert_rejected() + self.write_receipt(self.sids.foreign500) + self.assert_accepted() + + def test_wrong_trustee_alias_deny_is_rejected(self): + self.deny(self.sids.guests, "(AD)") + self.write_receipt(self.sids.users) + self.assert_rejected() + + def test_wrong_mask_flags_or_type_are_rejected(self): + users = self.sids.users + for verb, spec in (("deny", "(WD)"), # different mask + ("deny", "(OI)(AD)"), # inheritance flag + ("grant", "(AD)")): # allow, not deny + self.lift_deny(users) + self.lift_grant(users) + getattr(self, verb)(users, spec) + self.write_receipt(users) + self.assert_rejected() + + def test_absent_deny_and_changed_pins_are_rejected(self): + users = self.sids.users + self.write_receipt(users) + self.assert_rejected() + self.deny(users, "(AD)") + self.write_receipt(users, dacl_sha="0" * 64) + self.assert_rejected() + self.write_receipt(users) + self.assert_accepted() + (self.archived / "graph.sqlite3").write_bytes(b"changed") + self.assert_rejected() + + def test_fenced_but_inactive_pointer_stages_and_rejects_current_writes(self): + from trace_gc.errors import ContractError + self.deny(self.sids.users, "(AD)") + self.write_receipt(self.sids.users) + instance = self.bound(activate=False) + with instance.staging(): + pass + with self.assertRaises(ContractError) as caught: + with instance.write(): + self.fail("inactive phase entered a current write") + self.assertEqual(caught.exception.code, "PHASE_INACTIVE") + + +class PhaseOwnershipScenarios: + """Real PhaseAuthority + RunBudget over a fence the host created. + + Host supplies ``harness``, ``fence()`` (returns the sealed fence receipt path), + ``remove_parent_deny()`` and ``tamper_archive()``. + """ + + def ledger(self, activate): + instance = self.harness.authority(self.fence_receipt, activate=activate) + path = self.harness.budget_path(instance) + return instance, path + + def used(self, path): + import sqlite3 + connection = sqlite3.connect(f"file:{path.as_posix()}?mode=ro", uri=True) + try: + from trace_gc.canonical import loads + return loads(connection.execute("SELECT used FROM trace_budgets").fetchone()[0]) + finally: + connection.close() + + def rejects(self, call, code): + from trace_gc.errors import ContractError + with self.assertRaises(ContractError) as caught: + call() + self.assertEqual(caught.exception.code, code) + + def test_inactive_fenced_phase_stages_but_current_writes_are_rejected_state_unchanged(self): + instance, path = self.ledger(activate=False) + with instance.staging(): + pass + ledger = self.harness.budget(path, instance) # staging open of the DB is allowed + before = database_dump(path) + self.rejects(lambda: ledger.consume("model_calls"), "PHASE_INACTIVE") + self.rejects(instance.check, "PHASE_INACTIVE") + self.assertEqual(database_dump(path), before) + self.assertEqual(self.used(path)["model_calls"], 0) + + def test_current_phase_writes_and_persists_over_the_alias_fence(self): + instance, path = self.ledger(activate=True) + ledger = self.harness.budget(path, instance) + before = database_dump(path) + self.assertEqual(ledger.consume("model_calls", 2)["model_calls"], 2) + instance.check() + with instance.staging(): + pass + self.assertNotEqual(database_dump(path), before) + self.assertEqual(self.used(path)["model_calls"], 2) + reopened = self.harness.budget(path, instance) # current: write-mode open + self.assertEqual(reopened.snapshot()["used"]["model_calls"], 2) + + def test_removed_parent_deny_rejects_staging_writes_and_opens_state_unchanged(self): + instance, path = self.ledger(activate=True) + ledger = self.harness.budget(path, instance) + ledger.consume("model_calls") + before = database_dump(path) + self.remove_parent_deny() + self.rejects(lambda: ledger.consume("model_calls"), "PHASE_FENCE") + self.rejects(instance.check, "PHASE_FENCE") + with self.assertRaises(Exception) as caught: + with instance.staging(): + self.fail("staging entered without the parent deny") + self.assertEqual(getattr(caught.exception, "code", None), "PHASE_FENCE") + self.rejects(lambda: self.harness.budget(path, instance), "PHASE_FENCE") + self.assertEqual(database_dump(path), before) + + def test_changed_archive_byte_rejects_writes_and_staging_state_unchanged(self): + instance, path = self.ledger(activate=True) + ledger = self.harness.budget(path, instance) + before = database_dump(path) + self.tamper_archive() + self.rejects(lambda: ledger.consume("model_calls"), "PHASE_FENCE") + with self.assertRaises(Exception) as caught: + with instance.staging(): + self.fail("staging entered over a changed archive") + self.assertEqual(getattr(caught.exception, "code", None), "PHASE_FENCE") + self.assertEqual(database_dump(path), before) + + def test_changed_signed_pointer_is_not_current_state_unchanged(self): + instance, path = self.ledger(activate=True) + ledger = self.harness.budget(path, instance) + before = database_dump(path) + self.harness.pointer.write_bytes(self.harness.raw_pointer.replace(b"authored-phase", + b"another-phase")) + self.rejects(lambda: ledger.consume("model_calls"), "PHASE_INACTIVE") + self.assertEqual(database_dump(path), before) diff --git a/tests/test_paper_pilot_phase_cutover_guarded_v4.py b/tests/test_paper_pilot_phase_cutover_guarded_v4.py index 1155b97..46d0188 100644 --- a/tests/test_paper_pilot_phase_cutover_guarded_v4.py +++ b/tests/test_paper_pilot_phase_cutover_guarded_v4.py @@ -14,6 +14,7 @@ from src import paper_pilot_phase_cutover_guarded_v4 as guarded from src.paper_pilot_phase_cutover import _current_sid +from tools import windows_fixture_acl from trace_gc.budget import RunBudget from trace_gc.canonical import bytes_digest, dumps from trace_gc.trust import IssuerPolicy, Signer, TrustStore @@ -56,6 +57,11 @@ def setUp(self): (self.parent / name).write_bytes(b"0") self.files = {name: bytes_digest((self.old / name).read_bytes()) for name in guarded.NAMES} + # Explicit, protected and verified before the first pin (see tools/windows_fixture_acl.py). + windows_fixture_acl.stabilize_owned_fixture( + self.base, [("parent", self.parent), ("state", self.old)] + + [("file:" + name, self.old / name) for name in sorted(guarded.NAMES)], + read=guarded.directory_dacl_sddl) self.parent_acl = bytes_digest(guarded.directory_dacl_sddl(self.parent).encode()) self.file_acl = {name: bytes_digest( guarded.directory_dacl_sddl(self.old / name).encode()) diff --git a/tests/test_paper_pilot_phase_fence.py b/tests/test_paper_pilot_phase_fence.py index d2633e1..51434f5 100644 --- a/tests/test_paper_pilot_phase_fence.py +++ b/tests/test_paper_pilot_phase_fence.py @@ -10,8 +10,12 @@ from trace_gc.canonical import bytes_digest from trace_gc.errors import ContractError -from trace_gc.phase_authority import directory_dacl_sddl +from trace_gc.phase_authority import dacl_has_ace, directory_dacl_sddl from src.paper_pilot_phase_cutover import NAMES, _current_sid, fence_closed_old_phase +from tests.phase_trustee_harness import assert_fence_refused_before_effects, producer_context_supported +from tools.windows_native_acl import WindowsNative, require_recreation_denied + +PARENT_DENY = "(D;;LC;;;{sid})" @unittest.skipUnless(os.name == "nt", "requires disposable NTFS DACL fixture") @@ -47,16 +51,26 @@ def tearDown(self): self.temp.cleanup() def test_exact_replay_after_closed_directory_move(self): - self.assertNotIn(f"(D;;LC;;;{self.sid})", directory_dacl_sddl(self.old.parent)) + self.assertFalse(dacl_has_ace(directory_dacl_sddl(self.old.parent), PARENT_DENY, self.sid)) + if not producer_context_supported(self, self.root, self.sid): + # Fail closed: the original token is not refused beneath the deny, so nothing was + # fenced; the strict positive path is held by the supported-context run. + assert_fence_refused_before_effects( + self, lambda: fence_closed_old_phase( + old_root=self.old, archived_root=self.archived, expected_file_sha256=self.expected, + deny_sid=self.sid, receipt_path=self.receipt), + parent=self.old.parent, old=self.old, archive=self.archived, receipt=self.receipt, + content=self.expected) + return first = fence_closed_old_phase(old_root=self.old, archived_root=self.archived, expected_file_sha256=self.expected, deny_sid=self.sid, receipt_path=self.receipt) self.assertEqual(first["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") self.assertFalse(self.old.exists()) self.assertEqual({name: bytes_digest((self.archived / name).read_bytes()) for name in NAMES}, self.expected) - self.assertIn(f"(D;;LC;;;{self.sid})", directory_dacl_sddl(self.old.parent)) - with self.assertRaises((PermissionError, OSError)): - self.old.mkdir() + self.assertTrue(dacl_has_ace(directory_dacl_sddl(self.old.parent), PARENT_DENY, self.sid)) + require_recreation_denied(WindowsNative(), self.old.parent, self.old) + self.assertFalse(self.old.exists()) second = fence_closed_old_phase(old_root=self.old, archived_root=self.archived, expected_file_sha256=self.expected, deny_sid=self.sid, receipt_path=self.receipt) self.assertEqual(first, second) @@ -74,7 +88,7 @@ def test_wrong_hash_holds_before_acl_change(self): expected_file_sha256=wrong, deny_sid=self.sid, receipt_path=self.receipt) self.assertTrue(self.old.exists()) self.assertFalse(self.archived.exists()) - self.assertNotIn(f"(D;;LC;;;{self.sid})", directory_dacl_sddl(self.old.parent)) + self.assertFalse(dacl_has_ace(directory_dacl_sddl(self.old.parent), PARENT_DENY, self.sid)) def test_junction_archive_parent_holds_before_acl_or_rename(self): junction = self.root / "redirected-archive" diff --git a/tests/test_paper_pilot_phase_trustees_model.py b/tests/test_paper_pilot_phase_trustees_model.py new file mode 100644 index 0000000..2ed4725 --- /dev/null +++ b/tests/test_paper_pilot_phase_trustees_model.py @@ -0,0 +1,940 @@ +"""Phase-fence trustee contract run against a MODEL of Windows' DACL seams. + +The real v1 and v4 fence functions, the real ``PhaseAuthority`` constructor and +signed pointer, and the real ``RunBudget`` consumer run on any platform against +``WindowsModel`` (per-path DACL text, ``icacls`` and SID spelling). This pins the +control flow and the ACE-strictness contract. It is NOT Windows evidence: that is +``test_paper_pilot_phase_trustees_v1_v4.py`` on a native Windows runner. +""" +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +from tests.phase_trustee_harness import ( + FILE_ACE, FOREIGN_RID500_SID, GUESTS_SID, LOCAL_RID500_SID, NAMES, NEAR_RID500_SID, + PARENT_ACE, PLAIN_ACCOUNT_SID, USERS_SID, PhaseHarness, PhaseOwnershipScenarios, + DescriptorModel, ReceiptValidatorScenarios, Sids, WindowsModel, assert_fence_refused_before_effects, sha) +from trace_gc import phase_authority as authority +from trace_gc.canonical import bytes_digest, dumps, loads +from trace_gc.errors import ContractError +from src import paper_pilot_phase_cutover as v1 +from src import paper_pilot_phase_cutover_guarded_v4 as v4 + +MODEL_SIDS = dict(natural=PLAIN_ACCOUNT_SID, local500=LOCAL_RID500_SID, + plain=PLAIN_ACCOUNT_SID, near500=NEAR_RID500_SID) + + +class OffWindowsContractTests(unittest.TestCase): + """No operating system, no numeric-SID guessing; identical on every platform.""" + + def test_numeric_sid_fails_closed_without_windows(self): + with patch.object(authority, "_is_windows", return_value=False): + for sid in (USERS_SID, FOREIGN_RID500_SID, "S-1-5-21-1-2-3-500"): + with self.assertRaises(ContractError) as caught: + authority.canonical_dacl_trustee(sid) + self.assertEqual(caught.exception.code, "PHASE_PLATFORM") + with self.assertRaises(ContractError): + authority.dacl_has_ace("D:(D;;LC;;;LA)", PARENT_ACE, FOREIGN_RID500_SID) + with self.assertRaises(ContractError): + authority.canonical_dacl_aces("D:(D;;LC;;;" + FOREIGN_RID500_SID + ")") + with self.assertRaises(ContractError): + authority.dacl_has_deny_trustee("D:(D;;LC;;;LA)", LOCAL_RID500_SID) + + def test_alias_and_non_sid_text_pass_through_without_windows(self): + with patch.object(authority, "_is_windows", return_value=False): + for text in ("LA", "BU", "SY", "S-1-5-18)(A;;FA;;;WD", "", "S-1-5"): + self.assertEqual(authority.canonical_dacl_trustee(text), text) + self.assertTrue(authority.dacl_has_ace("D:(D;;LC;;;BU)", PARENT_ACE, "BU")) + self.assertFalse(authority.dacl_has_ace("D:(D;;LC;;;BU)", PARENT_ACE, "LA")) + self.assertFalse(authority.dacl_has_ace("D:(D;;LC;;;LA)", PARENT_ACE, "BA")) + + def test_off_windows_fences_and_authority_still_refuse(self): + with patch.object(v1.os, "name", "posix"), patch.object(v4.os, "name", "posix"): + with self.assertRaises(ContractError) as caught: + v1.fence_closed_old_phase(old_root="/x/state", archived_root="/y/state", + expected_file_sha256={}, deny_sid=USERS_SID, receipt_path="/z/r.json") + self.assertEqual(caught.exception.code, "PHASE_PLATFORM") + with self.assertRaises(ContractError) as caught: + v4.fence_closed_old_phase_guarded(old_root="/x/state", archived_root="/y/state", + expected_file_sha256={}, source_stage_lock="/x/a", phase_stage_lock="/x/b", + deny_sid=USERS_SID, expected_parent_dacl_sha256="0" * 64, + expected_file_dacl_sha256={}, barrier_receipt_path="/z/b.json", + fence_receipt_path="/z/f.json") + self.assertEqual(caught.exception.code, "PHASE_PLATFORM") + + +class ModeledCase(unittest.TestCase): + """An owned temp tree plus the Windows model, patched into the real code.""" + + current_sid = USERS_SID + + def setUp(self): + temp = tempfile.TemporaryDirectory(prefix="jraphyte-trustee-model-") + self.addCleanup(temp.cleanup) + self.base = Path(temp.name).resolve() + self.model = WindowsModel(self, current_sid=self.current_sid, modules=(v1, v4)).start() + self.parent = self.base / "old-app" + self.old = self.parent / "state" + self.archived = self.base / "archive" / "historical-state" + self.old.mkdir(parents=True) + self.archived.parent.mkdir() + (self.base / "control").mkdir() + self.content = {} + for name in NAMES: + body = b"0" if name == "controller.lock" else ("owned:" + name).encode() + (self.old / name).write_bytes(body) + self.content[name] = bytes_digest(body) + for name in ("source-stage.lock", "phase-stage.lock"): + (self.parent / name).write_bytes(b"0") + self.harness = PhaseHarness(self, self.base, self.old, self.archived) + + def sddl(self, path) -> str: + return self.model.sddl(path) + + def acl_pins(self): + return (sha(self.sddl(self.parent)), {n: sha(self.sddl(self.old / n)) for n in NAMES}) + + def assert_phase_accepts(self, fence: Path): + instance = self.harness.authority(fence, activate=True) + instance.check() + with instance.staging(): + pass + + +class ModeledV1FenceTests(ModeledCase): + def setUp(self): + super().setUp() + self.receipt = self.base / "control" / "fence-receipt.json" + + def invoke(self, sid=None): + return v1.fence_closed_old_phase( + old_root=self.old, archived_root=self.archived, + expected_file_sha256=self.content, deny_sid=sid or self.current_sid, + receipt_path=self.receipt) + + def test_group_alias_fence_replays_keeps_raw_digest_and_validates(self): + first = self.invoke() + raw = self.sddl(self.parent) + self.assertEqual(first["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assertIn("(D;;LC;;;BU)", raw) + self.assertNotIn(USERS_SID, raw) + self.assertFalse(self.old.exists()) + self.assertEqual(first, self.invoke()) + body = loads(self.receipt.read_bytes()) + self.assertEqual(body["deny_sid"], USERS_SID) + self.assertEqual(body["old_parent_dacl_sha256"], bytes_digest(raw.encode("utf-8"))) + self.assert_phase_accepts(self.receipt) + + def test_interrupted_rename_retries_to_the_identical_receipt(self): + with patch.object(v1.os, "rename", side_effect=PermissionError("injected")): + with self.assertRaises(PermissionError): + self.invoke() + self.assertTrue(self.old.exists() and not self.receipt.exists()) + self.assertIn("(D;;LC;;;BU)", self.sddl(self.parent)) + recovered = self.invoke() + self.assertEqual(recovered, self.invoke()) + self.assert_phase_accepts(self.receipt) + + def test_unrelated_parent_deny_is_preserved_and_not_the_barrier(self): + self.model.icacls(["icacls.exe", str(self.parent), "/deny", "*" + GUESTS_SID + ":(AD)"]) + self.assertFalse(authority.dacl_has_ace(self.sddl(self.parent), PARENT_ACE, USERS_SID)) + self.invoke() + raw = self.sddl(self.parent) + self.assertEqual(raw.count("(D;;LC;;;BG)"), 1) + self.assertEqual(raw.count("(D;;LC;;;BU)"), 1) + + def test_wrong_hash_and_foreign_sid_hold_before_any_acl_change(self): + before = self.sddl(self.parent) + with self.assertRaises(ContractError): + v1.fence_closed_old_phase( + old_root=self.old, archived_root=self.archived, deny_sid=USERS_SID, + expected_file_sha256={**self.content, "graph.sqlite3": "0" * 64}, + receipt_path=self.receipt) + with self.assertRaises(ContractError): + self.invoke(GUESTS_SID) + self.assertEqual(self.sddl(self.parent), before) + self.assertTrue(self.old.exists() and not self.receipt.exists()) + + def test_a_local_administrator_deny_is_not_the_foreign_rid500_deny(self): + # Another domain's RID-500 owner must not treat the local LA deny as its own. + self.model.icacls(["icacls.exe", str(self.parent), "/deny", + "*" + LOCAL_RID500_SID + ":(AD)"]) + self.model.current_sid = FOREIGN_RID500_SID # the modeled token is that account + with patch.object(v1, "_current_sid", return_value=FOREIGN_RID500_SID): + self.invoke(FOREIGN_RID500_SID) + raw = self.sddl(self.parent) + self.assertEqual(raw.count("(D;;LC;;;LA)"), 1) + self.assertEqual(raw.count("(D;;LC;;;" + FOREIGN_RID500_SID + ")"), 1) + self.assertEqual(loads(self.receipt.read_bytes())["deny_sid"], FOREIGN_RID500_SID) + + +class ModeledV1TokenGateTests(ModeledCase): + """The v1 producer holds, with the old state in place and the deny it added removed again, + unless the parent deny really refuses the current token creating a child of the FENCED + parent. The model supplies the refusal (or, with ``token_bypasses_deny`` or + ``bypass_parents``, its absence); that this matches Windows is for the native tests to show.""" + + def setUp(self): + super().setUp() + self.receipt = self.base / "control" / "fence-receipt.json" + self.control = self.receipt.parent + self.calls = [] + real = self.model.icacls + + def record(args): + self.calls.append(list(args)) + return real(args) + patcher = patch.object(self.model, "icacls", record) + patcher.start() + self.addCleanup(patcher.stop) + + def invoke(self): + return v1.fence_closed_old_phase( + old_root=self.old, archived_root=self.archived, expected_file_sha256=self.content, + deny_sid=self.current_sid, receipt_path=self.receipt) + + def probes(self): + return sorted(path.name for directory in (self.parent, self.control) + for path in directory.glob(".fence-*")) + + def parent_verbs(self): + return [args[2] for args in self.calls if Path(args[1]) == self.parent] + + def assert_held_with_old_state_in_place(self, parent_dacl, *, verbs=("/deny", "/remove:d")): + self.assertEqual(self.sddl(self.parent), parent_dacl) + self.assertTrue(self.old.is_dir() and not self.archived.exists()) + self.assertEqual({path.name for path in self.old.iterdir()}, set(NAMES)) + self.assertEqual({name: bytes_digest((self.old / name).read_bytes()) for name in NAMES}, + self.content) + self.assertFalse(self.receipt.exists()) + self.assertEqual(self.parent_verbs(), list(verbs)) + self.assertFalse([args for args in self.calls if Path(args[1]) == self.control]) + self.assertEqual(self.probes(), []) + + def hold(self, fragment): + with patch.object(v1.os, "rename", side_effect=AssertionError("renamed before the gate")): + with self.assertRaises(ContractError) as caught: + self.invoke() + self.assertEqual(caught.exception.code, "PHASE_FENCE") + self.assertIn(fragment, str(caught.exception)) + return caught.exception + + def test_the_native_refusal_assertion_accepts_a_refusal_and_nothing_else(self): + refused = dict(parent=self.parent, old=self.old, archive=self.archived, + receipt=self.receipt, content=self.content) + self.model.token_bypasses_deny = True + assert_fence_refused_before_effects(self, self.invoke, **refused) + self.model.token_bypasses_deny = False + with self.assertRaises(AssertionError): + assert_fence_refused_before_effects(self, self.invoke, **refused) + + def test_the_native_refusal_assertion_rejects_a_refusal_for_another_reason(self): + self.model.token_bypasses_deny = True + + def another_reason(): + raise ContractError("PHASE_FENCE", "some other problem") + with self.assertRaises(AssertionError): + assert_fence_refused_before_effects( + self, another_reason, parent=self.parent, old=self.old, archive=self.archived, + receipt=self.receipt, content=self.content) + + def test_a_refused_token_fences_and_the_probe_leaves_nothing_behind(self): + result = self.invoke() + self.assertEqual(result["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assertEqual(self.probes(), []) + self.assertEqual(self.parent_verbs(), ["/deny"]) + self.assertFalse([args for args in self.calls if Path(args[1]) == self.control]) + + def test_a_token_the_deny_does_not_refuse_holds_and_removes_the_deny_it_added(self): + self.model.token_bypasses_deny = True + before = self.sddl(self.parent) + error = self.hold(v1.TOKEN_NOT_REFUSED) + self.assertEqual(error.detail, v1.TOKEN_NOT_REFUSED) + self.assert_held_with_old_state_in_place(before) + + def test_the_fence_can_be_published_once_the_token_is_refused_again(self): + self.model.token_bypasses_deny = True + before = self.sddl(self.parent) + with self.assertRaises(ContractError): + self.invoke() + self.model.token_bypasses_deny = False + self.assertEqual(self.invoke()["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assertNotEqual(self.sddl(self.parent), before) + + def test_replaying_a_completed_fence_with_an_unrefused_token_does_not_republish_it(self): + first = self.invoke() + raw, receipt = self.sddl(self.parent), self.receipt.read_bytes() + self.calls.clear() + self.model.token_bypasses_deny = True + self.hold(v1.TOKEN_NOT_REFUSED) + self.assertEqual((self.sddl(self.parent), self.receipt.read_bytes()), (raw, receipt)) + self.assertEqual(self.parent_verbs(), []) + self.assertEqual(self.probes(), []) + self.model.token_bypasses_deny = False + self.assertEqual(self.invoke(), first) + + def test_the_gate_samples_the_fenced_parent_not_the_control_directory(self): + # A control directory that refuses creation must not stand in for an old parent that does not. + self.model.icacls(["icacls.exe", str(self.control), "/deny", "*" + self.current_sid + ":(AD)"]) + self.calls.clear() + self.model.bypass_parents = {self.model.key(self.parent)} + before = self.sddl(self.parent) + self.hold(v1.TOKEN_NOT_REFUSED) + self.assert_held_with_old_state_in_place(before) + self.assertEqual(list(self.control.glob(".fence-*")), []) + + def test_a_control_directory_that_does_not_refuse_does_not_block_a_refusing_old_parent(self): + self.model.bypass_parents = {self.model.key(self.control)} + self.assertEqual(self.invoke()["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assertEqual(self.probes(), []) + + def test_a_deny_that_cannot_be_installed_holds_without_touching_the_old_state(self): + before = self.sddl(self.parent) + real = self.model.icacls + + def refuse(args): + self.calls.append(list(args)) + if Path(args[1]) == self.parent and args[2] == "/deny": + return subprocess.CompletedProcess(args, 5, "", "") + return real(args) + with patch.object(self.model, "icacls", refuse): + self.hold("could not deny old state recreation") + self.assert_held_with_old_state_in_place(before, verbs=("/deny",)) + + def test_a_refusal_that_is_not_access_denied_is_not_a_denial(self): + before = self.sddl(self.parent) + for error in (FileNotFoundError(2, "modeled missing"), OSError(5, "no windows error"), + PermissionError(13, "modeled permission error without a winerror")): + with self.subTest(error=type(error).__name__, errno=error.errno): + self.calls.clear() + with patch.object(v1.os, "mkdir", side_effect=error): + self.hold(v1.DENIAL_UNVERIFIED) + self.assert_held_with_old_state_in_place(before) + + def test_a_probe_directory_that_cannot_be_removed_is_reported_and_the_deny_is_still_removed(self): + self.model.token_bypasses_deny = True + before = self.sddl(self.parent) + with patch.object(v1.os, "rmdir", side_effect=PermissionError(13, "modeled")): + self.hold("the probe directory was not removed") + self.assertEqual(self.sddl(self.parent), before) + self.assertTrue(self.old.is_dir() and not self.archived.exists() and not self.receipt.exists()) + self.assertEqual(len(self.probes()), 1) + self.assertTrue(self.probes()[0].startswith(".fence-probe-")) + + def test_a_deny_that_cannot_be_removed_after_a_hold_says_so_and_stays_for_the_owner(self): + self.model.token_bypasses_deny = True + before = self.sddl(self.parent) + real = self.model.icacls + + def refuse_removal(args): + self.calls.append(list(args)) + if Path(args[1]) == self.parent and args[2] == "/remove:d": + return subprocess.CompletedProcess(args, 5, "", "") + return real(args) + with patch.object(self.model, "icacls", refuse_removal): + self.hold("the added deny could not be removed and verified") + self.assertNotEqual(self.sddl(self.parent), before) + self.assertTrue(self.old.is_dir() and not self.receipt.exists() and self.probes() == []) + + def test_a_removal_that_reports_success_but_leaves_the_dacl_different_is_not_trusted(self): + self.model.token_bypasses_deny = True + real = self.model.icacls + + def lie(args): + self.calls.append(list(args)) + if Path(args[1]) == self.parent and args[2] == "/remove:d": + return subprocess.CompletedProcess(args, 0, "", "") + return real(args) + with patch.object(self.model, "icacls", lie): + self.hold("the added deny could not be removed and verified") + + def test_a_removal_that_exits_nonzero_is_not_trusted_even_if_the_dacl_reads_back_equal(self): + self.model.token_bypasses_deny = True + before = self.sddl(self.parent) + real = self.model.icacls + + def exits_badly(args): + self.calls.append(list(args)) + done = real(args) + if Path(args[1]) == self.parent and args[2] == "/remove:d": + return subprocess.CompletedProcess(args, 1, "", "") + return done + with patch.object(self.model, "icacls", exits_badly): + error = self.hold("the added deny could not be removed and verified (icacls exit 1)") + self.assertEqual(self.sddl(self.parent), before) + self.assertEqual(error.code, "PHASE_FENCE") + + def test_a_removal_that_cannot_run_is_a_hold_and_never_an_escaped_exception(self): + self.model.token_bypasses_deny = True + real = self.model.icacls + for failure in (OSError("modeled: no icacls"), subprocess.TimeoutExpired("icacls", 30)): + with self.subTest(failure=type(failure).__name__): + def cannot_run(args): + if Path(args[1]) == self.parent and args[2] == "/remove:d": + raise failure + return real(args) + with patch.object(self.model, "icacls", cannot_run): + self.hold("the added deny could not be removed and verified (removal could not run)") + self.assertTrue(self.old.is_dir() and not self.receipt.exists()) + self.model.icacls(["icacls.exe", str(self.parent), "/remove:d", "*" + self.current_sid]) + + def test_an_existing_deny_is_not_removed_when_the_token_is_not_refused(self): + self.invoke() + self.assertEqual(self.old.exists(), False) + # An interrupted earlier call: the deny is there and the old state still is too. + self.archived.rename(self.old) + self.receipt.unlink() + self.calls.clear() + before = self.sddl(self.parent) + self.model.token_bypasses_deny = True + self.hold(v1.TOKEN_NOT_REFUSED) + self.assertEqual(self.sddl(self.parent), before) + self.assertEqual(self.parent_verbs(), []) + + def test_another_deny_for_the_fence_sid_holds_before_any_acl_change(self): + self.model.icacls(["icacls.exe", str(self.parent), "/deny", "*" + self.current_sid + ":(WD,AD)"]) + self.calls.clear() + before = self.sddl(self.parent) + self.hold("another deny for the fence SID") + self.assertEqual(self.sddl(self.parent), before) + self.assertEqual(self.calls, []) + self.assertTrue(self.old.is_dir() and self.probes() == []) + + def test_the_gate_runs_after_the_inventory_pins_and_the_deny_and_before_the_move(self): + order = [] + real_pins = v1._pinned_files + + def pins(*args): + order.append("pins") + return real_pins(*args) + + def gate(parent, sid, restore): + order.append("gate") + self.assertEqual(Path(parent), self.parent) + self.assertIn("(D;;LC;;;", self.sddl(self.parent)) + self.assertEqual(restore, before) + self.assertTrue(self.old.is_dir() and not self.archived.exists()) + raise ContractError("PHASE_FENCE", "stop at the gate") + before = self.sddl(self.parent) + with patch.object(v1, "_pinned_files", pins), patch.object(v1, "_require_denial_enforced", gate): + with self.assertRaises(ContractError): + self.invoke() + self.assertEqual(order, ["pins", "gate"]) + self.assertTrue(self.old.is_dir() and not self.archived.exists() and not self.receipt.exists()) + self.model.set(self.parent, *self.model.entry(self.parent)[:1], [ + ace for ace in self.model.entry(self.parent)[1] if not ace.startswith("(D;")]) + self.calls.clear() + with self.assertRaises(ContractError): + v1.fence_closed_old_phase( + old_root=self.old, archived_root=self.archived, deny_sid=self.current_sid, + expected_file_sha256={**self.content, "graph.sqlite3": "0" * 64}, + receipt_path=self.receipt) + self.assertEqual(self.probes(), []) + self.assertEqual(self.calls, []) + + +class ModeledLocalRid500FenceTests(ModeledV1FenceTests): + current_sid = LOCAL_RID500_SID + + def test_group_alias_fence_replays_keeps_raw_digest_and_validates(self): + self.invoke() + raw = self.sddl(self.parent) + self.assertIn("(D;;LC;;;LA)", raw) + self.assertNotIn(LOCAL_RID500_SID, raw) + self.assertEqual(self.invoke(), self.invoke()) + self.assert_phase_accepts(self.receipt) + + test_unrelated_parent_deny_is_preserved_and_not_the_barrier = None + test_wrong_hash_and_foreign_sid_hold_before_any_acl_change = None + test_interrupted_rename_retries_to_the_identical_receipt = None + test_a_local_administrator_deny_is_not_the_foreign_rid500_deny = None + + def test_foreign_rid500_deny_is_not_the_local_account_deny(self): + self.model.icacls(["icacls.exe", str(self.parent), "/deny", + "*" + FOREIGN_RID500_SID + ":(AD)"]) + self.invoke() + raw = self.sddl(self.parent) + self.assertEqual(raw.count("(D;;LC;;;LA)"), 1) + self.assertEqual(raw.count("(D;;LC;;;" + FOREIGN_RID500_SID + ")"), 1) + + +class ModeledV4FenceTests(ModeledCase): + def setUp(self): + super().setUp() + self.receipts = self.base / "receipts" + self.receipts.mkdir() + self.pin() + + def pin(self): + self.parent_pin, self.file_pins = self.acl_pins() + + def invoke(self, sid=None): + return v4.fence_closed_old_phase_guarded( + old_root=self.old, archived_root=self.archived, + expected_file_sha256=self.content, + source_stage_lock=self.parent / "source-stage.lock", + phase_stage_lock=self.parent / "phase-stage.lock", deny_sid=sid or self.current_sid, + expected_parent_dacl_sha256=self.parent_pin, + expected_file_dacl_sha256=self.file_pins, + barrier_receipt_path=self.receipts / "barrier.json", + fence_receipt_path=self.receipts / "fence.json") + + def test_group_alias_fence_denies_every_file_restores_and_validates(self): + original = {n: self.sddl(self.old / n) for n in NAMES} + denied = {} + real_rename = v4.os.rename + + def observe(source, destination): + denied.update({n: authority.dacl_has_ace(self.sddl(self.old / n), FILE_ACE, USERS_SID) + for n in NAMES}) + return real_rename(source, destination) + with patch.object(v4.os, "rename", side_effect=observe): + result = self.invoke() + self.assertEqual(result["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assertEqual(denied, {n: True for n in NAMES}) + self.assertIn("(D;;LC;;;BU)", self.sddl(self.parent)) + for name in NAMES: # exact original ACEs restored, no allow copy + self.assertEqual(self.sddl(self.archived / name), original[name]) + self.assertFalse(v4._file_deny(self.archived / name, USERS_SID)) + self.assertEqual(result, self.invoke()) + recorded = loads((self.receipts / "barrier.json").read_bytes()) + self.assertEqual(recorded["sid"], USERS_SID) + self.assert_phase_accepts(self.receipts / "fence.json") + + def test_crash_after_first_alias_deny_replays(self): + real = v4._icacls + calls = [] + + def first_then_crash(path, *args): + real(path, *args) + calls.append(path) + if len(calls) == 1: + raise RuntimeError("injected post-controller-deny crash") + with patch.object(v4, "_icacls", side_effect=first_then_crash): + with self.assertRaisesRegex(RuntimeError, "injected"): + self.invoke() + self.assertIn("(D;;DCLC;;;BU)", self.sddl(self.old / "controller.lock")) + self.assertEqual(self.invoke()["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assert_phase_accepts(self.receipts / "fence.json") + + def test_crash_after_rename_replays_archive_identity(self): + real = v4._icacls + + def fail_restore(path, *args): + if "/remove:d" in args: + raise RuntimeError("injected archive restore crash") + return real(path, *args) + with patch.object(v4, "_icacls", side_effect=fail_restore): + with self.assertRaisesRegex(RuntimeError, "injected"): + self.invoke() + self.assertTrue(self.archived.exists() and not self.old.exists()) + self.assertEqual(self.invoke()["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assert_phase_accepts(self.receipts / "fence.json") + + def test_preexisting_alias_file_deny_is_preserved_and_rejected(self): + path = self.old / "checkpoint.sqlite3" + self.model.icacls(["icacls.exe", str(path), "/deny", "*" + USERS_SID + ":(X)"]) + prior = self.sddl(path) + self.assertIn(";;;BU)", prior) + self.pin() + with self.assertRaisesRegex(ContractError, "existing owner deny"): + self.invoke() + self.assertEqual(self.sddl(path), prior) + self.assertFalse((self.receipts / "barrier.json").exists()) + + def test_preexisting_alias_parent_deny_is_preserved_and_rejected(self): + self.model.icacls(["icacls.exe", str(self.parent), "/deny", "*" + USERS_SID + ":(X)"]) + prior = self.sddl(self.parent) + self.pin() + with self.assertRaisesRegex(ContractError, "existing owner deny"): + self.invoke() + self.assertEqual(self.sddl(self.parent), prior) + + def test_deny_for_a_different_trustee_is_not_an_owner_deny(self): + path = self.old / "checkpoint.sqlite3" + self.model.icacls(["icacls.exe", str(path), "/deny", "*" + GUESTS_SID + ":(X)"]) + prior = self.sddl(path) + self.pin() + self.assertEqual(self.invoke()["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assertEqual(self.sddl(self.archived / path.name), prior) + + def test_local_administrator_deny_is_not_a_foreign_rid500_owner_deny(self): + path = self.old / "checkpoint.sqlite3" + self.model.icacls(["icacls.exe", str(path), "/deny", "*" + LOCAL_RID500_SID + ":(X)"]) + raw = self.sddl(path) + self.assertIn(";;;LA)", raw) + self.assertTrue(v4._owner_deny_present(raw, LOCAL_RID500_SID)) + for other in (FOREIGN_RID500_SID, NEAR_RID500_SID, PLAIN_ACCOUNT_SID, USERS_SID): + self.assertFalse(v4._owner_deny_present(raw, other), other) + self.assertFalse(v4._file_deny(path, other), other) + + def test_continuous_lock_wrapper_fences_under_the_alias(self): + body = v4.held_quiescence_receipt_body( + old_root=self.old, archived_root=self.archived, + expected_file_sha256=self.content, + source_stage_lock=self.parent / "source-stage.lock", + phase_stage_lock=self.parent / "phase-stage.lock", deny_sid=USERS_SID, + expected_parent_dacl_sha256=self.parent_pin, + expected_file_dacl_sha256=self.file_pins, + quiescence_authorization_sha256="b" * 64) + quiescence = bytes_digest((dumps(body) + "\n").encode()) + + def call(): + return v4.quiescence_then_fence_guarded( + old_root=self.old, archived_root=self.archived, + expected_file_sha256=self.content, + source_stage_lock=self.parent / "source-stage.lock", + phase_stage_lock=self.parent / "phase-stage.lock", deny_sid=USERS_SID, + expected_parent_dacl_sha256=self.parent_pin, + expected_file_dacl_sha256=self.file_pins, + quiescence_authorization_sha256="b" * 64, + fence_authorization_sha256="c" * 64, + quiescence_receipt_path=self.receipts / "quiescence.json", + expected_quiescence_receipt_sha256=quiescence, + barrier_receipt_path=self.receipts / "barrier.json", + fence_receipt_path=self.receipts / "fence.json", observer=None) + first = call() + self.assertFalse(first["reconciled"]) + replay = call() + self.assertTrue(replay["reconciled"]) + self.assertEqual(replay["receipt_sha256"], first["receipt_sha256"]) + self.assert_phase_accepts(self.receipts / "fence.json") + + +class StrictAceContractTests(unittest.TestCase): + """ACE count, order, flags, rights and non-trustee fields are compared exactly.""" + + def setUp(self): + model = WindowsModel(self, modules=()).start() + self.model = model + self.base = "D:AI(A;ID;FA;;;BA)(A;ID;0x1200a9;;;BU)(A;ID;FA;;;SY)" + + def test_same_acl_only_trustee_spelling_may_differ(self): + numeric = "D:AI(A;ID;FA;;;BA)(A;ID;0x1200a9;;;" + USERS_SID + ")(A;ID;FA;;;SY)" + self.assertTrue(v4._same_effective_dacl(self.base, numeric)) + self.assertTrue(v4._same_effective_dacl(self.base, "D:" + self.base[4:])) # AI is ignored + for label, other in ( + ("explicit allow copy", "D:AI(A;ID;FA;;;BA)(A;ID;0x1200a9;;;BU)(A;ID;FA;;;SY)(A;;0x1200a9;;;BU)"), + ("explicit allow copy in place", "D:AI(A;ID;FA;;;BA)(A;;0x1200a9;;;BU)(A;ID;FA;;;SY)"), + ("dropped ACE", "D:AI(A;ID;FA;;;BA)(A;ID;FA;;;SY)"), + ("reordered", "D:AI(A;ID;0x1200a9;;;BU)(A;ID;FA;;;BA)(A;ID;FA;;;SY)"), + ("rights", "D:AI(A;ID;FA;;;BA)(A;ID;0x1200a8;;;BU)(A;ID;FA;;;SY)"), + ("type", "D:AI(A;ID;FA;;;BA)(D;ID;0x1200a9;;;BU)(A;ID;FA;;;SY)"), + ("flags", "D:AI(A;ID;FA;;;BA)(A;CIID;0x1200a9;;;BU)(A;ID;FA;;;SY)"), + ("trustee", "D:AI(A;ID;FA;;;BA)(A;ID;0x1200a9;;;BG)(A;ID;FA;;;SY)"), + ("protection", "D:PAI(A;ID;FA;;;BA)(A;ID;0x1200a9;;;BU)(A;ID;FA;;;SY)")): + self.assertFalse(v4._same_effective_dacl(self.base, other), label) + + def test_admission_diff_allows_exactly_one_owner_deny(self): + deny_file = "(D;;DCLC;;;BU)" + added = "D:AI" + deny_file + self.base[4:] + self.assertTrue(v4._only_admission_dacl_added(self.base, added, USERS_SID)) + numeric = "D:AI(D;;DCLC;;;" + USERS_SID + ")" + self.base[4:] + self.assertTrue(v4._only_admission_dacl_added(self.base, numeric, USERS_SID)) + self.assertTrue(v4._only_admission_dacl_added(self.base, self.base, USERS_SID)) # before the deny + for label, current in ( + ("deny plus allow copy", added + "(A;;0x1200a9;;;BU)"), + ("deny with wider rights", "D:AI(D;;DCLCWD;;;BU)" + self.base[4:]), + ("deny with a flag", "D:AI(D;OI;DCLC;;;BU)" + self.base[4:]), + ("deny for another trustee", "D:AI(D;;DCLC;;;BG)" + self.base[4:]), + ("deny for foreign rid500", "D:AI(D;;DCLC;;;" + FOREIGN_RID500_SID + ")" + self.base[4:]), + ("two owner denies", "D:AI" + deny_file * 2 + self.base[4:]), + ("reordered base", "D:AI" + deny_file + "(A;ID;0x1200a9;;;BU)(A;ID;FA;;;BA)(A;ID;FA;;;SY)"), + ("dropped base ACE", "D:AI" + deny_file + "(A;ID;FA;;;BA)(A;ID;FA;;;SY)")): + self.assertFalse(v4._only_admission_dacl_added(self.base, current, USERS_SID), label) + + def test_parent_admission_diff_allows_exactly_one_owner_deny(self): + parent = "D:PAI(A;;FA;;;BA)(A;OICIID;FA;;;SY)" + self.assertTrue(v4._only_parent_admission_added( + parent, "D:PAI(D;;LC;;;BU)(A;;FA;;;BA)(A;OICIID;FA;;;SY)", USERS_SID)) + for label, current in ( + ("extra allow", "D:PAI(D;;LC;;;BU)(A;;FA;;;BA)(A;OICIID;FA;;;SY)(A;;LC;;;BU)"), + ("wrong rights", "D:PAI(D;;DCLC;;;BU)(A;;FA;;;BA)(A;OICIID;FA;;;SY)"), + ("inheritable deny", "D:PAI(D;OICI;LC;;;BU)(A;;FA;;;BA)(A;OICIID;FA;;;SY)"), + ("foreign rid500", "D:PAI(D;;LC;;;" + FOREIGN_RID500_SID + ")(A;;FA;;;BA)(A;OICIID;FA;;;SY)"), + ("protection dropped", "D:AI(D;;LC;;;BU)(A;;FA;;;BA)(A;OICIID;FA;;;SY)")): + self.assertFalse(v4._only_parent_admission_added(parent, current, USERS_SID), label) + + def test_owner_deny_matches_only_the_same_sid_in_any_spelling(self): + sids = {"users": USERS_SID, "local": LOCAL_RID500_SID, "foreign": FOREIGN_RID500_SID, + "near": NEAR_RID500_SID, "plain": PLAIN_ACCOUNT_SID, "guests": GUESTS_SID} + for ace_name, ace_sid in sids.items(): + for spelling in (self.model.spell(ace_sid), ace_sid): + parent = "D:AI(D;;LC;;;" + spelling + ")(A;OICIID;FA;;;SY)" + file = "D:AI(D;;DCLC;;;" + spelling + ")(A;ID;FA;;;SY)" + for query_name, query in sids.items(): + label = (ace_name, spelling, query_name) + same = ace_name == query_name + self.assertEqual(authority.dacl_has_ace(parent, PARENT_ACE, query), same, label) + self.assertEqual(authority.dacl_has_ace(file, FILE_ACE, query), same, label) + self.assertEqual(authority.dacl_has_deny_trustee(parent, query), same, label) + + def test_only_the_trustee_field_is_normalized(self): + self.assertTrue(authority.dacl_has_ace("D:(D;;LC;;;BU)", PARENT_ACE, USERS_SID)) + self.assertTrue(authority.dacl_has_ace("D:(D;;LC;;;" + USERS_SID + ")", + PARENT_ACE, USERS_SID)) + for text in ("D:(D;;LC;;;BG)", "D:(D;;DC;;;BU)", "D:(D;;LCWP;;;BU)", "D:(D;OI;LC;;;BU)", + "D:(D;CI;LC;;;BU)", "D:(A;;LC;;;BU)", "D:(OD;;LC;;;BU)"): + self.assertFalse(authority.dacl_has_ace(text, PARENT_ACE, USERS_SID), text) + self.assertFalse(authority.dacl_has_ace("D:(D;;LC;;;BU)", FILE_ACE, USERS_SID)) + self.assertEqual(authority.canonical_dacl_aces("D:AI(D;;LC;;;BU)(A;ID;FA;;;SY)"), + ["(D;;LC;;;BU)", "(A;ID;FA;;;SY)"]) + self.assertEqual(authority.canonical_dacl_aces( + "D:(A;ID;FA;;;SY)(D;;LC;;;" + USERS_SID + ")"), ["(A;ID;FA;;;SY)", "(D;;LC;;;BU)"]) + self.assertFalse(authority.dacl_has_deny_trustee("D:(A;;FA;;;BU)", USERS_SID)) + self.assertFalse(authority.dacl_has_deny_trustee("D:(D;;FX;;;BA)", USERS_SID)) + self.assertTrue(authority.dacl_has_deny_trustee("D:(D;;FX;;;BU)", USERS_SID)) + + def test_injected_trustee_text_is_not_translated_or_matched(self): + injected = "S-1-5-18)(A;;FA;;;WD" + self.assertEqual(authority.canonical_dacl_trustee(injected), injected) + self.assertFalse(authority.dacl_has_ace("D:(D;;LC;;;BU)(A;;FA;;;WD)", PARENT_ACE, injected)) + + +class ModeledReceiptValidatorTests(ReceiptValidatorScenarios, ModeledCase): + def setUp(self): + super().setUp() + self.sids = Sids(**MODEL_SIDS) + for name in NAMES: # the validator inspects the archived tree + body = ("validator:" + name).encode() + (self.old / name).write_bytes(body) + self.old.rename(self.archived) + self.files = {n: bytes_digest((self.archived / n).read_bytes()) for n in NAMES} + self.receipt = self.base / "fence.json" + + def raw(self): + return self.sddl(self.parent) + + def _icacls(self, verb, sid, spec=None): + args = ["icacls.exe", str(self.parent), verb, "*" + sid + (":" + spec if spec else "")] + self.model.icacls(args) + + def deny(self, sid, spec): + self._icacls("/deny", sid, spec) + + def grant(self, sid, spec): + self._icacls("/grant", sid, spec) + + def lift_deny(self, sid): + self._icacls("/remove:d", sid) + + def lift_grant(self, sid): + self._icacls("/remove:g", sid) + + +class ModeledPhaseOwnershipTests(PhaseOwnershipScenarios, ModeledCase): + def setUp(self): + super().setUp() + self.fence_receipt = self.base / "control" / "fence-receipt.json" + v1.fence_closed_old_phase(old_root=self.old, archived_root=self.archived, + expected_file_sha256=self.content, deny_sid=USERS_SID, + receipt_path=self.fence_receipt) + self.assertIn("(D;;LC;;;BU)", self.sddl(self.parent)) + + def remove_parent_deny(self): + self.model.icacls(["icacls.exe", str(self.parent), "/remove:d", "*" + USERS_SID]) + + def tamper_archive(self): + (self.archived / "graph.sqlite3").write_bytes(b"changed") + + + def test_second_opener_is_excluded_while_a_write_scope_is_held(self): + first = self.harness.authority(self.fence_receipt, activate=True) + second = self.harness.peer(first) + with first.write(): + with self.assertRaises(OSError): + with second.write(): + self.fail("two current writers entered the phase together") + with first.staging(): # re-entrant for the holder + pass + with second.write(): + pass + + +class ModeledV4PhaseOwnershipTests(ModeledPhaseOwnershipTests): + """The same phase scenarios over a fence sealed by the guarded v4 helper.""" + + def setUp(self): + ModeledCase.setUp(self) + self.receipts = self.base / "receipts" + self.receipts.mkdir() + parent_pin, file_pins = self.acl_pins() + v4.fence_closed_old_phase_guarded( + old_root=self.old, archived_root=self.archived, + expected_file_sha256=self.content, + source_stage_lock=self.parent / "source-stage.lock", + phase_stage_lock=self.parent / "phase-stage.lock", deny_sid=USERS_SID, + expected_parent_dacl_sha256=parent_pin, expected_file_dacl_sha256=file_pins, + barrier_receipt_path=self.receipts / "barrier.json", + fence_receipt_path=self.receipts / "fence.json") + self.fence_receipt = self.receipts / "fence.json" + + +class InstalledToolModelTests(unittest.TestCase): + """The installed-wheel checker's Windows branch, driven over the model (not native).""" + + @staticmethod + def load_tool(): + import importlib.util + path = Path(__file__).resolve().parents[1] / "tools" / "installed_phase_authority_check.py" + spec = importlib.util.spec_from_file_location("installed_phase_authority_check", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def start_model(self, tool, *, ignored_removals=lambda args: False): + model = WindowsModel(self, current_sid=PLAIN_ACCOUNT_SID, modules=()).start() + calls = [] + + def icacls(*args): + calls.append(list(args)) + if ignored_removals(args): + return subprocess.CompletedProcess(list(args), 0, "", "") + return model.icacls(list(args)) + utility, info = tool.load_fixture_utility() + for name, value in (("call", icacls), ("current_sid", lambda: PLAIN_ACCOUNT_SID), + ("descriptor_fixture", lambda: (utility, DescriptorModel(model), info))): + patcher = patch.object(tool, name, value) + patcher.start() + self.addCleanup(patcher.stop) + return model, utility, calls + + def test_every_windows_check_passes_against_the_model(self): + tool = self.load_tool() + model, utility, calls = self.start_model(tool) + report = tool.Report() + tool.native(report) + windows = report.data["windows_checks"] + self.assertEqual([name for name, passed in windows.items() if not passed], []) + self.assertEqual(len(windows), 35) + for name in ("alias_deny_accepted_write_and_staging", + "fenced_inactive_stages_but_rejects_current_write", + "rejected_write_leaves_ledger_unchanged", + "foreign_rid500_deny_rejected_for_local_account", + "machine_rid500_deny_rejected_for_foreign_domain_rid500", + "foreign_rid500_deny_installed_by_descriptor_exactly", + "foreign_rid500_deny_removed_between_cases", + "owned_dacl_cleanup_verified", + "fixture_utility_loaded_without_path_or_import_changes", + "owned_fixture_parent_stabilized_before_pins", + "all_trace_gc_modules_from_installed_package_after_fixtures"): + self.assertTrue(windows[name], name) + self.assertEqual(report.data["fixture_utility_sha256"], tool.sha(Path(utility.__file__).read_bytes())) + + def test_the_owned_parent_is_stabilized_exactly_once_before_any_pin_or_deny(self): + tool = self.load_tool() + model, utility, calls = self.start_model(tool) + seen = [] + real = tool.stabilize_parent + + def stabilize(base_dir, path, read): + seen.append((path.name, len(calls), model.sddl(path))) + return real(base_dir, path, read) + with patch.object(tool, "stabilize_parent", stabilize): + tool.native(tool.Report()) + self.assertEqual([(name, before) for name, before, _ in seen], [("old-app", 0)]) + + def test_the_foreign_domain_deny_never_reaches_icacls_and_is_removed_between_cases(self): + tool = self.load_tool() + model, utility, calls = self.start_model(tool) + foreign_aces = [] + original = DescriptorModel.apply_dacl + + def watch(self_, path, sddl): + original(self_, path, sddl) + foreign_aces.append("(D;;LC;;;%s)" % FOREIGN_RID500_SID in sddl) + with patch.object(DescriptorModel, "apply_dacl", watch): + tool.native(tool.Report()) + self.assertTrue(all(FOREIGN_RID500_SID not in " ".join(args) or args[2] != "/deny" for args in calls)) + self.assertEqual([args for args in calls if args[2] == "/deny" and any( + FOREIGN_RID500_SID in item for item in args)], []) + self.assertEqual(foreign_aces.count(True), 1) + self.assertGreaterEqual(foreign_aces.count(False), 1) + for flags, aces in model.dacls.values(): + self.assertFalse([ace for ace in aces if FOREIGN_RID500_SID in ace]) + + def test_a_foreign_deny_that_is_not_actually_removed_fails_the_run_instead_of_being_hidden(self): + tool = self.load_tool() + model, utility, calls = self.start_model(tool) + with patch.object(utility, "remove_descriptor_deny", lambda *args, **kwargs: True): + with self.assertRaises(RuntimeError): + tool.native(tool.Report()) + + def test_a_removal_that_reports_drift_fails_the_run(self): + tool = self.load_tool() + model, utility, calls = self.start_model(tool) + real = utility.remove_descriptor_deny + + def drifting(*args, **kwargs): + real(*args, **kwargs) + return False + with patch.object(utility, "remove_descriptor_deny", drifting): + with self.assertRaises(RuntimeError): + tool.native(tool.Report()) + + def test_an_owned_deny_that_survives_a_lift_between_cases_fails_the_run(self): + tool = self.load_tool() + self.start_model(tool, ignored_removals=lambda args: args[2] == "/remove:d" + and args[3] == "*" + USERS_SID) + with self.assertRaises(RuntimeError): + tool.native(tool.Report()) + + def test_a_failed_final_cleanup_is_reported_and_fails_the_run(self): + tool = self.load_tool() + armed = [] + model, utility, calls = self.start_model( + tool, ignored_removals=lambda args: bool(armed) and args[2] == "/remove:d") + real_check = tool.Report.check + + def check(self_, name, passed): + real_check(self_, name, passed) + if name == "absent_deny_rejected": + armed.append(name) + key = next(item for item in model.dacls if item.endswith("old-app")) + flags, aces = model.dacls[key] + model.dacls[key] = (flags, ["(D;;LC;;;BU)", *aces]) + with patch.object(tool.Report, "check", check): + report = tool.Report() + tool.native(report) + self.assertFalse(report.data["windows_checks"]["owned_dacl_cleanup_verified"]) + self.assertFalse(report.ok) + self.assertEqual([name for name, passed in report.data["windows_checks"].items() if not passed], + ["owned_dacl_cleanup_verified"]) + + def test_loading_the_fixture_utility_edits_neither_the_path_nor_the_installed_imports(self): + tool = self.load_tool() + code = ("import importlib.util, json, sys\n" + "spec = importlib.util.spec_from_file_location('check', sys.argv[1])\n" + "module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)\n" + "before = list(sys.path)\n" + "utility, info = module.load_fixture_utility()\n" + "print(json.dumps({'isolated': info['isolated'], 'path_same': before == sys.path,\n" + " 'modules': sorted(n for n in sys.modules if n.split('.')[0] in ('trace_gc','src','tools')),\n" + " 'registered': 'jraphyte_fixture_windows_native_acl' in sys.modules,\n" + " 'redact_is_lazy': 'tools' not in sys.modules}))\n") + done = subprocess.run([sys.executable, "-I", "-c", code, tool.__file__], capture_output=True, + text=True, timeout=60, cwd=tempfile.gettempdir()) + self.assertEqual(done.returncode, 0, done.stderr) + result = json.loads(done.stdout) + self.assertEqual(result, {"isolated": True, "path_same": True, "modules": [], + "registered": False, "redact_is_lazy": True}) + + def test_portable_checks_reject_a_wrong_declared_digest(self): + tool = self.load_tool() + report = tool.Report() + tool.portable(report, {"phase_authority.py": "0" * 64}) + checks = report.data["portable_checks"] + self.assertFalse(checks["installed_matches_reviewed_source:phase_authority.py"]) + self.assertTrue(checks["numeric_sid_fails_closed_without_windows"]) + self.assertFalse(report.ok) + self.assertEqual(report.data["windows_checks"], {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_paper_pilot_phase_trustees_v1_v4.py b/tests/test_paper_pilot_phase_trustees_v1_v4.py new file mode 100644 index 0000000..c824b97 --- /dev/null +++ b/tests/test_paper_pilot_phase_trustees_v1_v4.py @@ -0,0 +1,887 @@ +"""Native Windows trustee-alias regressions for the v1/v4 phase fences and the packaged +phase-authority receipt validator. + +Windows serializes some numeric SIDs in a DACL string as SDDL aliases (the Users group +S-1-5-32-545 is ``BU``; a machine's built-in Administrator, RID 500, is ``LA``). The v1 +fence, the v4 guarded fence and ``PhaseAuthority.verify_fence`` must recognize their own +deny ACE under either spelling while rejecting a different trustee (including a distinct +account/domain SID that shares RID 500), mask, ACE type, inheritance flag or digest. + +These classes need a real Windows host (real icacls, NTFS, msvcrt, SID translation) and +are skipped elsewhere; ``test_paper_pilot_phase_trustees_model.py`` runs the same logic +everywhere against a labelled model. Every fixture is an owned temporary directory; only +ACEs this test added beneath it are removed. Identities that are not the running account +are only ever *represented* in real DACLs; denied opens are asserted for the group alias +(the process token holds Users) and for the process's own identity, never for a simulated +one. Phase acceptance always goes through the real ``PhaseAuthority`` constructor, a +signed activation pointer, and both ``write()`` and ``staging()``. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest.mock import patch + +from tests.phase_trustee_harness import ( + FILE_ACE, FOREIGN_RID500_SID, GUESTS_SID, NAMES, PARENT_ACE, USERS_SID, PhaseHarness, + PhaseOwnershipScenarios, ReceiptValidatorScenarios, SUPPORTED_CONTEXT_ENV, Sids, + assert_fence_refused_before_effects, producer_context_supported, sha as _sha) +from tools import windows_fixture_acl, windows_native_acl +from trace_gc import phase_authority as authority +from trace_gc.canonical import bytes_digest, dumps, loads +from trace_gc.errors import ContractError +from trace_gc.phase_authority import directory_dacl_sddl + +WINDOWS = os.name == "nt" +TEMP_PREFIX = "jraphyte-phase-trustee-" + + +def _call(*args: str) -> subprocess.CompletedProcess: + return subprocess.run(list(args), capture_output=True, text=True, timeout=30) + + +def _icacls(*args: str) -> None: + result = _call("icacls.exe", *args) + assert result.returncode == 0, result.stderr or result.stdout + + +def _current_sid() -> str: + from src.paper_pilot_phase_cutover import _current_sid as current + return current() + + +def _account_prefix() -> str: + prefix = re.fullmatch(r"(S-1-5-21-\d+-\d+-\d+)-\d+", _current_sid()) + assert prefix is not None, "test requires a machine/domain account SID" + return prefix.group(1) + + +def _local_rid500_sid() -> str: + return _account_prefix() + "-500" + + +def _plain_account_sid() -> str: + current = _current_sid() + return current if not current.endswith("-500") else _account_prefix() + "-1001" + + +def _near_rid500_sid() -> str: + head, last = _account_prefix().rsplit("-", 1) + return head + "-" + str(int(last) + 1) + "-500" + + +def _native_sids() -> Sids: + return Sids(natural=_current_sid(), local500=_local_rid500_sid(), + plain=_plain_account_sid(), near500=_near_rid500_sid()) + + +class OwnedTree: + """A disposable tree whose every ACL edit is confined beneath ``base``.""" + + def __init__(self, test: unittest.TestCase): + self.test = test + self.temp = tempfile.TemporaryDirectory(prefix=TEMP_PREFIX) + self.hold = windows_native_acl.ChildStopHold() + test.addCleanup(self.cleanup) + self.base = Path(self.temp.name) + assert self.base.name.startswith(TEMP_PREFIX) + # The fabricated foreign SID is absent: icacls cannot resolve it, so the one test that + # installs it removes and verifies it through the native descriptor API itself. + self.sids = {USERS_SID, GUESTS_SID, _current_sid(), + _local_rid500_sid(), _plain_account_sid(), _near_rid500_sid()} + + def owned(self, path: Path) -> Path: + resolved = Path(path).resolve() + if not resolved.is_relative_to(self.base.resolve()): + raise AssertionError(f"refusing ACL change outside fixture: {path}") + return resolved + + def cleanup(self): + # A launched child that is not shown stopped keeps the whole tree: nothing is restored or + # removed beneath it, and the temporary directory's finalizer is detached. + self.hold.settle(self.temp, self._lift_acls) + self.test.assertFalse(self.base.exists()) + + def _lift_acls(self): + # Lift only the deny/allow ACEs this test could have added; the tree is + # then removed. The temporary directory's own ancestors are never edited. + for path in [self.base, *self.base.rglob("*")]: + if path.exists() and path != self.base: + for sid in self.sids: + _call("icacls.exe", str(self.owned(path)), "/remove:d", "*" + sid) + # The one explicit grant any case adds (Users, on its own parent). + _call("icacls.exe", str(self.owned(path)), "/remove:g", "*" + USERS_SID) + + +class _Identity: + """Pin the fence's current-owner seam to one SID, or leave it natural.""" + def __init__(self, module, sid): + self.module, self.sid = module, sid + + def __enter__(self): + self.patch = (patch.object(self.module, "_current_sid", return_value=self.sid) + if self.sid is not None else None) + if self.patch: + self.patch.start() + + def __exit__(self, *exc): + if self.patch: + self.patch.stop() + + +@unittest.skipUnless(WINDOWS, "Windows SID/SDDL contract") +class SharedTrusteeHelperTests(unittest.TestCase): + def test_real_windows_serialization_of_builtin_and_foreign_sids(self): + self.assertEqual(authority.canonical_dacl_trustee(USERS_SID), "BU") + self.assertEqual(authority.canonical_dacl_trustee("S-1-5-18"), "SY") + self.assertEqual(authority.canonical_dacl_trustee("S-1-1-0"), "WD") + self.assertEqual(authority.canonical_dacl_trustee("BU"), "BU") + self.assertEqual(authority.canonical_dacl_trustee("S-1-5-21-1-2-3-1001"), + "S-1-5-21-1-2-3-1001") + injected = "S-1-5-18)(A;;FA;;;WD" + self.assertEqual(authority.canonical_dacl_trustee(injected), injected) + self.assertEqual(authority.canonical_dacl_trustee(FOREIGN_RID500_SID), + FOREIGN_RID500_SID) + + def test_distinct_rid500_accounts_never_collapse(self): + local, foreign, plain = _local_rid500_sid(), FOREIGN_RID500_SID, _plain_account_sid() + near = _near_rid500_sid() + canonical = {sid: authority.canonical_dacl_trustee(sid) + for sid in (local, foreign, plain, near)} + self.assertEqual(len(set(canonical.values())), 4, canonical) + self.assertEqual(canonical[foreign], foreign) + self.assertEqual(canonical[near], near) + sids = {"local": local, "foreign": foreign, "plain": plain, "near": near} + for ace_name, ace_sid in sids.items(): + for spelling in (canonical[ace_sid], ace_sid): # alias form and numeric form + parent = "D:AI(D;;LC;;;" + spelling + ")(A;OICIID;FA;;;SY)" + file = "D:AI(D;;DCLC;;;" + spelling + ")(A;ID;FA;;;SY)" + for query_name, query_sid in sids.items(): + same = ace_name == query_name + label = (ace_name, spelling, query_name) + self.assertEqual(authority.dacl_has_ace(parent, PARENT_ACE, query_sid), + same, label) + self.assertEqual(authority.dacl_has_ace(file, FILE_ACE, query_sid), + same, label) + self.assertEqual(authority.dacl_has_deny_trustee(parent, query_sid), + same, label) + + def test_only_the_trustee_field_is_normalized(self): + self.assertTrue(authority.dacl_has_ace("D:(D;;LC;;;BU)", PARENT_ACE, USERS_SID)) + self.assertTrue(authority.dacl_has_ace("D:(D;;LC;;;" + USERS_SID + ")", + PARENT_ACE, USERS_SID)) + for text in ("D:(D;;LC;;;BG)", # different trustee + "D:(D;;DC;;;BU)", # different mask + "D:(D;;LCWP;;;BU)", # broader mask + "D:(D;OI;LC;;;BU)", # inheritance flag added + "D:(D;CI;LC;;;BU)", + "D:(A;;LC;;;BU)", # allow, not deny + "D:(OD;;LC;;;BU)"): # object deny + self.assertFalse(authority.dacl_has_ace(text, PARENT_ACE, USERS_SID), text) + self.assertFalse(authority.dacl_has_ace("D:(D;;LC;;;BU)", FILE_ACE, USERS_SID)) + # ACE order and extra ACEs stay visible to the caller's list comparison. + self.assertEqual(authority.canonical_dacl_aces("D:AI(D;;LC;;;BU)(A;ID;FA;;;SY)"), + ["(D;;LC;;;BU)", "(A;ID;FA;;;SY)"]) + self.assertEqual(authority.canonical_dacl_aces( + "D:(A;ID;FA;;;SY)(D;;LC;;;" + USERS_SID + ")"), + ["(A;ID;FA;;;SY)", "(D;;LC;;;BU)"]) + # An allow ACE or a deny of another trustee is not an owner deny. + self.assertFalse(authority.dacl_has_deny_trustee("D:(A;;FA;;;BU)", USERS_SID)) + self.assertFalse(authority.dacl_has_deny_trustee("D:(D;;FX;;;BA)", USERS_SID)) + self.assertTrue(authority.dacl_has_deny_trustee("D:(D;;FX;;;BU)", USERS_SID)) + + def test_matches_the_reviewed_v2_canonicalization(self): + from src import paper_pilot_phase_cutover_guarded_v2 as v2 + for sid in (USERS_SID, "S-1-5-18", "S-1-1-0", _local_rid500_sid(), + FOREIGN_RID500_SID, _plain_account_sid(), "BU", "LA"): + self.assertEqual(authority.canonical_dacl_trustee(sid), + v2._canonical_trustee(sid), sid) + + + +@unittest.skipUnless(WINDOWS, "Windows NTFS DACL contract") +class NativeReceiptValidatorTests(ReceiptValidatorScenarios, unittest.TestCase): + """The real PhaseAuthority against a real DACL that Windows spells.""" + + def setUp(self): + self.tree = OwnedTree(self) + base = self.tree.base + self.sids = _native_sids() + self.parent = base / "old-app" + self.old = self.parent / "state" + self.archived = base / "archive" / "state" + self.parent.mkdir() + self.archived.mkdir(parents=True) + # Explicit, protected and verified before any deny or pin (tools/windows_fixture_acl.py). + windows_fixture_acl.stabilize_owned_fixture( + base, [("parent", self.parent)], read=directory_dacl_sddl) + self.files = {} + for name in NAMES: + body = ("validator:" + name).encode() + (self.archived / name).write_bytes(body) + self.files[name] = bytes_digest(body) + self.receipt = base / "fence.json" + self.harness = PhaseHarness(self, base, self.old, self.archived) + + def raw(self): + return directory_dacl_sddl(self.parent) + + def deny(self, sid, spec): + if sid == FOREIGN_RID500_SID: + self.deny_without_account_lookup(sid, spec) + else: + _icacls(str(self.parent), "/deny", "*" + sid + ":" + spec) + + def deny_without_account_lookup(self, sid, spec): + """Install the deny for a SID no machine knows, and prove what Windows really stored.""" + self.assertEqual(spec, "(AD)") + native = windows_native_acl.WindowsNative() + before, owner_before = self.raw(), native.read_owner(self.parent) + installed = windows_native_acl.deny_by_descriptor(native, self.parent, sid) + self.addCleanup(self.remove_foreign_deny, native, sid) + self.assertTrue(installed["installed_exactly"], + windows_native_acl.components_message("installation", installed["components"])) + after = self.raw() + self.assertEqual(installed["before"], before) + self.assertEqual(installed["after"], after) + before_aces = windows_native_acl.split_dacl(before)[1] + after_aces = windows_native_acl.split_dacl(after)[1] + self.assertEqual(after_aces, ["(D;;LC;;;" + sid + ")"] + before_aces) + self.assertEqual(len(after_aces), len(before_aces) + 1) + kind, flags, mask, object_guid, inherited_guid, trustee = after_aces[0][1:-1].split(";") + self.assertEqual((kind, flags, mask, object_guid, inherited_guid, trustee), + ("D", "", "LC", "", "", sid)) + self.assertEqual(native.read_owner(self.parent), owner_before) + self.assertEqual(installed["owner_after"], owner_before) + self.assertNotEqual(_sha(before), _sha(after)) + self.assertTrue(authority.dacl_has_ace(after, PARENT_ACE, sid)) + self.assertFalse(authority.dacl_has_ace(after, PARENT_ACE, self.sids.local500)) + + def remove_foreign_deny(self, native, sid): + self.assertTrue(windows_native_acl.remove_descriptor_deny(native, self.parent, sid)) + self.assertNotIn("(D;;LC;;;" + sid + ")", self.raw()) + + def grant(self, sid, spec): + _icacls(str(self.parent), "/grant", "*" + sid + ":" + spec) + + def lift_deny(self, sid): + _icacls(str(self.parent), "/remove:d", "*" + sid) + + def lift_grant(self, sid): + _icacls(str(self.parent), "/remove:g", "*" + sid) + + +class _FenceCase(unittest.TestCase): + """Shared owned layout for the v1 and v4 entry points.""" + + module = None + + def setUp(self): + if not WINDOWS: + self.skipTest("Windows ACL contract") + self.tree = OwnedTree(self) + base = self.tree.base + self.parent = base / "old-app" + self.old = self.parent / "state" + self.archive = base / "archive" / "state" + self.receipts = base / "receipts" + self.old.mkdir(parents=True) + self.archive.parent.mkdir() + self.receipts.mkdir() + self.content = {} + for name in NAMES: + body = b"0" if name == "controller.lock" else ("owned:" + name).encode() + (self.old / name).write_bytes(body) + self.content[name] = bytes_digest(body) + for name in ("source-stage.lock", "phase-stage.lock"): + (self.parent / name).write_bytes(b"0") + # Explicit, protected and verified before the first pin, so no pin depends on what + # the temporary parent passes down (see tools/windows_fixture_acl.py). + windows_fixture_acl.stabilize_owned_fixture( + self.tree.base, [("parent", self.parent), ("state", self.old)] + + [("file:" + name, self.old / name) for name in sorted(NAMES)], + read=directory_dacl_sddl) + self._pin() + self._harness = None + + @property + def harness(self): + if self._harness is None: + self._harness = PhaseHarness(self, self.tree.base, self.old, self.archive) + return self._harness + + def _pin(self): + self.parent_acl_sha = _sha(directory_dacl_sddl(self.parent)) + self.file_acl_sha = {name: _sha(directory_dacl_sddl(self.old / name)) + for name in NAMES} + + def identity(self, sid): + return _Identity(self.module, sid) + + def archived_hashes(self): + return {name: bytes_digest((self.archive / name).read_bytes()) for name in NAMES} + + def assert_validator_accepts(self, receipt: Path): + instance = self.harness.authority(receipt, activate=True) + instance.check() + with instance.staging(): + pass + + def assert_old_path_recreation_denied(self): + windows_native_acl.require_recreation_denied(windows_native_acl.WindowsNative(), self.parent, self.old) + self.assertFalse(self.old.exists()) + + def supported_producer(self, sid) -> bool: + return producer_context_supported(self, self.tree.base, sid) + + def without_token_gate(self): + """The v1 token gate has its own native and modeled tests; this keeps it out of cases that + exercise receipts, replay and ownership, and is never used where recreation is asserted.""" + patcher = patch.object(self.module, "_require_denial_enforced") + patcher.start() + self.addCleanup(patcher.stop) + + def attempt_open(self, path: Path) -> subprocess.CompletedProcess: + return _call(sys.executable, "-c", + "from pathlib import Path; import sys; Path(sys.argv[1]).open('a+b').close()", + str(path)) + + +class V1FenceTrusteeTests(_FenceCase): + @classmethod + def setUpClass(cls): + from src import paper_pilot_phase_cutover as module + cls.module = module + + def setUp(self): + super().setUp() + self.archive = self.tree.base / "archive" / "historical-state" + self.receipt = self.tree.base / "control" / "fence-receipt.json" + self.receipt.parent.mkdir() + + def invoke(self, sid): + return self.module.fence_closed_old_phase( + old_root=self.old, archived_root=self.archive, + expected_file_sha256=self.content, deny_sid=sid, receipt_path=self.receipt) + + def assert_refused_before_effects(self, sid): + assert_fence_refused_before_effects( + self, lambda: self.invoke(sid), parent=self.parent, old=self.old, archive=self.archive, + receipt=self.receipt, content=self.content) + + def test_group_alias_identity_fences_replays_and_validates(self): + with self.identity(USERS_SID): + if not self.supported_producer(USERS_SID): + # Fail closed: the original token is not refused beneath the deny, so nothing + # was fenced; the supported-context run below holds the strict positive path. + self.assert_refused_before_effects(USERS_SID) + return + first = self.invoke(USERS_SID) + self.assertEqual(first["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + raw = directory_dacl_sddl(self.parent) + self.assertIn("(D;;LC;;;BU)", raw) + self.assertFalse(self.old.exists()) + self.assertEqual(self.archived_hashes(), self.content) + # Live denial: Users is in this process token, so recreation is refused. + self.assert_old_path_recreation_denied() + self.assertEqual(first, self.invoke(USERS_SID)) + # The receipt keeps the numeric SID and the raw (alias-spelled) digest. + from trace_gc.canonical import loads + body = loads(self.receipt.read_bytes()) + self.assertEqual(body["deny_sid"], USERS_SID) + self.assertEqual(body["old_parent_dacl_sha256"], bytes_digest(raw.encode("utf-8"))) + self.assert_validator_accepts(self.receipt) + + def test_crash_after_parent_deny_retries_to_the_identical_receipt(self): + self.without_token_gate() + with self.identity(USERS_SID): + with patch.object(self.module.os, "rename", + side_effect=PermissionError("injected rename failure")): + with self.assertRaises(PermissionError): + self.invoke(USERS_SID) + self.assertTrue(self.old.exists()) + self.assertFalse(self.archive.exists()) + self.assertFalse(self.receipt.exists()) + self.assertIn("(D;;LC;;;BU)", directory_dacl_sddl(self.parent)) + recovered = self.invoke(USERS_SID) + self.assertEqual(recovered["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assertEqual(recovered, self.invoke(USERS_SID)) + self.assertEqual(self.archived_hashes(), self.content) + self.assert_validator_accepts(self.receipt) + + def test_unrelated_parent_deny_is_preserved_and_does_not_satisfy_the_barrier(self): + self.without_token_gate() + _icacls(str(self.parent), "/deny", "*" + GUESTS_SID + ":(AD)") + with self.identity(USERS_SID): + self.assertNotIn("(D;;LC;;;BU)", directory_dacl_sddl(self.parent)) + self.assertEqual(self.invoke(USERS_SID)["status"], + "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + raw = directory_dacl_sddl(self.parent) + self.assertIn("(D;;LC;;;BG)", raw) + self.assertIn("(D;;LC;;;BU)", raw) + self.assert_validator_accepts(self.receipt) + + def test_machine_rid500_identity_fences_and_validates(self): + # Representation evidence for the LA spelling: the process is not this + # account, so no denied-open is claimed for it here. + self.without_token_gate() + sid = _local_rid500_sid() + with self.identity(sid): + result = self.invoke(sid) + self.assertEqual(result["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assertTrue(authority.dacl_has_ace( + directory_dacl_sddl(self.parent), PARENT_ACE, sid)) + self.assertEqual(result, self.invoke(sid)) + self.assert_validator_accepts(self.receipt) + + def test_natural_runner_identity_fences_with_live_denied_recreation(self): + sid = _current_sid() # LA on a hosted RID-500 runner + if not self.supported_producer(sid): + self.assert_refused_before_effects(sid) + return + result = self.invoke(sid) + self.assertEqual(result["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assertTrue(authority.dacl_has_ace( + directory_dacl_sddl(self.parent), PARENT_ACE, sid)) + self.assert_old_path_recreation_denied() + self.assertEqual(result, self.invoke(sid)) + self.assertEqual(self.archived_hashes(), self.content) + self.assert_validator_accepts(self.receipt) + + def test_wrong_hash_still_holds_before_any_acl_change(self): + before = directory_dacl_sddl(self.parent) + with self.identity(USERS_SID): + with self.assertRaises(ContractError): + self.module.fence_closed_old_phase( + old_root=self.old, archived_root=self.archive, + expected_file_sha256={**self.content, "graph.sqlite3": "0" * 64}, + deny_sid=USERS_SID, receipt_path=self.receipt) + self.assertEqual(directory_dacl_sddl(self.parent), before) + self.assertTrue(self.old.exists()) + self.assertFalse(self.receipt.exists()) + + def test_sid_that_is_not_the_current_owner_is_rejected(self): + before = directory_dacl_sddl(self.parent) + with self.identity(USERS_SID): + with self.assertRaises(ContractError): + self.invoke(GUESTS_SID) + self.assertEqual(directory_dacl_sddl(self.parent), before) + self.assertTrue(self.old.exists()) + + STRICT_PRODUCER_CASES = ( + "tests.test_paper_pilot_phase_trustees_v1_v4.V1FenceTrusteeTests" + ".test_group_alias_identity_fences_replays_and_validates", + "tests.test_paper_pilot_phase_trustees_v1_v4.V1FenceTrusteeTests" + ".test_natural_runner_identity_fences_with_live_denied_recreation", + "tests.test_paper_pilot_phase_fence.PhaseNamespaceFenceTests" + ".test_exact_replay_after_closed_directory_move") + STRICT_RUNNER = ( + "import json, sys, unittest\n" + "from tools import windows_native_acl\n" + "held = windows_native_acl.WindowsNative().privileges()\n" + "with open(sys.argv[1] + '.token.json', 'w', encoding='utf-8') as token:\n" + " json.dump({'enabled': sorted(n for n, e in held if e), 'disabled': sorted(n for n, e in held if not e)}, token)\n" + "names = sys.argv[2:]\n" + "suite = unittest.defaultTestLoader.loadTestsFromNames(names)\n" + "with open(sys.argv[1], 'w', encoding='utf-8') as out:\n" + " result = unittest.TextTestRunner(stream=out, verbosity=0).run(suite)\n" + "sys.exit(0 if result.wasSuccessful() and result.testsRun == len(names) else 1)\n") + + def test_the_strict_positive_producer_path_runs_in_a_restricted_primary_token_child(self): + """The original-token acceptance gate, forced: the child's ORIGINAL process token is a copy + of this one with every privilege removed, and the supported-context branch is required, so + the child fails unless the production producer fences and recreation is refused. Nothing + here is accepted on a thread impersonating a stripped token, and a context that is not + supported fails this test rather than passing the refusal branch.""" + native = windows_native_acl.WindowsNative() + out = self.tree.base / "strict-producer-child.txt" + with patch.dict(os.environ, {SUPPORTED_CONTEXT_ENV: "required"}): + launch = self.tree.hold.launch( + native, [sys.executable, "-c", self.STRICT_RUNNER, str(out), *self.STRICT_PRODUCER_CASES], + Path.cwd(), timeout=900) + report = out.read_text(encoding="utf-8") if out.exists() else "" + self.assertEqual(windows_native_acl.launch_defects(launch), [], windows_native_acl.redact(str(launch))) + self.assertEqual( + {key: launch[key] for key in ("launched", "completed", "exit_code", "timed_out", "cleanup_verified")}, + {"launched": True, "completed": True, "exit_code": 0, "timed_out": False, "cleanup_verified": True}, + "restricted-primary child did not accept: " + windows_native_acl.redact( + str(launch) + " " + report[-6000:])) + self.assertIn("Ran 3 tests", report) + self.assertTrue(report.rstrip().endswith("OK"), report[-500:]) + identity = json.loads(Path(str(out) + ".token.json").read_text(encoding="utf-8")) + self.assertLessEqual(set(identity["enabled"]), {"SeChangeNotifyPrivilege"}, identity) + self.assertTrue(identity["disabled"] or identity["enabled"], identity) + + +class V4FenceTrusteeTests(_FenceCase): + @classmethod + def setUpClass(cls): + from src import paper_pilot_phase_cutover_guarded_v4 as module + cls.module = module + + def setUp(self): + super().setUp() + self.receipts = self.tree.base / "receipts" + + def invoke(self, sid): + return self.module.fence_closed_old_phase_guarded( + old_root=self.old, archived_root=self.archive, + expected_file_sha256=self.content, + source_stage_lock=self.parent / "source-stage.lock", + phase_stage_lock=self.parent / "phase-stage.lock", deny_sid=sid, + expected_parent_dacl_sha256=self.parent_acl_sha, + expected_file_dacl_sha256=self.file_acl_sha, + barrier_receipt_path=self.receipts / "barrier.json", + fence_receipt_path=self.receipts / "fence.json") + + def test_group_alias_identity_fences_with_live_denials_replays_and_validates(self): + seen = {} + actual_rename = self.module.os.rename + + def observe_then_rename(source, destination): + for name in NAMES: + seen[name] = self.attempt_open(self.old / name).returncode != 0 + return actual_rename(source, destination) + with self.identity(USERS_SID): + with patch.object(self.module.os, "rename", side_effect=observe_then_rename): + result = self.invoke(USERS_SID) + self.assertEqual(result["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assertEqual(seen, {name: True for name in NAMES}, seen) + raw = directory_dacl_sddl(self.parent) + self.assertIn("(D;;LC;;;BU)", raw) + self.assertFalse(self.old.exists()) + self.assertEqual(self.archived_hashes(), self.content) + for name in NAMES: # per-file denies are restored away in the archive + self.assertFalse(self.module._file_deny(self.archive / name, USERS_SID)) + self.assertEqual(result, self.invoke(USERS_SID)) + from trace_gc.canonical import loads + recorded = loads((self.receipts / "barrier.json").read_bytes()) + self.assertEqual(recorded["sid"], USERS_SID) + self.assertEqual(loads((self.receipts / "fence.json").read_bytes()) + ["old_parent_dacl_sha256"], bytes_digest(raw.encode("utf-8"))) + self.assert_validator_accepts(self.receipts / "fence.json") + + def test_crash_after_first_alias_deny_replays(self): + actual = self.module._icacls + calls = {"count": 0} + + def first_deny_then_crash(path, *args): + actual(path, *args) + calls["count"] += 1 + if calls["count"] == 1: + raise RuntimeError("injected post-controller-deny crash") + with self.identity(USERS_SID): + with patch.object(self.module, "_icacls", side_effect=first_deny_then_crash): + with self.assertRaisesRegex(RuntimeError, "injected"): + self.invoke(USERS_SID) + self.assertIn("(D;;DCLC;;;BU)", + directory_dacl_sddl(self.old / "controller.lock")) + self.assertTrue(self.module._file_deny(self.old / "controller.lock", USERS_SID)) + self.assertNotEqual(self.attempt_open(self.old / "controller.lock").returncode, 0) + self.assertEqual(self.invoke(USERS_SID)["status"], + "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assert_validator_accepts(self.receipts / "fence.json") + + def test_failed_rename_retains_alias_denial_then_same_request_recovers(self): + with self.identity(USERS_SID): + with patch.object(self.module.os, "rename", + side_effect=PermissionError("injected rename failure")): + with self.assertRaises(PermissionError): + self.invoke(USERS_SID) + self.assertTrue(self.old.exists()) + self.assertFalse(self.archive.exists()) + for name in NAMES: + self.assertTrue(self.module._file_deny(self.old / name, USERS_SID)) + self.assertEqual(self.invoke(USERS_SID)["status"], + "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assertEqual(self.archived_hashes(), self.content) + + def test_crash_after_rename_replays_archive_identity(self): + actual = self.module._icacls + + def fail_first_archive_restore(path, *args): + if "/remove:d" in args: + raise RuntimeError("injected archive restore crash") + return actual(path, *args) + with self.identity(USERS_SID): + with patch.object(self.module, "_icacls", side_effect=fail_first_archive_restore): + with self.assertRaisesRegex(RuntimeError, "injected"): + self.invoke(USERS_SID) + self.assertFalse(self.old.exists()) + self.assertTrue(self.archive.exists()) + self.assertEqual(self.invoke(USERS_SID)["status"], + "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assertEqual(self.archived_hashes(), self.content) + self.assert_validator_accepts(self.receipts / "fence.json") + + def test_preexisting_alias_file_deny_is_preserved_and_rejected(self): + path = self.old / "checkpoint.sqlite3" + _icacls(str(path), "/deny", "*" + USERS_SID + ":(X)") + prior = directory_dacl_sddl(path) + self.assertIn(";;;BU)", prior) + self.file_acl_sha[path.name] = _sha(prior) + with self.identity(USERS_SID): + with self.assertRaisesRegex(Exception, "existing owner deny"): + self.invoke(USERS_SID) + self.assertEqual(directory_dacl_sddl(path), prior) + self.assertFalse((self.receipts / "barrier.json").exists()) + self.assertTrue(self.old.exists()) + + def test_preexisting_alias_parent_deny_is_preserved_and_rejected(self): + _icacls(str(self.parent), "/deny", "*" + USERS_SID + ":(X)") + prior = directory_dacl_sddl(self.parent) + self.assertIn(";;;BU)", prior) + # Changing the parent ACL re-propagates inheritance to its children, so + # re-pin them: only the owner deny may reject. + self._pin() + with self.identity(USERS_SID): + with self.assertRaisesRegex(Exception, "existing owner deny"): + self.invoke(USERS_SID) + self.assertEqual(directory_dacl_sddl(self.parent), prior) + self.assertFalse((self.receipts / "barrier.json").exists()) + + def test_preexisting_deny_for_a_different_trustee_is_not_an_owner_deny(self): + path = self.old / "checkpoint.sqlite3" + _icacls(str(path), "/deny", "*" + GUESTS_SID + ":(X)") + prior = directory_dacl_sddl(path) + self.assertIn(";;;BG)", prior) + self.file_acl_sha[path.name] = _sha(prior) + with self.identity(USERS_SID): + self.assertEqual(self.invoke(USERS_SID)["status"], + "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + archived = directory_dacl_sddl(self.archive / path.name) + self.assertTrue(self.module._same_effective_dacl(prior, archived)) + self.assertIn(";;;BG)", archived) + + def test_machine_rid500_identity_fences_and_validates(self): + # Representation evidence for the LA spelling (see the V1 case). + sid = _local_rid500_sid() + with self.identity(sid): + result = self.invoke(sid) + self.assertEqual(result["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assertTrue(authority.dacl_has_ace( + directory_dacl_sddl(self.parent), PARENT_ACE, sid)) + self.assertEqual(result, self.invoke(sid)) + self.assert_validator_accepts(self.receipts / "fence.json") + + def test_natural_runner_identity_fences_with_live_denied_opens(self): + sid = _current_sid() # LA on a hosted RID-500 runner + seen = {} + actual_rename = self.module.os.rename + + def observe_then_rename(source, destination): + for name in NAMES: + seen[name] = self.attempt_open(self.old / name).returncode != 0 + return actual_rename(source, destination) + with patch.object(self.module.os, "rename", side_effect=observe_then_rename): + result = self.invoke(sid) + self.assertEqual(seen, {name: True for name in NAMES}, seen) + self.assertEqual(result["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assertEqual(result, self.invoke(sid)) + self.assert_validator_accepts(self.receipts / "fence.json") + + def test_distinct_rid500_sid_is_not_the_owner_deny(self): + # A deny for the local RID-500 account is not a deny for the plain runner + # account, and neither is accepted as the foreign RID-500 domain's deny. + local = _local_rid500_sid() + path = self.old / "checkpoint.sqlite3" + _icacls(str(path), "/deny", "*" + local + ":(X)") + raw = directory_dacl_sddl(path) + self.assertTrue(self.module._owner_deny_present(raw, local), raw) + self.assertFalse(self.module._owner_deny_present(raw, FOREIGN_RID500_SID), raw) + plain = _plain_account_sid() + if plain != local: + self.assertFalse(self.module._owner_deny_present(raw, plain), raw) + self.assertFalse(self.module._file_deny(path, FOREIGN_RID500_SID)) + + +class V4QuiescenceTrusteeTests(_FenceCase): + @classmethod + def setUpClass(cls): + from src import paper_pilot_phase_cutover_guarded_v4 as module + cls.module = module + + def setUp(self): + super().setUp() + self.auth = {"quiescence": "b" * 64, "fence": "c" * 64} + self.quiescence_sha = None + + def _prepare(self, sid): + body = self.module.held_quiescence_receipt_body( + old_root=self.old, archived_root=self.archive, + expected_file_sha256=self.content, + source_stage_lock=self.parent / "source-stage.lock", + phase_stage_lock=self.parent / "phase-stage.lock", deny_sid=sid, + expected_parent_dacl_sha256=self.parent_acl_sha, + expected_file_dacl_sha256=self.file_acl_sha, + quiescence_authorization_sha256=self.auth["quiescence"]) + self.quiescence_sha = bytes_digest((dumps(body) + "\n").encode()) + + def call(self, sid, observer=None): + return self.module.quiescence_then_fence_guarded( + old_root=self.old, archived_root=self.archive, + expected_file_sha256=self.content, + source_stage_lock=self.parent / "source-stage.lock", + phase_stage_lock=self.parent / "phase-stage.lock", deny_sid=sid, + expected_parent_dacl_sha256=self.parent_acl_sha, + expected_file_dacl_sha256=self.file_acl_sha, + quiescence_authorization_sha256=self.auth["quiescence"], + fence_authorization_sha256=self.auth["fence"], + quiescence_receipt_path=self.receipts / "quiescence.json", + expected_quiescence_receipt_sha256=self.quiescence_sha, + barrier_receipt_path=self.receipts / "barrier.json", + fence_receipt_path=self.receipts / "fence.json", observer=observer) + + def test_continuous_lock_fence_under_alias_identity_and_alias_replay(self): + with self.identity(USERS_SID): + self._prepare(USERS_SID) + result = self.call(USERS_SID) + self.assertEqual(result["status"], "OLD_PATH_FENCED_NEW_PHASE_ALLOWED") + self.assertFalse(result["reconciled"]) + self.assertEqual(bytes_digest((self.receipts / "quiescence.json").read_bytes()), + self.quiescence_sha) + replay = self.call(USERS_SID) + self.assertTrue(replay["reconciled"]) + self.assertEqual(replay["receipt_sha256"], result["receipt_sha256"]) + self.assertIn("(D;;LC;;;BU)", directory_dacl_sddl(self.parent)) + self.assertEqual(self.archived_hashes(), self.content) + self.assert_validator_accepts(self.receipts / "fence.json") + + def test_partial_controller_deny_replay_passes_none_handle_to_observer(self): + from contextlib import contextmanager + + @contextmanager + def lease(): + yield + original = self.module._icacls + + def crash_after_controller_deny(path, *args): + result = original(path, *args) + if Path(path) == self.old / "controller.lock" and args[:1] == ("/deny",): + raise RuntimeError("injected first deny loss") + return result + with self.identity(USERS_SID): + self._prepare(USERS_SID) + with patch.object(self.module, "_icacls", side_effect=crash_after_controller_deny): + with self.assertRaisesRegex(RuntimeError, "first deny loss"): + self.call(USERS_SID, observer=lambda **kwargs: lease()) + observed = [] + + def replay_observer(**kwargs): + observed.append(kwargs["controller_handle"]) + self.assertEqual(set(kwargs["writable_database_names"]), + set(NAMES) - {"controller.lock"}) + return lease() + self.assertTrue(self.call(USERS_SID, observer=replay_observer)["reconciled"]) + self.assertEqual(observed, [None]) + self.assert_validator_accepts(self.receipts / "fence.json") + + def test_partial_database_deny_replay_excludes_only_the_denied_database(self): + from contextlib import contextmanager + + @contextmanager + def lease(): + yield + target = self.old / "checkpoint.sqlite3" + original = self.module._icacls + + def crash_after_database_deny(path, *args): + result = original(path, *args) + if Path(path) == target and args[:1] == ("/deny",): + raise RuntimeError("injected database deny loss") + return result + with self.identity(USERS_SID): + self._prepare(USERS_SID) + with patch.object(self.module, "_icacls", side_effect=crash_after_database_deny): + with self.assertRaisesRegex(RuntimeError, "database deny loss"): + self.call(USERS_SID, observer=lambda **kwargs: lease()) + observed = [] + + def replay_observer(**kwargs): + observed.append(kwargs["writable_database_names"]) + return lease() + self.assertTrue(self.call(USERS_SID, observer=replay_observer)["reconciled"]) + self.assertEqual(set(observed[0]), set(NAMES) - {"controller.lock", target.name}) + + + +class _Ownership(PhaseOwnershipScenarios): + """Real PhaseAuthority + RunBudget over a fence created by the real helper (mixin).""" + + def setUp(self): + super().setUp() + self.archive = self.tree.base / "archive" / "historical-state" + self.fence_receipt = self.make_fence() + + def remove_parent_deny(self): + _icacls(str(self.parent), "/remove:d", "*" + USERS_SID) + + def tamper_archive(self): + (self.archive / "graph.sqlite3").write_bytes(b"changed") + + +class V1PhaseOwnershipTests(_Ownership, _FenceCase): + def make_fence(self): + from src import paper_pilot_phase_cutover as v1 + self.module = v1 + self.without_token_gate() + control = self.tree.base / "control" + control.mkdir() + receipt = control / "fence-receipt.json" + with self.identity(USERS_SID): + v1.fence_closed_old_phase(old_root=self.old, archived_root=self.archive, + expected_file_sha256=self.content, deny_sid=USERS_SID, + receipt_path=receipt) + self.assertIn("(D;;LC;;;BU)", directory_dacl_sddl(self.parent)) + return receipt + + def test_second_opener_waits_for_the_held_write_scope_then_enters(self): + first = self.harness.authority(self.fence_receipt, activate=True) + second = self.harness.peer(first) + entered, finished = threading.Event(), threading.Event() + failure = [] + + def contender(): + try: + with second.write(): + entered.set() + except BaseException as error: # reported on the main thread + failure.append(error) + finally: + finished.set() + with first.write(): + worker = threading.Thread(target=contender) + worker.start() + self.assertFalse(entered.wait(1.5), "a second writer entered the held phase") + self.assertTrue(finished.wait(20), "waiting writer never completed") + worker.join() + self.assertEqual(failure, []) + self.assertTrue(entered.is_set()) + + +class V4PhaseOwnershipTests(_Ownership, _FenceCase): + def make_fence(self): + from src import paper_pilot_phase_cutover_guarded_v4 as v4 + self.module = v4 + with self.identity(USERS_SID): + v4.fence_closed_old_phase_guarded( + old_root=self.old, archived_root=self.archive, + expected_file_sha256=self.content, + source_stage_lock=self.parent / "source-stage.lock", + phase_stage_lock=self.parent / "phase-stage.lock", deny_sid=USERS_SID, + expected_parent_dacl_sha256=self.parent_acl_sha, + expected_file_dacl_sha256=self.file_acl_sha, + barrier_receipt_path=self.receipts / "barrier.json", + fence_receipt_path=self.receipts / "fence.json") + return self.receipts / "fence.json" + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sdist_discovery.py b/tests/test_sdist_discovery.py new file mode 100644 index 0000000..9bac8c6 --- /dev/null +++ b/tests/test_sdist_discovery.py @@ -0,0 +1,249 @@ +"""The sdist/wheel discovery that CI uses must work for every supported build-backend spelling. + +setuptools 68 (the declared floor) writes ``trace-gc-0.4.0.tar.gz`` with root ``trace-gc-0.4.0``; +setuptools 84 writes ``trace_gc-0.4.0.tar.gz``. These tests run the helper on archives of each +spelling, require missing and ambiguous candidates to fail, and run the exact discovery commands +that the workflow contains. The real-backend archive is exercised in ``test_sdist_phase_distribution``. +""" +from __future__ import annotations + +import io +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import sys +import tarfile +import tempfile +import unittest + +from tools import sdist_discovery as discovery + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "test.yml" +HELPER = ROOT / "tools" / "sdist_discovery.py" +BUILD_STEP = "Build the sdist and the wheel from it" +PKG_INFO = "Metadata-Version: 2.1\nName: %s\nVersion: 0.4.0\n" + + +def add_file(archive: tarfile.TarFile, name: str, data: bytes = b"") -> None: + info = tarfile.TarInfo(name) + info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + + +def make_sdist(dist: Path, archive_name: str, root: str, *, project: str = "trace_gc", pkg_info: bool = True, + extra=()) -> Path: + path = dist / archive_name + with tarfile.open(path, "w:gz") as archive: + if pkg_info: + add_file(archive, root + "/PKG-INFO", (PKG_INFO % project).encode()) + add_file(archive, root + "/pyproject.toml", b"[project]\n") + add_file(archive, root + "/tests/test_example.py", b"") + for name, data in extra: + add_file(archive, name, data) + return path + + +class DiscoveryCase(unittest.TestCase): + def setUp(self): + self.work = Path(tempfile.mkdtemp(prefix="sdist-discovery-")) + self.addCleanup(shutil.rmtree, self.work, ignore_errors=True) + self.dist = self.work / "dist" + self.dist.mkdir() + self.into = self.work / "sdist" + + def cli(self, *arguments): + return subprocess.run([sys.executable, str(HELPER), *arguments], cwd=ROOT, capture_output=True, + timeout=120) + + +class SpellingTests(DiscoveryCase): + VARIANTS = ( + ("setuptools 68", "trace-gc-0.4.0.tar.gz", "trace-gc-0.4.0"), + ("setuptools 84", "trace_gc-0.4.0.tar.gz", "trace_gc-0.4.0"), + ("mixed", "trace_gc-0.4.0.tar.gz", "trace-gc-0.4.0"), + ) + + def test_the_actual_archive_and_actual_extracted_root_are_found_for_each_spelling(self): + for label, archive_name, root in self.VARIANTS: + with self.subTest(label): + for child in self.dist.iterdir(): + child.unlink() + shutil.rmtree(self.into, ignore_errors=True) + make_sdist(self.dist, archive_name, root) + found = discovery.extract_sdist(self.dist, self.into, "trace-gc") + self.assertEqual(found, self.into / root) + self.assertTrue((found / "PKG-INFO").is_file()) + self.assertTrue((found / "tests" / "test_example.py").is_file()) + + def test_the_command_line_prints_only_the_resolved_root(self): + for label, archive_name, root in self.VARIANTS: + with self.subTest(label): + for child in self.dist.iterdir(): + child.unlink() + shutil.rmtree(self.into, ignore_errors=True) + make_sdist(self.dist, archive_name, root) + done = self.cli("extract", "--project", "trace-gc", "--dist", str(self.dist), "--into", str(self.into)) + self.assertEqual(done.returncode, 0, done.stderr) + self.assertEqual(done.stdout, (str((self.into / root).resolve()) + "\n").encode()) + self.assertNotIn(b"\r", done.stdout) + + def test_a_wheel_is_found_by_its_actual_name(self): + wheel = self.dist / "trace_gc-0.4.0-py3-none-any.whl" + wheel.write_bytes(b"PK") + self.assertEqual(discovery.find_wheel(self.dist, "trace-gc"), wheel) + done = self.cli("wheel", "--project", "trace-gc", "--dist", str(self.dist)) + self.assertEqual((done.returncode, done.stdout), (0, (str(wheel.resolve()) + "\n").encode())) + + +class RejectionTests(DiscoveryCase): + def assert_rejected(self, *fragments): + with self.assertRaises(discovery.DiscoveryError) as caught: + discovery.extract_sdist(self.dist, self.into, "trace-gc") + for fragment in fragments: + self.assertIn(fragment, str(caught.exception)) + + def test_a_missing_archive_fails(self): + self.assert_rejected("no .tar.gz file") + done = self.cli("extract", "--project", "trace-gc", "--dist", str(self.dist), "--into", str(self.into)) + self.assertEqual((done.returncode, done.stdout), (1, b"")) + self.assertIn(b"no .tar.gz file", done.stderr) + + def test_a_missing_dist_directory_fails(self): + shutil.rmtree(self.dist) + self.assert_rejected("not a directory") + + def test_two_archives_are_ambiguous_whatever_their_spelling(self): + make_sdist(self.dist, "trace-gc-0.4.0.tar.gz", "trace-gc-0.4.0") + make_sdist(self.dist, "trace_gc-0.4.0.tar.gz", "trace_gc-0.4.0") + self.assert_rejected("ambiguous", "trace-gc-0.4.0.tar.gz", "trace_gc-0.4.0.tar.gz") + self.assertFalse(self.into.exists()) + done = self.cli("extract", "--project", "trace-gc", "--dist", str(self.dist), "--into", str(self.into)) + self.assertEqual((done.returncode, done.stdout), (1, b"")) + + def test_an_archive_of_another_project_fails(self): + make_sdist(self.dist, "other_project-0.4.0.tar.gz", "other_project-0.4.0") + self.assert_rejected("is not a distribution of trace-gc") + + def test_pkg_info_naming_another_project_fails(self): + make_sdist(self.dist, "trace_gc-0.4.0.tar.gz", "trace_gc-0.4.0", project="other-project") + self.assert_rejected("does not name trace-gc") + + def test_an_archive_without_pkg_info_fails(self): + make_sdist(self.dist, "trace-gc-0.4.0.tar.gz", "trace-gc-0.4.0", pkg_info=False) + self.assert_rejected("no PKG-INFO") + + def test_several_top_level_directories_fail(self): + make_sdist(self.dist, "trace-gc-0.4.0.tar.gz", "trace-gc-0.4.0", extra=(("stray/file.txt", b"x"),)) + self.assert_rejected("exactly one top-level directory") + + def test_path_traversal_and_absolute_members_fail_before_any_extraction(self): + for member in ("trace-gc-0.4.0/../escape.txt", "/absolute.txt", "trace-gc-0.4.0\\..\\escape.txt"): + with self.subTest(member): + for child in self.dist.iterdir(): + child.unlink() + make_sdist(self.dist, "trace-gc-0.4.0.tar.gz", "trace-gc-0.4.0", extra=((member, b"x"),)) + self.assert_rejected("unsafe member name") + self.assertFalse(self.into.exists()) + self.assertFalse((self.work / "escape.txt").exists()) + + def test_link_members_fail(self): + with tarfile.open(self.dist / "trace-gc-0.4.0.tar.gz", "w:gz") as archive: + add_file(archive, "trace-gc-0.4.0/PKG-INFO", (PKG_INFO % "trace_gc").encode()) + link = tarfile.TarInfo("trace-gc-0.4.0/link") + link.type = tarfile.SYMTYPE + link.linkname = "/etc/passwd" + archive.addfile(link) + self.assert_rejected("neither a file nor a directory") + + def test_a_non_empty_extraction_target_fails(self): + make_sdist(self.dist, "trace-gc-0.4.0.tar.gz", "trace-gc-0.4.0") + self.into.mkdir() + (self.into / "stale").write_text("old") + self.assert_rejected("not an empty directory") + + def test_wheels_missing_and_ambiguous_fail(self): + with self.assertRaises(discovery.DiscoveryError): + discovery.find_wheel(self.dist, "trace-gc") + (self.dist / "trace_gc-0.4.0-py3-none-any.whl").write_bytes(b"PK") + (self.dist / "trace_gc-0.4.1-py3-none-any.whl").write_bytes(b"PK") + with self.assertRaises(discovery.DiscoveryError) as caught: + discovery.find_wheel(self.dist, "trace-gc") + self.assertIn("ambiguous", str(caught.exception)) + done = self.cli("wheel", "--project", "trace-gc", "--dist", str(self.dist)) + self.assertEqual((done.returncode, done.stdout), (1, b"")) + + def test_a_wheel_of_another_project_fails(self): + (self.dist / "other_project-0.4.0-py3-none-any.whl").write_bytes(b"PK") + with self.assertRaises(discovery.DiscoveryError): + discovery.find_wheel(self.dist, "trace-gc") + + +def build_step() -> str: + text = WORKFLOW.read_text(encoding="utf-8") + start = text.index(" - name: " + BUILD_STEP) + following = re.search(r"^ - name: ", text[start + 1:], re.MULTILINE) + return text[start:start + 1 + following.start()] if following else text[start:] + + +class WorkflowDiscoveryTests(DiscoveryCase): + COMMAND = re.compile(r'(?P\w+)="\$\(python (?Ptools/sdist_discovery\.py [^)]*)\)"') + + def commands(self): + found = {match["variable"]: match["command"] for match in self.COMMAND.finditer(build_step())} + self.assertEqual(sorted(found), ["SDIST", "WHEEL"]) + return found + + def run_workflow_command(self, variable): + command = self.commands()[variable].replace("$RUNNER_TEMP", self.work.as_posix()) + return subprocess.run([sys.executable, *shlex.split(command)], cwd=ROOT, capture_output=True, timeout=120) + + def test_the_build_step_assumes_no_archive_spelling_and_no_first_match(self): + step = build_step() + self.assertNotIn("trace_gc-", step) + self.assertNotIn("trace-gc-", step) + self.assertNotIn("glob.glob", step) + self.assertNotIn("[0]", step) + self.assertNotIn(".tar.gz", step) + self.assertIn("set -euo pipefail", step) + self.assertIn('"$INSTALLED" -m pip install --no-deps "$WHEEL"', step) + for command in self.commands().values(): + self.assertIn("--project trace-gc", command) + self.assertIn('--dist "$RUNNER_TEMP/dist"', command) + + def test_the_workflow_discovery_commands_resolve_both_spellings(self): + wheel = self.dist / "trace_gc-0.4.0-py3-none-any.whl" + for label, archive_name, root in SpellingTests.VARIANTS: + with self.subTest(label): + for child in self.dist.iterdir(): + child.unlink() + shutil.rmtree(self.into, ignore_errors=True) + make_sdist(self.dist, archive_name, root) + wheel.write_bytes(b"PK") + sdist = self.run_workflow_command("SDIST") + self.assertEqual(sdist.returncode, 0, sdist.stderr) + self.assertEqual(sdist.stdout.decode().strip(), str((self.into / root).resolve())) + built = self.run_workflow_command("WHEEL") + self.assertEqual(built.stdout.decode().strip(), str(wheel.resolve())) + + def test_the_workflow_discovery_commands_fail_on_missing_and_ambiguous_artifacts(self): + for variable in ("SDIST", "WHEEL"): + with self.subTest("missing " + variable): + done = self.run_workflow_command(variable) + self.assertEqual((done.returncode, done.stdout), (1, b"")) + make_sdist(self.dist, "trace-gc-0.4.0.tar.gz", "trace-gc-0.4.0") + make_sdist(self.dist, "trace_gc-0.4.0.tar.gz", "trace_gc-0.4.0") + (self.dist / "trace_gc-0.4.0-py3-none-any.whl").write_bytes(b"PK") + (self.dist / "trace_gc-0.4.1-py3-none-any.whl").write_bytes(b"PK") + for variable in ("SDIST", "WHEEL"): + with self.subTest("ambiguous " + variable): + done = self.run_workflow_command(variable) + self.assertEqual((done.returncode, done.stdout), (1, b"")) + self.assertIn(b"ambiguous", done.stderr) + self.assertFalse(self.into.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sdist_phase_distribution.py b/tests/test_sdist_phase_distribution.py new file mode 100644 index 0000000..895abb0 --- /dev/null +++ b/tests/test_sdist_phase_distribution.py @@ -0,0 +1,151 @@ +"""A genuinely generated sdist must ship, and be able to import, what the phase-fence tests need. + +The wheel installs only ``trace_gc``; ``src/``, ``tools/``, the phase and Windows test modules and +``tests/phase_trustee_harness.py`` reach an sdist only through ``MANIFEST.in`` on older build +backends. This builds a real sdist with the declared build backend (setuptools from pyproject.toml) from a copy of the tree, derives +from the source imports and by-path loads every file the affected tests need, requires each to be an +sdist member, and requires every affected test module to import from the extracted sdist. A missing +module fails; nothing is skipped. Any equivalent MANIFEST.in rule passes. +""" +from __future__ import annotations + +import ast +import json +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import unittest + +from tools import sdist_discovery + +ROOT = Path(__file__).resolve().parents[1] +ROOT_PATTERNS = ("tests/test_paper_pilot_phase_*.py", "tests/test_windows_*.py") +EXTRA_ROOTS = ("tests/phase_trustee_harness.py", "tools/installed_phase_authority_check.py", + "tests/test_sdist_phase_distribution.py", "tests/test_sdist_discovery.py") +EXTRA_DATA = (".github/workflows/test.yml",) +BY_PATH = re.compile(r"""["'](src|tools)["']\s*/\s*["'](\w+\.py)["']""") +FIRST_PARTY = {"src", "tools", "tests", "trace_gc"} +BUILD_INPUTS = ("pyproject.toml", "setup.cfg", "setup.py", "MANIFEST.in", "README.md", "LICENSE") +BUILD_TREES = ("trace_gc", "src", "tools", "tests", ".github") +IMPORT_PROBE = ( + "import json, sys, unittest\n" + "sys.path.insert(0, '')\n" + "broken, loaded = [], set()\n" + "def walk(suite):\n" + " for item in suite:\n" + " if isinstance(item, unittest.TestSuite):\n" + " walk(item)\n" + " elif type(item).__name__ == '_FailedTest':\n" + " broken.append(item.id())\n" + " else:\n" + " loaded.add(type(item).__module__)\n" + "for pattern in sys.argv[1:]:\n" + " walk(unittest.TestLoader().discover('tests', pattern))\n" + "print(json.dumps({'broken': broken, 'loaded': sorted(loaded)}))\n" +) + + +def _resolve(module: str) -> Path | None: + base = ROOT.joinpath(*module.split(".")) + for candidate in (base.with_suffix(".py"), base / "__init__.py"): + if candidate.is_file(): + return candidate + return None + + +def _imports(path: Path): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + yield from (alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + yield node.module + yield from (node.module + "." + alias.name for alias in node.names) + + +def affected_files() -> set[Path]: + todo = [path for pattern in ROOT_PATTERNS for path in ROOT.glob(pattern)] + todo += [ROOT / name for name in EXTRA_ROOTS] + seen: set[Path] = set() + while todo: + path = todo.pop() + if path in seen: + continue + seen.add(path) + text = path.read_text(encoding="utf-8") + for module in _imports(path): + if module.split(".")[0] in FIRST_PARTY: + found = _resolve(module) + if found is not None: + todo.append(found) + for folder, name in BY_PATH.findall(text): + todo.append(ROOT / folder / name) + return seen + + +def needed_relative_paths() -> set[str]: + return {path.relative_to(ROOT).as_posix() for path in affected_files() + if not path.relative_to(ROOT).as_posix().startswith("trace_gc/")} | set(EXTRA_DATA) + + +class ClosureTests(unittest.TestCase): + def test_the_closure_is_the_expected_non_empty_set(self): + needed = needed_relative_paths() + for expected in ("src/paper_pilot_phase_cutover_guarded_v4.py", "tests/phase_trustee_harness.py", + "tools/windows_fixture_acl.py", "tools/windows_fence_diagnostics.py", + "tools/installed_phase_authority_check.py", "tools/sdist_discovery.py", + "tools/windows_native_acl.py", + "tests/test_sdist_discovery.py", ".github/workflows/test.yml"): + self.assertIn(expected, needed) + + +class GeneratedSdistTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + declared = re.search(r"setuptools>=(\d+)", (ROOT / "pyproject.toml").read_text(encoding="utf-8")) + import setuptools + if declared is None or int(setuptools.__version__.split(".")[0]) < int(declared.group(1)): + raise AssertionError("pyproject.toml requires setuptools>=%s; this interpreter has %s" + % (declared and declared.group(1), setuptools.__version__)) + cls.work = Path(tempfile.mkdtemp(prefix="sdist-phase-")) + cls.addClassCleanup(shutil.rmtree, cls.work, ignore_errors=True) + tree = cls.work / "tree" + tree.mkdir() + for name in BUILD_INPUTS: + if (ROOT / name).is_file(): + shutil.copy2(ROOT / name, tree / name) + for name in BUILD_TREES: + shutil.copytree(ROOT / name, tree / name, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + dist = cls.work / "dist" + dist.mkdir() + build = subprocess.run( + [sys.executable, "-c", "import sys, setuptools.build_meta as b; b.build_sdist(sys.argv[1])", str(dist)], + cwd=tree, capture_output=True, text=True, timeout=300) + if build.returncode: + raise AssertionError("sdist build failed:\n" + build.stdout + build.stderr) + archive = sdist_discovery.find_sdist(dist, "trace-gc") + with tarfile.open(archive) as handle: + cls.members = {"/".join(member.name.split("/")[1:]) for member in handle.getmembers() if member.isfile()} + cls.extracted = sdist_discovery.extract_sdist(dist, cls.work / "extracted", "trace-gc") + + def test_every_file_the_affected_tests_need_is_a_member_of_the_generated_sdist(self): + missing = sorted(needed_relative_paths() - self.members) + self.assertEqual(missing, [], "the generated sdist does not ship files the affected tests need") + + def test_every_affected_test_module_imports_from_the_extracted_sdist(self): + patterns = [Path(pattern).name for pattern in ROOT_PATTERNS] + expected = sorted(path.stem for pattern in ROOT_PATTERNS for path in ROOT.glob(pattern)) + probe = subprocess.run([sys.executable, "-c", IMPORT_PROBE, *patterns], cwd=self.extracted, + capture_output=True, text=True, timeout=300) + self.assertEqual(probe.returncode, 0, probe.stdout + probe.stderr) + result = json.loads(probe.stdout) + self.assertEqual(result["broken"], []) + self.assertEqual(result["loaded"], expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_windows_native_acl.py b/tests/test_windows_native_acl.py new file mode 100644 index 0000000..c8babf9 --- /dev/null +++ b/tests/test_windows_native_acl.py @@ -0,0 +1,2316 @@ +"""Descriptor installation without an account lookup, and the directory-creation denial probe. + +The platform-independent cases run the real helper logic against a labelled fake of the Win32 +layer. They show that each observed outcome gets its own verdict, that only a refusal for the full +original token passes (creation by either route fails whatever the privileges or the stripped +token say), that a deny whose trustee Windows reads back as an alias is found and removed by its +full SID, that a restoration is verified for control and protection flags, ordered ACEs and +owner, and that nothing sensitive reaches the report. The Windows-only cases run the same helpers +on an owned temporary directory against the real descriptor, token and access-check APIs; they +assert facts that do not depend on why a given host does or does not refuse ``mkdir``. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +from tests import test_paper_pilot_phase_trustees_v1_v4 as v1_v4 +from tools import windows_fence_diagnostics +from tools import windows_fixture_acl +from tools import windows_native_acl as native_acl + +WINDOWS = os.name == "nt" +FOREIGN = "S-1-5-21-1111111111-2222222222-3333333333-500" +MACHINE = "S-1-5-21-4444444444-5555555555-6666666666-500" +NEAR_MACHINE = "S-1-5-21-4444444444-5555555555-6666666667-500" +USERS = "S-1-5-32-545" +BASE = "D:AI(A;OICIID;FA;;;SY)(A;OICIID;FA;;;BA)" +PROTECTED = "D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;OW)" +ALIASES = {MACHINE: "LA", USERS: "BU"} +ROUTES = ("os.mkdir", "CreateDirectoryW") +SCRIPT = Path(native_acl.__file__).resolve() + + +class FakeNative: + """A labelled model of the Win32 layer: one DACL string, scripted creation outcomes.""" + + def __init__(self, *, dacl=BASE, stripped_creates=False, full_creates=False, + enabled=("SeChangeNotifyPrivilege",), disabled=("SeBackupPrivilege",), + stripped_mask=0x1F01FF & ~0x4, full_mask=0x1F01FF & ~0x4, routes=ROUTES, + codes=None, unremoved=()): + self.dacl, self.owner = dacl, "O:BA" + self.creates = {True: stripped_creates, False: full_creates} + self.enabled, self.disabled = tuple(enabled), tuple(disabled) + self.masks = {True: stripped_mask, False: full_mask} + self.routes = tuple(routes) + self.codes, self.unremoved = dict(codes or {}), set(unremoved) + self.calls, self.removed = [], [] + + def privileges(self): + return [(name, True) for name in self.enabled] + [(name, False) for name in self.disabled] + + def filesystem(self, path): + return "NTFS" + + def canonical_trustee(self, sid): + return ALIASES.get(sid, sid) + + def read_dacl(self, path): + return self.dacl + + def read_owner(self, path): + return self.owner + + def apply_dacl(self, path, sddl, protection="auto"): + self.applied = getattr(self, "applied", []) + [protection] + self.dacl = sddl + + def access_mask(self, path, stripped): + return self.masks[stripped] + + def try_create_directory(self, path, stripped, route="os.mkdir"): + self.calls.append(("directory", "stripped" if stripped else "full", route)) + if self.creates[stripped] and route in self.routes: + self.removed.append(str(path)) + return {"created": True, "removed": "directory" not in self.unremoved} + return {"created": False, "winerror": self.codes.get((stripped, route), 5)} + + def try_create_file(self, path, stripped): + self.calls.append(("file", "stripped" if stripped else "full")) + return {"created": True, "removed": "file" not in self.unremoved} + + +class AliasingNative(FakeNative): + """Windows reads a well-known or machine-local numeric trustee back as its alias.""" + + def apply_dacl(self, path, sddl, protection="auto"): + self.applied = getattr(self, "applied", []) + [protection] + + def spell(match): + return ";;;" + ALIASES.get(match.group(1), match.group(1)) + ")" + self.dacl = re.sub(r";;;(S-1-[0-9-]+)\)", spell, sddl) + + +class ProtectionDroppingNative(AliasingNative): + """Restores every ACE exactly but loses the DACL protection flag.""" + + def apply_dacl(self, path, sddl): + super().apply_dacl(path, sddl.replace("D:PAI", "D:AI")) + + +class OwnerChangingNative(AliasingNative): + def apply_dacl(self, path, sddl): + super().apply_dacl(path, sddl) + self.owner = "O:SY" + + +class DescriptorTextTests(unittest.TestCase): + def test_an_ace_is_inserted_first_and_removed_exactly_once(self): + ace = "(D;;LC;;;" + FOREIGN + ")" + added = native_acl.with_ace_first(BASE, ace) + self.assertEqual(added, "D:AI" + ace + "(A;OICIID;FA;;;SY)(A;OICIID;FA;;;BA)") + self.assertEqual(native_acl.split_dacl(added)[1][0], ace) + self.assertEqual(native_acl.without_ace(added, ace), BASE) + twice = native_acl.with_ace_first(added, ace) + self.assertEqual(native_acl.without_ace(twice, ace), added) + with self.assertRaises(ValueError): + native_acl.without_ace(BASE, ace) + + def test_text_that_is_not_one_plain_dacl_is_refused(self): + for text in ("", "O:BAD:(A;;FA;;;SY)", BASE + "S:(AU;SA;FA;;;WD)", "D:(A;;FA;;;SY"): + with self.subTest(text): + with self.assertRaises(ValueError): + native_acl.split_dacl(text) + + def test_deny_by_descriptor_installs_one_exact_ace_and_keeps_the_owner(self): + fake = FakeNative() + done = native_acl.deny_by_descriptor(fake, Path("parent"), FOREIGN) + self.assertEqual(done["ace"], "(D;;LC;;;" + FOREIGN + ")") + self.assertEqual(native_acl.split_dacl(done["after"])[1], + [done["ace"]] + native_acl.split_dacl(BASE)[1]) + self.assertEqual(done["owner_before"], done["owner_after"]) + self.assertTrue(done["installed_exactly"]) + self.assertTrue(native_acl.remove_descriptor_deny(fake, Path("parent"), FOREIGN)) + self.assertEqual(fake.dacl, BASE) + with self.assertRaises(ValueError): + native_acl.remove_descriptor_deny(fake, Path("parent"), FOREIGN) + + def test_a_deny_that_windows_reads_back_as_an_alias_is_found_and_removed_by_its_full_sid(self): + for sid, alias in ((MACHINE, "LA"), (USERS, "BU")): + with self.subTest(sid): + fake = AliasingNative(dacl=PROTECTED) + done = native_acl.deny_by_descriptor(fake, Path("parent"), sid) + self.assertTrue(done["installed_exactly"]) + self.assertEqual(native_acl.split_dacl(fake.dacl)[1][0], "(D;;LC;;;%s)" % alias) + self.assertNotIn(done["ace"], native_acl.split_dacl(fake.dacl)[1]) + with self.assertRaises(ValueError): + native_acl.without_ace(fake.dacl, done["ace"]) + self.assertTrue(native_acl.deny_present(fake, Path("parent"), sid)) + self.assertTrue(native_acl.remove_descriptor_deny(fake, Path("parent"), sid)) + self.assertEqual(fake.dacl, PROTECTED) + self.assertFalse(native_acl.deny_present(fake, Path("parent"), sid)) + + def test_an_installation_that_is_not_exact_is_not_reported_as_installed(self): + class LastNative(AliasingNative): + def apply_dacl(self, path, sddl): + control, aces = native_acl.split_dacl(sddl) + super().apply_dacl(path, native_acl.join_dacl(control, [*aces[1:], aces[0]])) + + class DroppingNative(AliasingNative): + def apply_dacl(self, path, sddl): + control, aces = native_acl.split_dacl(sddl) + super().apply_dacl(path, native_acl.join_dacl(control, aces[:-1])) + cases = {"order": LastNative, "protection": ProtectionDroppingNative, + "owner": OwnerChangingNative, "lost ace": DroppingNative} + for label, cls in cases.items(): + with self.subTest(label): + done = native_acl.deny_by_descriptor(cls(dacl=PROTECTED), Path("parent"), FOREIGN) + self.assertFalse(done["installed_exactly"]) + self.assertTrue(native_acl.deny_by_descriptor(AliasingNative(dacl=PROTECTED), Path("parent"), + FOREIGN)["installed_exactly"]) + + def test_removal_takes_only_the_intended_ace_and_never_collapses_distinct_rid500_sids(self): + start = ("D:PAI(D;;LC;;;LA)(D;;LC;;;%s)(D;;DC;;;LA)(D;OICI;LC;;;LA)(A;;LC;;;LA)(A;OICI;FA;;;SY)" + % FOREIGN) + fake = AliasingNative(dacl=start) + self.assertTrue(native_acl.remove_descriptor_deny(fake, Path("parent"), MACHINE)) + self.assertEqual(fake.dacl, "D:PAI(D;;LC;;;%s)(D;;DC;;;LA)(D;OICI;LC;;;LA)(A;;LC;;;LA)(A;OICI;FA;;;SY)" + % FOREIGN) + self.assertTrue(native_acl.remove_descriptor_deny(fake, Path("parent"), FOREIGN)) + self.assertEqual(fake.dacl, "D:PAI(D;;DC;;;LA)(D;OICI;LC;;;LA)(A;;LC;;;LA)(A;OICI;FA;;;SY)") + for other in (FOREIGN, NEAR_MACHINE, "S-1-5-21-4444444444-5555555555-6666666666-1001"): + with self.subTest(other): + before = fake.dacl + self.assertFalse(native_acl.deny_present(fake, Path("parent"), other)) + with self.assertRaises(ValueError): + native_acl.remove_descriptor_deny(fake, Path("parent"), other) + self.assertEqual(fake.dacl, before) + twice = AliasingNative(dacl="D:PAI(D;;LC;;;LA)(A;OICI;FA;;;SY)(D;;LC;;;LA)") + self.assertTrue(native_acl.remove_descriptor_deny(twice, Path("parent"), MACHINE)) + self.assertEqual(twice.dacl, "D:PAI(A;OICI;FA;;;SY)(D;;LC;;;LA)") + foreign_only = AliasingNative(dacl="D:PAI(D;;LC;;;%s)(A;OICI;FA;;;SY)" % FOREIGN) + with self.assertRaises(ValueError): + native_acl.remove_descriptor_deny(foreign_only, Path("parent"), MACHINE) + self.assertEqual(foreign_only.dacl, "D:PAI(D;;LC;;;%s)(A;OICI;FA;;;SY)" % FOREIGN) + + def test_a_removal_that_drops_protection_or_changes_the_owner_is_reported_as_failed(self): + for label, cls in (("protection", ProtectionDroppingNative), ("owner", OwnerChangingNative)): + with self.subTest(label): + fake = cls(dacl=PROTECTED) + fake.dacl = "D:PAI(D;;LC;;;LA)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FA;;;OW)" + self.assertFalse(native_acl.remove_descriptor_deny(fake, Path("parent"), MACHINE)) + self.assertNotIn("(D;;LC;;;LA)", fake.dacl) + + def test_restoration_names_each_component_that_differs(self): + fake = AliasingNative(dacl=PROTECTED) + owner = fake.owner + path = Path("parent") + self.assertEqual(native_acl.restoration(fake, path, PROTECTED, owner), + {"control": True, "aces": True, "owner": True, "verified": True}) + spelled = PROTECTED.replace("(A;OICI;FA;;;BA)", "(A;OICI;FA;;;" + USERS + ")") + self.assertTrue(native_acl.restoration(AliasingNative(dacl=PROTECTED.replace( + "(A;OICI;FA;;;BA)", "(A;OICI;FA;;;BU)")), path, spelled, owner)["verified"]) + cases = { + "control": (PROTECTED.replace("D:PAI", "D:AI"), owner, "control"), + "aces": (PROTECTED.replace("(A;OICI;FA;;;BA)", ""), owner, "aces"), + "order": ("D:PAI(A;OICI;FA;;;BA)(A;OICI;FA;;;SY)(A;OICI;FA;;;OW)", owner, "aces"), + "flags": (PROTECTED.replace("OICI;FA;;;SY", "OI;FA;;;SY"), owner, "aces"), + "owner": (PROTECTED, "O:SY", "owner"), + } + for label, (dacl, now_owner, component) in cases.items(): + with self.subTest(label): + drifted = AliasingNative(dacl=dacl) + drifted.owner = now_owner + result = native_acl.restoration(drifted, path, PROTECTED, owner) + self.assertFalse(result["verified"]) + self.assertFalse(result[component]) + self.assertEqual(sum(1 for value in result.values() if not value), 2) + + +class VerdictTests(unittest.TestCase): + def report(self, **options): + return native_acl.recreation_report(FakeNative(**options), Path("parent"), Path("parent") / "state") + + def test_a_denial_for_both_tokens_is_the_denied_verdict(self): + report = self.report() + self.assertEqual(report["verdict"], "DENIED") + self.assertFalse(report["cause_established"]) + + def test_only_a_refusal_is_accepted(self): + self.assertEqual(native_acl.ACCEPTED_VERDICTS, ("DENIED",)) + + def test_full_token_creation_by_either_route_fails_whatever_the_privileges_or_the_stripped_token_say(self): + bypass = dict(enabled=("SeChangeNotifyPrivilege", "SeBackupPrivilege", "SeRestorePrivilege"), + disabled=()) + for route in ROUTES: + for label, options in ( + ("bypass privilege enabled and AccessCheck denies", bypass), + ("no privilege", {}), + ("AccessCheck grants the right", dict(full_mask=0x1F01FF))): + with self.subTest(route=route, case=label): + fake = FakeNative(full_creates=True, routes=(route,), **options) + with self.assertRaises(AssertionError) as caught: + native_acl.require_recreation_denied(fake, Path("parent"), Path("parent") / "state") + report = json.loads(str(caught.exception).split(": ", 1)[1]) + self.assertTrue(report["verdict"].startswith("FULL_TOKEN_CREATES"), report["verdict"]) + self.assertTrue(report[{"os.mkdir": "mkdir"}.get(route, "mkdir_CreateDirectoryW")] + ["full"]["created"]) + self.assertFalse(report["cause_established"]) + self.assertNotIn(report["verdict"], native_acl.ACCEPTED_VERDICTS) + + def test_every_other_outcome_is_refused_with_its_own_verdict(self): + cases = [ + ("FULL_TOKEN_CREATES_UNEXPLAINED", dict(full_creates=True)), + ("FULL_TOKEN_CREATES_UNEXPLAINED", dict(full_creates=True, enabled=("SeChangeNotifyPrivilege",), + disabled=("SeBackupPrivilege", "SeRestorePrivilege"))), + ("FULL_TOKEN_CREATES_UNEXPLAINED", dict(full_creates=True, enabled=("SeRestorePrivilege",), + full_mask=0x1F01FF)), + ("FULL_TOKEN_CREATES_WHILE_BYPASS_PRIVILEGE_ENABLED", + dict(full_creates=True, enabled=("SeBackupPrivilege",))), + ("KERNEL_CREATE_IGNORES_DACL", dict(stripped_creates=True, full_creates=True)), + ("DACL_GRANTS_ADD_SUBDIRECTORY", dict(stripped_creates=True, full_creates=True, stripped_mask=0x1F01FF)), + ] + for expected, options in cases: + with self.subTest(expected, options=options): + report = self.report(**options) + self.assertEqual(report["verdict"], expected) + self.assertNotIn(report["verdict"], native_acl.ACCEPTED_VERDICTS) + fake = FakeNative(**options) + with self.assertRaises(AssertionError) as caught: + native_acl.require_recreation_denied(fake, Path("parent"), Path("parent") / "state") + self.assertIn(expected, str(caught.exception)) + self.assertEqual(json.loads(str(caught.exception).split(": ", 1)[1])["verdict"], expected) + + def test_a_refusal_that_is_not_access_denied_is_not_a_denial_for_any_route_or_token(self): + for code, meaning in ((2, "file not found"), (3, "path not found"), (32, "sharing violation"), + (87, "invalid parameter"), (112, "disk full"), (123, "invalid name"), + (183, "already exists"), (None, "no Windows error at all")): + for stripped in (True, False): + for route in ROUTES: + with self.subTest(code=code, meaning=meaning, stripped=stripped, route=route): + options = dict(codes={(stripped, route): code}) + report = self.report(**options) + self.assertEqual(report["verdict"], "REFUSED_WITHOUT_ACCESS_DENIED") + self.assertEqual(report["non_denial_outcomes"], + [{"route": route, "token": "stripped" if stripped else "full", + "winerror": code}]) + self.assertNotIn(report["verdict"], native_acl.ACCEPTED_VERDICTS) + with self.assertRaises(AssertionError) as caught: + native_acl.require_recreation_denied( + FakeNative(**options), Path("parent"), Path("parent") / "state") + facts = json.loads(str(caught.exception).split(": ", 1)[1]) + self.assertEqual(facts["non_denial_outcomes"], report["non_denial_outcomes"]) + + def test_only_error_access_denied_counts_as_a_refusal_and_every_outcome_is_checked(self): + self.assertEqual(native_acl.ERROR_ACCESS_DENIED, 5) + report = self.report() + self.assertEqual(report["non_denial_outcomes"], []) + for key in ("mkdir", "mkdir_CreateDirectoryW"): + for label in ("stripped", "full"): + self.assertEqual(report[key][label], {"created": False, "winerror": 5}, (key, label)) + mixed = self.report(codes={(True, "os.mkdir"): 3, (False, "CreateDirectoryW"): 183}) + self.assertEqual([(o["route"], o["token"], o["winerror"]) for o in mixed["non_denial_outcomes"]], + [("os.mkdir", "stripped", 3), ("CreateDirectoryW", "full", 183)]) + + def test_an_outcome_that_carries_no_windows_error_is_not_a_denial(self): + report = self.report() + report["mkdir_CreateDirectoryW"]["full"] = {"created": False} + self.assertEqual(native_acl.classify(report), "REFUSED_WITHOUT_ACCESS_DENIED") + self.assertEqual(native_acl.non_denial_outcomes(report), + [{"route": "CreateDirectoryW", "token": "full", "winerror": None}]) + + def test_a_creation_still_outranks_an_unrelated_failure_on_another_probe(self): + report = self.report(full_creates=True, routes=("os.mkdir",), codes={(False, "CreateDirectoryW"): 3}) + self.assertEqual(report["verdict"], "FULL_TOKEN_CREATES_UNEXPLAINED") + + def test_a_probe_that_does_not_verifiably_remove_what_it_created_fails_the_gate(self): + for kind in ("file", "directory"): + with self.subTest(kind): + fake = FakeNative(unremoved=(kind,), stripped_creates=kind == "directory", + full_creates=kind == "directory") + report = native_acl.recreation_report(fake, Path("parent"), Path("parent") / "state") + self.assertFalse(report["probe_cleanup_verified"]) + with self.assertRaises(AssertionError): + native_acl.require_recreation_denied(fake, Path("parent"), Path("parent") / "state") + denied_but_dirty = FakeNative(unremoved=("file",)) + report = native_acl.recreation_report(denied_but_dirty, Path("parent"), Path("parent") / "state") + self.assertEqual(report["verdict"], "DENIED") + with self.assertRaises(AssertionError) as caught: + native_acl.require_recreation_denied(denied_but_dirty, Path("parent"), Path("parent") / "state") + self.assertFalse(json.loads(str(caught.exception).split(": ", 1)[1])["probe_cleanup_verified"]) + self.assertTrue(native_acl.recreation_report( + FakeNative(), Path("parent"), Path("parent") / "state")["probe_cleanup_verified"]) + + def test_creation_by_either_route_counts_as_creation(self): + base = self.report() + self.assertEqual(base["verdict"], "DENIED") + for route in ("mkdir", "mkdir_CreateDirectoryW"): + report = json.loads(json.dumps(base)) + report[route]["full"] = {"created": True, "removed": True} + self.assertEqual(native_acl.classify(report), "FULL_TOKEN_CREATES_UNEXPLAINED", route) + report[route]["stripped"] = {"created": True, "removed": True} + self.assertEqual(native_acl.classify(report), "KERNEL_CREATE_IGNORES_DACL", route) + + def test_a_denied_probe_passes_and_returns_its_report(self): + report = native_acl.require_recreation_denied(FakeNative(), Path("parent"), Path("parent") / "state") + self.assertEqual(report["verdict"], "DENIED") + + def test_the_privilege_stripped_token_is_attempted_before_the_full_token_by_every_route(self): + fake = FakeNative() + native_acl.recreation_report(fake, Path("parent"), Path("parent") / "state") + directories = [call for call in fake.calls if call[0] == "directory"] + self.assertEqual(directories, [("directory", "stripped", "os.mkdir"), + ("directory", "stripped", "CreateDirectoryW"), + ("directory", "full", "os.mkdir"), + ("directory", "full", "CreateDirectoryW")]) + + def test_the_report_has_no_path_or_machine_identifier(self): + fake = FakeNative(dacl="D:PAI(D;;LC;;;" + MACHINE + ")(A;OICI;FA;;;SY)") + report = native_acl.recreation_report(fake, Path("secret-parent"), Path("secret-parent") / "secret-state") + text = json.dumps(report) + self.assertNotIn("secret", text) + self.assertNotIn("4444444444", text) + self.assertIn("S-1-5-21--500", text) + + +class CandidateMatrixTests(unittest.TestCase): + def test_every_shape_is_tried_and_every_original_dacl_is_restored(self): + fake = FakeNative() + matrix = native_acl.candidate_matrix(fake, MACHINE, lambda label: Path(label)) + self.assertEqual(sorted(matrix["shapes"]), sorted(native_acl.CANDIDATE_SHAPES)) + self.assertTrue(matrix["cleanup_verified"]) + self.assertEqual(fake.dacl, BASE) + self.assertIn("(D;;LC;;;" + MACHINE + ")", + matrix["shapes"]["current_LC"]["installed_dacl"].replace("S-1-5-21--500", MACHINE)) + for label, entry in matrix["shapes"].items(): + self.assertEqual(entry["restored"], + {"control": True, "aces": True, "owner": True, "verified": True}, label) + self.assertEqual(sorted(entry["mkdir"]), ["full", "stripped"], label) + + def test_a_restore_that_does_not_take_effect_fails_the_cleanup_verdict(self): + class Sticky(FakeNative): + def apply_dacl(self, path, sddl): + if "(D;" in sddl: + self.dacl = sddl + fake = Sticky() + matrix = native_acl.candidate_matrix(fake, MACHINE, lambda label: Path(label)) + self.assertFalse(matrix["cleanup_verified"]) + self.assertFalse(matrix["shapes"]["current_LC"]["restored"]["verified"]) + self.assertTrue(matrix["shapes"]["control_no_deny"]["restored"]["verified"]) + + def test_a_created_probe_directory_that_is_not_removed_fails_the_matrix_cleanup(self): + fake = FakeNative(stripped_creates=True, full_creates=True, unremoved=("directory",)) + matrix = native_acl.candidate_matrix(fake, MACHINE, lambda label: Path(label)) + self.assertFalse(matrix["cleanup_verified"]) + self.assertFalse(matrix["shapes"]["current_LC"]["probe_cleanup_verified"]) + clean = native_acl.candidate_matrix( + FakeNative(stripped_creates=True, full_creates=True), MACHINE, lambda label: Path(label)) + self.assertTrue(clean["cleanup_verified"]) + self.assertEqual(sorted(clean["shapes"]["current_LC"]["mkdir_CreateDirectoryW"]), ["full", "stripped"]) + + def test_the_actual_fence_probe_is_not_verified_when_its_own_probe_cleanup_is_not(self): + fake = AliasingNative(dacl=PROTECTED, unremoved=("file",)) + done = native_acl.probe_actual_fence_shape(fake, lambda label: Path(label), MACHINE) + self.assertTrue(done["restored"]["verified"]) + self.assertFalse(done["verified"]) + + def test_a_failed_step_is_recorded_and_the_cleanup_is_not_reported_verified(self): + class Failing(FakeNative): + def access_mask(self, path, stripped): + raise native_acl.NativeError("AccessCheck", 5) + fake = Failing() + matrix = native_acl.candidate_matrix(fake, MACHINE, lambda label: Path(label)) + self.assertFalse(matrix["cleanup_verified"]) + self.assertEqual(matrix["shapes"]["current_LC"]["error"], {"type": "NativeError", "code": 5}) + self.assertEqual(fake.dacl, BASE) + + def test_a_restoration_that_keeps_every_ace_but_loses_protection_is_not_verified(self): + fake = ProtectionDroppingNative(dacl=PROTECTED) + matrix = native_acl.candidate_matrix(fake, MACHINE, lambda label: Path(label)) + self.assertFalse(matrix["cleanup_verified"]) + self.assertEqual(matrix["shapes"]["control_no_deny"]["restored"], + {"control": False, "aces": True, "owner": True, "verified": False}) + self.assertEqual(native_acl.split_dacl(fake.dacl)[0], "AI") + + def test_a_restoration_that_changes_the_owner_is_not_verified(self): + fake = OwnerChangingNative(dacl=PROTECTED) + matrix = native_acl.candidate_matrix(fake, MACHINE, lambda label: Path(label)) + self.assertFalse(matrix["cleanup_verified"]) + self.assertEqual(matrix["shapes"]["control_no_deny"]["restored"], + {"control": True, "aces": True, "owner": False, "verified": False}) + + def test_the_actual_fence_shape_probe_restores_and_verifies_its_parent(self): + fake = AliasingNative(dacl=PROTECTED) + done = native_acl.probe_actual_fence_shape(fake, lambda label: Path(label), MACHINE) + self.assertEqual(done["restored"]["verified"], True) + self.assertEqual(fake.dacl, PROTECTED) + self.assertEqual(done["report"]["verdict"], "DENIED") + drift = ProtectionDroppingNative(dacl=PROTECTED) + done = native_acl.probe_actual_fence_shape(drift, lambda label: Path(label), MACHINE) + self.assertEqual(done["restored"], {"control": False, "aces": True, "owner": True, "verified": False}) + + +class RedactionTests(unittest.TestCase): + SAMPLES = ("D:PAI(D;;LC;;;S-1-5-21-1111111111-2222222222-3333333333-500)(A;;FA;;;S-1-12-1-1-2-3-4)", + "O:S-1-5-21-4444444444-5555555555-6666666666-1001", "S-1-5-32-545", "no sid here", "") + + def test_the_local_redaction_matches_the_diagnostics_sanitizer(self): + for text in self.SAMPLES: + self.assertEqual(native_acl.redact(text), windows_fence_diagnostics.redact(text), text) + + def test_machine_and_directory_sub_authorities_are_masked_and_the_rid_is_kept(self): + masked = native_acl.redact(self.SAMPLES[0]) + self.assertIn("S-1-5-21--500", masked) + self.assertIn("S-1-12-1-", masked) + self.assertNotIn("1111111111", masked) + self.assertEqual(native_acl.redact(self.SAMPLES[2]), self.SAMPLES[2]) + + def test_loading_the_module_by_path_edits_neither_the_path_nor_the_imports(self): + code = ( + "import importlib.util, sys\n" + "paths, modules = list(sys.path), set(sys.modules)\n" + "spec = importlib.util.spec_from_file_location('loaded_by_path', %r)\n" + "module = importlib.util.module_from_spec(spec)\n" + "spec.loader.exec_module(module)\n" + "appeared = {name.split('.')[0] for name in set(sys.modules) - modules}\n" + "print(sys.path == paths, sorted(appeared & {'trace_gc', 'src', 'tools'}))\n" % str(SCRIPT)) + done = subprocess.run([sys.executable, "-I", "-c", code], capture_output=True, text=True, + cwd=tempfile.gettempdir(), timeout=60) + self.assertEqual((done.returncode, done.stdout.strip()), (0, "True []"), done.stderr) + + +class SourceScriptTests(unittest.TestCase): + """``python tools/windows_native_acl.py`` in a fresh interpreter, from another directory.""" + + def run_script(self, *arguments): + environment = {key: value for key, value in os.environ.items() if key != "PYTHONPATH"} + done = subprocess.run([sys.executable, str(SCRIPT), *arguments], capture_output=True, text=True, + cwd=tempfile.gettempdir(), env=environment, timeout=300) + return done, json.loads(done.stdout) + + def test_a_fresh_interpreter_runs_the_script_past_its_checkout_imports(self): + done, report = self.run_script("--stages-only") + self.assertEqual(done.returncode, 0, done.stderr) + self.assertNotEqual(report.get("error", {}).get("type"), "ModuleNotFoundError", report) + self.assertEqual(report["completed_stages"][:2], ["bootstrap", "checkout_imports"], report) + if WINDOWS: + self.assertEqual(report["completed_stages"], ["bootstrap", "checkout_imports", "native_api", "identity"]) + self.assertNotIn("error", report) + else: + self.assertEqual(report["error"], {"code": None, "stage": "native_api", "type": "NativeError"}) + self.assertEqual(report["os_name"], os.name) + + def test_the_full_report_names_its_stages_and_has_no_path_or_account(self): + done, report = self.run_script() + self.assertNotEqual(report.get("error", {}).get("type"), "ModuleNotFoundError", report) + self.assertEqual(report["completed_stages"][:2], ["bootstrap", "checkout_imports"], report) + text = done.stdout + self.assertNotIn(tempfile.gettempdir(), text) + self.assertNotRegex(text, r"S-1-5-21-\d+-\d+-\d+-\d+") + if WINDOWS: + self.assertEqual(done.returncode, 0 if report["cleanup_verified"] else 1, done.stderr) + for section in ("matrix", "actual_fence_shape", "privilege_isolation", + "installation_observation", "restricted_primary_child"): + self.assertIn(section, report, report) + else: + self.assertEqual(done.returncode, 0, done.stderr) + + def test_the_child_entry_writes_a_report_and_nothing_else(self): + with tempfile.TemporaryDirectory(prefix="jraphyte-native-acl-") as temp: + out = Path(temp) / "child.json" + done = subprocess.run([sys.executable, str(SCRIPT), "--child-recreation-probe", + temp, str(Path(temp) / "state"), str(out)], + capture_output=True, text=True, timeout=120) + self.assertEqual((done.returncode, done.stdout), (0, ""), done.stderr) + body = json.loads(out.read_text(encoding="utf-8")) + if WINDOWS: + self.assertIn(body["verdict"], ("DACL_GRANTS_ADD_SUBDIRECTORY", "DENIED")) + self.assertIs(body["probe_cleanup_verified"], True) + self.assertFalse((Path(temp) / "state").exists()) + else: + self.assertEqual(body["error"]["type"], "NativeError") + + +class InstallationComponentTests(unittest.TestCase): + ACE = "(D;;LC;;;" + USERS + ")" + + def components(self, after, *, before=PROTECTED, owner_after="O:BA", native=None): + return native_acl.installation_components(native or AliasingNative(), before, after, + self.ACE, "O:BA", owner_after) + + def test_an_exact_installation_reports_every_component_as_exact(self): + components = self.components(native_acl.with_ace_first(PROTECTED, "(D;;LC;;;BU)")) + self.assertTrue(components["installed_exactly"], components) + self.assertEqual(components["control"], {"before": "PAI", "after": "PAI", "unchanged": True}) + aces = components["aces"] + self.assertEqual((aces["count_before"], aces["count_after"], aces["new_ace_first"]), (3, 4, True)) + self.assertEqual((aces["explicit_after"], aces["inherited_after"]), (4, 0)) + self.assertEqual((aces["unexpected_after"], aces["missing_after"]), ([], [])) + + def test_a_flipped_auto_inheritance_flag_is_named_and_is_not_exact(self): + components = self.components(native_acl.with_ace_first("D:AI" + PROTECTED[len("D:PAI"):], "(D;;LC;;;BU)")) + self.assertFalse(components["installed_exactly"]) + self.assertEqual(components["control"], {"before": "PAI", "after": "AI", "unchanged": False}) + self.assertTrue(components["aces"]["exact"]) + + def test_a_copied_ace_is_unexpected_and_is_not_exact(self): + before = BASE + copied = "D:AI(D;;LC;;;BU)(A;;FA;;;SY)(A;;FA;;;BA)(A;OICIID;FA;;;SY)(A;OICIID;FA;;;BA)" + components = self.components(copied, before=before) + self.assertFalse(components["installed_exactly"]) + self.assertEqual(components["aces"]["unexpected_after"], ["(A;;FA;;;BA)", "(A;;FA;;;SY)"]) + self.assertEqual(components["aces"]["missing_after"], []) + self.assertEqual((components["aces"]["inherited_before"], components["aces"]["inherited_after"]), (2, 2)) + self.assertEqual((components["aces"]["explicit_before"], components["aces"]["explicit_after"]), (0, 3)) + + def test_a_missing_ace_a_reordered_ace_and_an_owner_change_are_each_not_exact(self): + for label, after, owner in ( + ("missing", "D:PAI(D;;LC;;;BU)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)", "O:BA"), + ("reordered", "D:PAI(A;OICI;FA;;;SY)(D;;LC;;;BU)(A;OICI;FA;;;BA)(A;OICI;FA;;;OW)", "O:BA"), + ("owner", native_acl.with_ace_first(PROTECTED, "(D;;LC;;;BU)"), "O:SY")): + with self.subTest(label): + components = self.components(after, owner_after=owner) + self.assertFalse(components["installed_exactly"]) + self.assertEqual(self.components("D:PAI(D;;LC;;;BU)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)") + ["aces"]["missing_after"], ["(A;OICI;FA;;;OW)"]) + + def test_the_facts_carry_no_machine_sid(self): + before = "D:PAI(A;OICI;FA;;;" + FOREIGN + ")" + after = native_acl.with_ace_first(before, "(D;;LC;;;" + MACHINE + ")") + components = native_acl.installation_components( + FakeNative(), before, after, "(D;;LC;;;" + MACHINE + ")", + "O:" + FOREIGN, "O:" + FOREIGN) + text = json.dumps(components) + self.assertNotIn("1111111111", text) + self.assertNotIn("4444444444", text) + self.assertIn("S-1-5-21--500", text) + + def test_deny_by_descriptor_returns_the_components_behind_its_boolean(self): + fake = AliasingNative(dacl=PROTECTED) + done = native_acl.deny_by_descriptor(fake, Path("x"), USERS) + self.assertEqual(done["installed_exactly"], done["components"]["installed_exactly"]) + self.assertTrue(done["installed_exactly"]) + self.assertEqual(fake.applied, ["auto"]) + native_acl.deny_by_descriptor(FakeNative(dacl=PROTECTED), Path("x"), USERS, protection="none") + message = native_acl.components_message("installation", done["components"]) + self.assertTrue(message.startswith("installation: {")) + + def test_the_protection_argument_reaches_the_native_layer_only_when_it_is_not_auto(self): + fake = FakeNative(dacl=PROTECTED) + native_acl.deny_by_descriptor(fake, Path("x"), USERS, protection="none") + self.assertEqual(fake.applied, ["none"]) + + def test_restoration_components_agree_with_the_restoration_verdict(self): + for fake in (AliasingNative(dacl=PROTECTED), ProtectionDroppingNative(dacl=PROTECTED), + OwnerChangingNative(dacl=PROTECTED)): + fake.apply_dacl(Path("x"), PROTECTED) + components = native_acl.restoration_components(fake, Path("x"), PROTECTED, "O:BA") + verdict = native_acl.restoration(fake, Path("x"), PROTECTED, "O:BA") + self.assertEqual(components["verified"], verdict["verified"], type(fake).__name__) + self.assertEqual(components["control"]["same"], verdict["control"]) + self.assertEqual(components["aces"]["same"], verdict["aces"]) + self.assertEqual(components["owner"]["same"], verdict["owner"]) + + +class RestorationComponentTests(unittest.TestCase): + def test_each_component_difference_is_named_and_fails_verification(self): + for label, fake, component in ( + ("aces", FakeNative(dacl="D:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)"), "aces"), + ("control", FakeNative(dacl="D:AI" + PROTECTED[len("D:PAI"):]), "control"), + ("owner", OwnerChangingNative(dacl=PROTECTED), "owner")): + with self.subTest(label): + fake.owner = "O:SY" if label == "owner" else fake.owner + found = native_acl.restoration_components(fake, Path("x"), PROTECTED, "O:BA") + self.assertIs(found[component]["same"], False) + self.assertIs(found["verified"], False) + same = native_acl.restoration_components(FakeNative(dacl=PROTECTED), Path("x"), PROTECTED, "O:BA") + self.assertEqual((same["control"]["same"], same["aces"]["same"], same["owner"]["same"], same["verified"]), + (True, True, True, True)) + + +class PrivilegeFake(FakeNative): + """Creation succeeds unless the token keeps the one privilege named in ``needs``.""" + + def __init__(self, needs=None, **kwargs): + super().__init__(enabled=("SeBackupPrivilege", "SeChangeNotifyPrivilege"), + disabled=("SeIncreaseQuotaPrivilege",), **kwargs) + self.needs = needs + + def try_create_directory(self, path, stripped, route="os.mkdir", delete=(), impersonate=False): + self.calls.append(("directory", stripped, route, tuple(delete), impersonate)) + every = {name for name, _ in self.privileges()} + kept = set() if stripped else every - set(delete) + if self.needs is not None and self.needs in kept: + return {"created": True, "removed": True} + return {"created": False, "winerror": 5} + + +class PrivilegeIsolationTests(unittest.TestCase): + def isolate(self, needs): + return native_acl.privilege_isolation(PrivilegeFake(needs), Path("parent"), Path("parent/state")) + + def test_the_single_privilege_whose_removal_turns_creation_into_a_denial_is_named(self): + report = self.isolate("SeBackupPrivilege") + self.assertEqual(report["single_removals_that_turn_creation_into_access_denied"], ["SeBackupPrivilege"]) + self.assertEqual(report["single_retentions_that_still_create"], ["SeBackupPrivilege"]) + self.assertEqual(report["outcomes"]["original_process_token"], {"created": True, "removed": True}) + self.assertEqual(report["outcomes"]["impersonated_copy_all_removed"], {"created": False, "winerror": 5}) + + def test_a_token_that_is_always_refused_names_no_privilege(self): + report = self.isolate(None) + self.assertEqual(report["single_removals_that_turn_creation_into_access_denied"], []) + self.assertEqual(report["single_retentions_that_still_create"], []) + self.assertEqual(report["enabled"], ["SeBackupPrivilege", "SeChangeNotifyPrivilege"]) + + def test_the_report_never_establishes_a_cause_and_verifies_its_cleanup(self): + for needs in ("SeBackupPrivilege", None): + report = self.isolate(needs) + self.assertIs(report["cause_established"], False) + self.assertIs(report["probe_cleanup_verified"], True) + + def test_a_refusal_with_another_code_is_not_a_turn_into_access_denied(self): + class Other(PrivilegeFake): + def try_create_directory(self, path, stripped, route="os.mkdir", delete=(), impersonate=False): + outcome = super().try_create_directory(path, stripped, route, delete, impersonate) + return {"created": False, "winerror": 3} if delete and not outcome["created"] else outcome + report = native_acl.privilege_isolation(Other("SeBackupPrivilege"), Path("p"), Path("p/s")) + self.assertEqual(report["single_removals_that_turn_creation_into_access_denied"], []) + + def test_a_directory_that_is_created_and_not_removed_fails_the_cleanup(self): + class Leaky(PrivilegeFake): + def try_create_directory(self, *args, **kwargs): + outcome = super().try_create_directory(*args, **kwargs) + return {**outcome, "removed": False} if outcome["created"] else outcome + report = native_acl.privilege_isolation(Leaky("SeBackupPrivilege"), Path("p"), Path("p/s")) + self.assertIs(report["probe_cleanup_verified"], False) + + def test_the_fence_shape_run_installs_the_parent_deny_and_restores_it(self): + fake = PrivilegeFake("SeBackupPrivilege", dacl=PROTECTED) + done = native_acl.privilege_isolation_on_fence_shape(fake, lambda label: Path(label), MACHINE) + self.assertIs(done["verified"], True) + self.assertEqual(fake.dacl, PROTECTED) + self.assertTrue(done["restored"]["verified"]) + + def test_a_restoration_that_loses_protection_is_not_verified(self): + class Dropping(PrivilegeFake): + def apply_dacl(self, path, sddl, protection="auto"): + self.dacl = sddl.replace("D:PAI", "D:AI") + done = native_acl.privilege_isolation_on_fence_shape( + Dropping("SeBackupPrivilege", dacl=PROTECTED), lambda label: Path(label), MACHINE) + self.assertIs(done["verified"], False) + self.assertFalse(done["restored"]["control"]["same"]) + + +class InstallationObservationTests(unittest.TestCase): + def test_each_shape_reports_its_components_and_restores_its_own_original(self): + fakes = {} + + def make(label): + fakes[label] = Path(label) + return Path(label) + fake = AliasingNative(dacl=PROTECTED) + report = native_acl.installation_observation(fake, MACHINE, make, lambda path: None) + for label in ("unprotected_inherited_auto_flag", "unprotected_inherited_no_flag", + "stabilized_protected_auto_flag"): + self.assertTrue(report[label]["installed"]["installed_exactly"], label) + self.assertTrue(report[label]["restored"]["verified"], label) + self.assertEqual(fake.applied, ["auto", "auto", "none", "auto", "auto", "auto"]) + self.assertTrue(report["stabilized_install_and_restoration_exact"]) + + def test_the_stabilized_verdict_is_false_when_the_stabilized_install_is_not_exact(self): + class Flipping(AliasingNative): + def apply_dacl(self, path, sddl, protection="auto"): + super().apply_dacl(path, sddl, protection) + if "(D;" in sddl: + self.dacl = self.dacl.replace("D:PAI", "D:AI", 1) + report = native_acl.installation_observation(Flipping(dacl=PROTECTED), MACHINE, + lambda label: Path(label), lambda path: None) + self.assertFalse(report["stabilized_install_and_restoration_exact"]) + self.assertEqual(report["stabilized_protected_auto_flag"]["installed"]["control"]["after"], "AI") + + def test_a_failing_shape_is_recorded_with_its_type_only(self): + def fail(label): + raise OSError("secret path") + report = native_acl.installation_observation(AliasingNative(), MACHINE, fail, lambda path: None) + self.assertEqual(report["unprotected_inherited_auto_flag"], {"error": {"type": "OSError", "code": None}}) + self.assertNotIn("secret", json.dumps(report)) + self.assertFalse(report["stabilized_install_and_restoration_exact"]) + + +class PerFixtureNative(AliasingNative): + """One descriptor per fixture, keyed by the directory name. A write that holds no deny (a + restoration) misbehaves for the fixtures in ``bad`` in the manner ``how``; a write that holds + the deny (an installation) raises for ``install_raises`` and reads back with the protection + flag toggled for ``inexact``.""" + + def __init__(self, bad=(), how="protection", install_raises=(), inexact=()): + super().__init__() + self.state, self.owners = {}, {} + self.bad, self.how = set(bad), how + self.install_raises, self.inexact = set(install_raises), set(inexact) + + @staticmethod + def key(path): + return Path(path).name + + def read_dacl(self, path): + key = self.key(path) + return self.state.setdefault(key, PROTECTED if key.startswith("stabilized") else BASE) + + def read_owner(self, path): + return self.owners.get(self.key(path), "O:BA") + + @staticmethod + def toggled(text): + return text.replace("D:PAI", "D:AI", 1) if text.startswith("D:PAI") else text.replace("D:AI", "D:PAI", 1) + + def apply_dacl(self, path, sddl, protection="auto"): + key, installing = self.key(path), "(D;" in sddl + if installing and key in self.install_raises: + raise OSError("install refused") + stored = re.sub(r";;;(S-1-[0-9-]+)\)", lambda m: ";;;" + ALIASES.get(m.group(1), m.group(1)) + ")", sddl) + if installing and key in self.inexact: + stored = self.toggled(stored) + if not installing and key in self.bad: + if self.how == "raises": + raise OSError("restore refused") + if self.how == "ignored": + return + if self.how == "protection": + stored = self.toggled(stored) + elif self.how == "owner": + self.owners[key] = "O:SY" + elif self.how == "extra_ace": + stored += "(A;;FA;;;WD)" + self.state[key] = stored + + +LABELS = ("unprotected_inherited_auto_flag", "unprotected_inherited_no_flag", "stabilized_protected_auto_flag") +STABILIZED = "stabilized_protected_auto_flag" +RESTORATION_FAILURES = ("protection", "owner", "extra_ace", "ignored", "raises") + + +class InstallationRestorationTests(unittest.TestCase): + """The restoration of EVERY attempted fixture is verified on its own; an installation that + reads back inexactly is a different fact from a restoration that cannot be shown.""" + + @staticmethod + def observe(native): + return native_acl.installation_observation(native, MACHINE, lambda label: Path(label), lambda path: None) + + def test_a_clean_run_verifies_every_restoration(self): + report = self.observe(PerFixtureNative()) + self.assertIs(report["restoration_verified"], True) + self.assertEqual(report["restoration_unverified"], []) + for label in LABELS: + self.assertIs(report[label]["restoration_verified"], True, label) + self.assertTrue(report[label]["installed"]["installed_exactly"], label) + self.assertIs(report["stabilized_install_and_restoration_exact"], True) + self.assertIs(native_acl.installation_section_verified(report), True) + + def test_a_restoration_failure_of_any_one_fixture_is_unverified_for_that_fixture_alone(self): + for label in LABELS: + for how in RESTORATION_FAILURES: + with self.subTest(label=label, how=how): + report = self.observe(PerFixtureNative(bad=(label,), how=how)) + self.assertEqual(report["restoration_unverified"], [label]) + self.assertIs(report["restoration_verified"], False) + self.assertIs(report[label]["restoration_verified"], False) + for other in LABELS: + if other != label: + self.assertIs(report[other]["restoration_verified"], True, other) + self.assertFalse(native_acl.installation_section_verified(report)) + self.assertIs(report["stabilized_install_and_restoration_exact"], label != STABILIZED) + self.assertTrue(report[label]["installed"]["installed_exactly"]) + if how == "raises": + self.assertEqual(report[label]["error"], {"type": "OSError", "code": None}) + self.assertNotIn("restored", report[label]) + else: + self.assertNotIn("error", report[label]) + self.assertIs(report[label]["restored"]["verified"], False) + self.assertNotIn("refused", json.dumps(report)) + + def test_the_stabilized_aggregate_alone_misses_an_unprotected_restoration_failure(self): + for label in LABELS[:2]: + for how in RESTORATION_FAILURES: + with self.subTest(label=label, how=how): + report = self.observe(PerFixtureNative(bad=(label,), how=how)) + self.assertIs(report["stabilized_install_and_restoration_exact"], True) + self.assertIs(native_acl.installation_section_verified(report), False) + + def test_two_failed_restorations_are_both_listed(self): + report = self.observe(PerFixtureNative(bad=(LABELS[0], LABELS[2]), how="owner")) + self.assertEqual(report["restoration_unverified"], [LABELS[0], LABELS[2]]) + + def test_an_inexact_unprotected_installation_with_a_verified_restoration_is_not_a_restoration_failure(self): + report = self.observe(PerFixtureNative(inexact=LABELS[:2])) + for label in LABELS[:2]: + self.assertIs(report[label]["installed"]["installed_exactly"], False, label) + self.assertIs(report[label]["restoration_verified"], True, label) + self.assertIs(report["restoration_verified"], True) + self.assertEqual(report["restoration_unverified"], []) + self.assertIs(native_acl.installation_section_verified(report), True) + + def test_an_inexact_stabilized_installation_stays_unclean_with_a_verified_restoration(self): + report = self.observe(PerFixtureNative(inexact=(STABILIZED,))) + self.assertIs(report[STABILIZED]["installed"]["installed_exactly"], False) + self.assertIs(report["restoration_verified"], True) + self.assertIs(report["stabilized_install_and_restoration_exact"], False) + self.assertIs(native_acl.installation_section_verified(report), False) + + def test_an_installation_that_raises_is_recorded_and_its_restoration_is_still_verified(self): + for label in LABELS: + with self.subTest(label): + report = self.observe(PerFixtureNative(install_raises=(label,))) + self.assertEqual(report[label]["error"], {"type": "OSError", "code": None}) + self.assertNotIn("installed", report[label]) + self.assertIs(report[label]["restored"]["verified"], True) + self.assertIs(report["restoration_verified"], True) + self.assertIs(native_acl.installation_section_verified(report), label != STABILIZED) + + def test_an_installation_that_raises_and_a_restoration_that_cannot_be_shown_is_unverified(self): + for label in LABELS: + with self.subTest(label): + report = self.observe(PerFixtureNative(install_raises=(label,), bad=(label,), how="raises")) + self.assertEqual(report[label]["error"], {"type": "OSError", "code": None}) + self.assertEqual(report["restoration_unverified"], [label]) + self.assertIs(native_acl.installation_section_verified(report), False) + + def test_a_fixture_that_fails_before_anything_is_captured_has_nothing_to_restore(self): + def fail_one(label): + if label == LABELS[1]: + raise OSError("no directory") + return Path(label) + report = native_acl.installation_observation(PerFixtureNative(), MACHINE, fail_one, lambda path: None) + self.assertEqual(report[LABELS[1]], {"error": {"type": "OSError", "code": None}}) + self.assertEqual(report["restoration_unverified"], []) + self.assertIs(report["restoration_verified"], True) + self.assertIs(native_acl.installation_section_verified(report), True) + + def cannot_stabilize(path): + raise OSError("no stabilization") + report = native_acl.installation_observation( + PerFixtureNative(), MACHINE, lambda label: Path(label), cannot_stabilize) + self.assertNotIn("restoration_verified", report[STABILIZED]) + self.assertIs(report["stabilized_install_and_restoration_exact"], False) + self.assertIs(native_acl.installation_section_verified(report), False) + + def test_the_section_verdict_needs_both_facts_to_be_exactly_true(self): + for body, clean in (({}, False), + ({"stabilized_install_and_restoration_exact": True}, False), + ({"restoration_verified": True}, False), + ({"stabilized_install_and_restoration_exact": True, "restoration_verified": False}, False), + ({"stabilized_install_and_restoration_exact": False, "restoration_verified": True}, False), + ({"stabilized_install_and_restoration_exact": 1, "restoration_verified": True}, False), + ({"stabilized_install_and_restoration_exact": True, "restoration_verified": "yes"}, False), + ({"stabilized_install_and_restoration_exact": True, "restoration_verified": True}, True)): + with self.subTest(body): + self.assertIs(native_acl.installation_section_verified(body), clean) + + +class FakeProcessApi: + """Scripted kernel seam for ``run_contained``: every call is recorded, every result scripted.""" + + def __init__(self, *, job=(100, None), start=(200, 300, None), assign=(True, None), + resume=(True, None), waits=((0, None),), terminate_job=(True, None), + terminate_process=(True, None), exit_code=(True, 0, None), active=((0, None),), + close_fails=(), raise_on_wait=None): + self.script = dict(job=job, start=start, assign=assign, resume=resume, + terminate_job=terminate_job, terminate_process=terminate_process, + exit_code=exit_code) + self.waits, self.active, self.close_fails = list(waits), list(active), set(close_fails) + self.raise_on_wait = raise_on_wait + self.calls, self.retained = [], [] + + def _next(self, queue): + return queue.pop(0) if len(queue) > 1 else queue[0] + + def create_job(self): + self.calls.append("create_job") + return self.script["job"] + + def start(self): + self.calls.append("start") + return self.script["start"] + + def assign(self, job, process): + self.calls.append("assign") + return self.script["assign"] + + def resume(self, thread): + self.calls.append("resume") + return self.script["resume"] + + def wait(self, handle, milliseconds): + self.calls.append(("wait", milliseconds)) + if self.raise_on_wait: + raise self.raise_on_wait + return self._next(self.waits) + + def terminate_job(self, job, code): + self.calls.append("terminate_job") + return self.script["terminate_job"] + + def terminate_process(self, process, code): + self.calls.append("terminate_process") + return self.script["terminate_process"] + + def exit_code(self, process): + self.calls.append("exit_code") + return self.script["exit_code"] + + def active_processes(self, job): + self.calls.append("active") + return self._next(self.active) + + def close(self, handle): + self.calls.append(("close", handle)) + return handle not in self.close_fails + + def pause(self, milliseconds): + self.calls.append("pause") + + def retain(self, handles): + self.retained.extend(handles) + + def closed(self): + return sorted(call[1] for call in self.calls if isinstance(call, tuple) and call[0] == "close") + + +class CausalProcessApi(FakeProcessApi): + """Kernel seam whose process state follows from what was actually done to it: a job + termination stops only a process assigned to that job (an empty job ends nothing), a direct + termination stops the process only when it succeeds and is effective, and an unassigned + suspended child is never signaled, and a job never holds it, until it is terminated directly.""" + + def __init__(self, *, raise_on_assign=None, terminate_process_ineffective=False, + exits_after_resume=True, **script): + super().__init__(**script) + self.raise_on_assign, self.terminate_process_ineffective = raise_on_assign, terminate_process_ineffective + self.exits_after_resume = exits_after_resume + self.assigned = self.resumed = self.signaled = False + self.stopped_by = None + + def assign(self, job, process): + if self.raise_on_assign: + self.calls.append("assign") + raise self.raise_on_assign + result = super().assign(job, process) + self.assigned = bool(result[0]) + return result + + def resume(self, thread): + result = super().resume(thread) + self.resumed = bool(result[0]) + if self.resumed and self.exits_after_resume: + self.signaled, self.stopped_by = True, "exit" + return result + + def wait(self, handle, milliseconds): + self.calls.append(("wait", milliseconds)) + if self.raise_on_wait: + raise self.raise_on_wait + return (0, None) if self.signaled else (native_acl.WAIT_TIMEOUT, None) + + def terminate_job(self, job, code): + result = super().terminate_job(job, code) + if result[0] and self.assigned and not self.signaled: + self.signaled, self.stopped_by = True, "terminate_job" + return result + + def terminate_process(self, process, code): + result = super().terminate_process(process, code) + if result[0] and not self.terminate_process_ineffective and not self.signaled: + self.signaled, self.stopped_by = True, "terminate_process" + return result + + def active_processes(self, job): + self.calls.append("active") + return (1 if self.assigned and not self.signaled else 0), None + + +class UnassignedChildTests(unittest.TestCase): + """A child that was started suspended but could not be assigned to the job is owned and alive + outside it: only a direct ``TerminateProcess`` stops it, and only a final wait that sees it + signaled establishes that.""" + + def run_it(self, **script): + api = CausalProcessApi(**script) + report, retained = native_acl.run_contained(api, 5.0, grace_ms=300) + return api, report, retained + + def test_the_model_does_not_signal_an_unassigned_child_when_the_empty_job_is_terminated(self): + api = CausalProcessApi(assign=(False, 5)) + api.start() + self.assertEqual(api.assign(100, 200), (False, 5)) + self.assertEqual(api.terminate_job(100, 1), (True, None)) + self.assertEqual((api.wait(200, 10)[0], api.active_processes(100)[0]), (native_acl.WAIT_TIMEOUT, 0)) + api.terminate_process(200, 1) + self.assertEqual((api.wait(200, 10)[0], api.stopped_by), (0, "terminate_process")) + + def test_an_unassigned_child_is_terminated_directly_and_seen_signaled(self): + api, report, retained = self.run_it(assign=(False, 5)) + self.assertEqual(api.stopped_by, "terminate_process") + self.assertNotIn("resume", api.calls) + self.assertIn("terminate_process", api.calls) + self.assertNotIn("terminate_job", api.calls) + self.assertEqual((report["launched"], report["completed"], report["terminated"], + report["final_wait"], report["child_stopped"], report["cleanup_verified"], + report["handles_retained"]), (False, False, True, "signaled", True, True, False)) + self.assertIn({"api": "AssignProcessToJobObject", "code": 5}, report["errors"]) + self.assertEqual((retained, api.closed()), ([], [100, 200, 300])) + + def test_an_unassigned_child_whose_direct_termination_fails_is_not_stopped_and_stays_owned(self): + api, report, retained = self.run_it(assign=(False, 5), terminate_process=(False, 5)) + self.assertFalse(api.signaled) + self.assertEqual((report["terminated"], report["final_wait"], report["child_stopped"], + report["cleanup_verified"], report["handles_retained"]), + (False, "timeout", False, False, True)) + self.assertEqual(report["terminate_errors"], [{"api": "TerminateProcess", "code": 5}]) + self.assertEqual((sorted(retained), api.closed()), ([100, 200, 300], [])) + + def test_a_direct_termination_that_reports_success_but_leaves_the_child_unsignaled_is_not_stopped(self): + api, report, retained = self.run_it(assign=(False, 5), terminate_process_ineffective=True) + self.assertEqual((report["terminated"], report["final_wait"], report["child_stopped"], + report["cleanup_verified"], report["handles_retained"]), + (True, "timeout", False, False, True)) + self.assertEqual((sorted(retained), api.closed()), ([100, 200, 300], [])) + + def test_the_job_termination_stops_an_assigned_child_that_cannot_be_resumed(self): + api, report, retained = self.run_it(resume=(False, 5)) + self.assertEqual(api.stopped_by, "terminate_job") + self.assertNotIn("terminate_process", api.calls) + self.assertEqual((report["launched"], report["child_stopped"], report["cleanup_verified"], retained), + (False, True, True, [])) + + def test_a_child_that_outlives_its_wait_is_stopped_by_the_job_termination(self): + api, report, _ = self.run_it(exits_after_resume=False) + self.assertEqual((api.stopped_by, report["timed_out"], report["child_stopped"]), + ("terminate_job", True, True)) + self.assertNotIn("terminate_process", api.calls) + + def test_an_assigned_child_whose_job_termination_fails_falls_back_to_the_process(self): + api, report, _ = self.run_it(exits_after_resume=False, terminate_job=(False, 5)) + self.assertEqual((api.stopped_by, report["child_stopped"]), ("terminate_process", True)) + self.assertEqual(report["terminate_errors"], [{"api": "TerminateJobObject", "code": 5}]) + + def test_an_assignment_that_raises_terminates_the_child_directly_and_keeps_the_handles(self): + api = CausalProcessApi(raise_on_assign=RuntimeError("boom")) + with self.assertRaises(RuntimeError): + native_acl.run_contained(api, 5.0) + self.assertEqual(api.stopped_by, "terminate_process") + self.assertNotIn("terminate_job", api.calls) + self.assertEqual((sorted(api.retained), api.closed()), ([100, 200, 300], [])) + + def test_a_wait_that_raises_after_assignment_terminates_the_job_and_keeps_the_handles(self): + api = CausalProcessApi(exits_after_resume=False, raise_on_wait=RuntimeError("boom")) + with self.assertRaises(RuntimeError): + native_acl.run_contained(api, 5.0) + self.assertEqual(api.stopped_by, "terminate_job") + self.assertEqual((sorted(api.retained), api.closed()), ([100, 200, 300], [])) + + +class RunContainedTests(unittest.TestCase): + def run_it(self, **script): + api = FakeProcessApi(**script) + report, retained = native_acl.run_contained(api, 5.0, grace_ms=300) + return api, report, retained + + def test_a_process_that_exits_zero_in_time_is_completed_and_every_handle_is_closed(self): + api, report, retained = self.run_it() + self.assertEqual((report["launched"], report["completed"], report["exit_code"], + report["timed_out"], report["cleanup_verified"]), (True, True, 0, False, True)) + self.assertEqual((retained, api.closed()), ([], [100, 200, 300])) + self.assertEqual(report["errors"], []) + self.assertNotIn("terminate_job", api.calls) + self.assertLess(api.calls.index("assign"), api.calls.index("resume")) + + def test_child_stopped_is_true_only_when_the_process_is_seen_signaled_and_the_job_is_empty(self): + for label, script, stopped in ( + ("exits in time", {}, True), + ("timeout then signaled", dict(waits=((native_acl.WAIT_TIMEOUT, None), (0, None)), + exit_code=(True, 1, None)), True), + ("never signaled", dict(waits=((native_acl.WAIT_TIMEOUT, None), (native_acl.WAIT_TIMEOUT, None)), + terminate_job=(False, 5), terminate_process=(False, 5)), False), + ("final wait failed", dict(waits=((native_acl.WAIT_TIMEOUT, None), (native_acl.WAIT_FAILED, 6))), False), + ("descendant remains", dict(active=((1, None),)), False), + ("job cannot be queried", dict(active=((None, 6),)), False), + ("exit code unreadable", dict(exit_code=(False, None, 6)), True), + ("still-active placeholder after the wait", dict(exit_code=(True, native_acl.STILL_ACTIVE, None)), True), + ("handle will not close", dict(close_fails=(200,)), True), + ("job not created", dict(job=(None, 5)), True), + ("process not created", dict(start=(None, None, 2)), True)): + with self.subTest(label): + _, report, _ = self.run_it(**script) + self.assertIs(report["child_stopped"], stopped, report) + + def test_a_timeout_terminates_the_job_and_is_never_a_completed_run(self): + api, report, retained = self.run_it(waits=((native_acl.WAIT_TIMEOUT, None), (0, None)), + exit_code=(True, 1, None)) + self.assertEqual((report["timed_out"], report["wait_failed"], report["terminated"], + report["final_wait"], report["completed"], report["cleanup_verified"]), + (True, False, True, "signaled", False, True)) + self.assertIn("terminate_job", api.calls) + + def test_a_failed_wait_is_reported_with_its_error_and_is_not_a_timeout(self): + api, report, _ = self.run_it(waits=((native_acl.WAIT_FAILED, 6), (0, None)), + exit_code=(True, 1, None)) + self.assertEqual((report["wait_failed"], report["timed_out"], report["completed"]), (True, False, False)) + self.assertIn({"api": "WaitForSingleObject", "code": 6}, report["errors"]) + self.assertIn("terminate_job", api.calls) + + def test_a_process_that_survives_termination_keeps_its_handles_and_fails_cleanup(self): + api, report, retained = self.run_it( + waits=((native_acl.WAIT_TIMEOUT, None), (native_acl.WAIT_TIMEOUT, None)), + terminate_job=(False, 5), terminate_process=(False, 5), active=((1, None),)) + self.assertEqual((report["completed"], report["cleanup_verified"], report["handles_retained"], + report["final_wait"]), (False, False, True, "timeout")) + self.assertIs(report["terminated"], False) + self.assertEqual(sorted(retained), [100, 200, 300]) + self.assertEqual(api.closed(), []) + self.assertEqual({item["api"] for item in report["terminate_errors"]}, + {"TerminateJobObject", "TerminateProcess"}) + + def test_a_job_termination_that_fails_falls_back_to_the_process_and_is_reported(self): + api, report, _ = self.run_it(waits=((native_acl.WAIT_TIMEOUT, None), (0, None)), + terminate_job=(False, 5), exit_code=(True, 1, None)) + self.assertEqual((report["terminated"], report["terminate_errors"]), + (True, [{"api": "TerminateJobObject", "code": 5}])) + self.assertIn("terminate_process", api.calls) + + def test_a_failed_final_wait_is_not_a_verified_cleanup(self): + _, report, retained = self.run_it( + waits=((native_acl.WAIT_TIMEOUT, None), (native_acl.WAIT_FAILED, 6)), active=((0, None),)) + self.assertEqual((report["final_wait"], report["cleanup_verified"], report["handles_retained"]), + ("failed", False, True)) + self.assertTrue(retained) + + def test_the_still_active_placeholder_is_never_an_exit_code(self): + _, report, retained = self.run_it(exit_code=(True, native_acl.STILL_ACTIVE, None)) + self.assertEqual((report["still_active"], report["exit_code"], report["completed"], + report["cleanup_verified"]), (True, None, False, False)) + self.assertTrue(retained) + + def test_an_exit_code_that_cannot_be_read_is_reported_and_not_completed(self): + _, report, _ = self.run_it(exit_code=(False, None, 6)) + self.assertEqual((report["exit_code"], report["completed"]), (None, False)) + self.assertIn({"api": "GetExitCodeProcess", "code": 6}, report["errors"]) + + def test_a_nonzero_exit_is_reported_as_is(self): + _, report, _ = self.run_it(exit_code=(True, 3, None)) + self.assertEqual((report["exit_code"], report["completed"], report["cleanup_verified"]), (3, True, True)) + + def test_a_descendant_left_in_the_job_is_terminated_and_the_run_is_not_completed(self): + api, report, retained = self.run_it(active=((2, None), (1, None), (0, None))) + self.assertEqual((report["descendants_terminated"], report["completed"], + report["job_active_processes"], report["cleanup_verified"]), (True, False, 0, True)) + self.assertIn("terminate_job", api.calls) + self.assertEqual(retained, []) + + def test_a_descendant_that_cannot_be_removed_fails_cleanup_and_keeps_the_job(self): + _, report, retained = self.run_it(active=((1, None),)) + self.assertEqual((report["job_active_processes"], report["cleanup_verified"], report["handles_retained"]), + (1, False, True)) + self.assertIn(100, retained) + + def test_a_job_that_cannot_be_queried_is_not_a_verified_cleanup(self): + _, report, _ = self.run_it(active=((None, 6),)) + self.assertEqual((report["job_active_processes"], report["cleanup_verified"]), (None, False)) + self.assertIn({"api": "QueryInformationJobObject", "code": 6}, report["errors"]) + + def test_a_process_that_cannot_be_contained_is_never_resumed_and_is_terminated(self): + api = CausalProcessApi(assign=(False, 5)) + report, _ = native_acl.run_contained(api, 5.0, grace_ms=300) + self.assertEqual((report["launched"], report["completed"]), (False, False)) + self.assertNotIn("resume", api.calls) + self.assertIn("terminate_process", api.calls) + self.assertEqual(api.stopped_by, "terminate_process") + self.assertIn({"api": "AssignProcessToJobObject", "code": 5}, report["errors"]) + + def test_a_process_that_cannot_be_resumed_is_terminated_and_not_launched(self): + api, report, _ = self.run_it(resume=(False, 5)) + self.assertEqual((report["launched"], report["completed"]), (False, False)) + self.assertIn("terminate_job", api.calls) + self.assertNotIn(("wait", 5000), api.calls) + + def test_a_job_that_cannot_be_created_starts_nothing(self): + api, report, retained = self.run_it(job=(None, 5)) + self.assertEqual((report["launched"], report["winerror"], retained), (False, 5, [])) + self.assertNotIn("start", api.calls) + + def test_a_launch_that_fails_reports_its_error_and_closes_the_job(self): + api, report, retained = self.run_it(start=(None, None, 2)) + self.assertEqual((report["launched"], report["winerror"], retained), (False, 2, [])) + self.assertEqual(api.closed(), [100]) + self.assertNotIn("assign", api.calls) + + def test_a_handle_that_cannot_be_closed_fails_cleanup_and_stays_owned(self): + _, report, retained = self.run_it(close_fails=(200,)) + self.assertEqual((report["cleanup_verified"], report["completed"], report["handles_retained"], + retained), (False, False, True, [200])) + + def test_an_unexpected_error_terminates_the_job_keeps_the_handles_and_propagates(self): + api = FakeProcessApi(raise_on_wait=RuntimeError("boom")) + with self.assertRaises(RuntimeError): + native_acl.run_contained(api, 5.0) + self.assertIn("terminate_job", api.calls) + self.assertEqual(sorted(api.retained), [100, 200, 300]) + self.assertEqual(api.closed(), []) + + +class FakePrivilegeApi: + def __init__(self, attributes, *, fail=(), ignore_restore=False, enable_noop=False, + side_effect=None, fail_readback_after=None): + self.attributes, self.fail, self.ignore_restore = dict(attributes), set(fail), ignore_restore + self.enable_noop, self.side_effect = enable_noop, side_effect + self.fail_readback_after, self.reads, self.adjustments = fail_readback_after, 0, [] + + def privilege_attributes(self): + self.reads += 1 + if self.fail_readback_after is not None and self.reads > self.fail_readback_after: + raise native_acl.NativeError("GetTokenInformation", 5) + return dict(self.attributes) + + def adjust_privilege(self, name, attributes): + self.adjustments.append((name, attributes)) + step = "enable" if attributes & 2 else "restore" + if step in self.fail: + raise native_acl.NativeError("AdjustTokenPrivileges", 5) + if (step == "restore" and self.ignore_restore) or (step == "enable" and self.enable_noop): + return + self.attributes[name] = (self.attributes[name] & ~2) | (attributes & 2) + if self.side_effect and step == "restore": + other, value = self.side_effect + self.attributes[other] = value + + +class PrivilegeScopeTests(unittest.TestCase): + NAME = "SeIncreaseQuotaPrivilege" + + def api(self, **kwargs): + return FakePrivilegeApi({self.NAME: 0, "SeBackupPrivilege": 3, "SeDebugPrivilege": 0}, **kwargs) + + def test_a_disabled_privilege_is_enabled_inside_and_disabled_again(self): + api = self.api() + with native_acl.privilege_scope(api, self.NAME): + self.assertTrue(api.attributes[self.NAME] & 2) + self.assertEqual(api.attributes, {self.NAME: 0, "SeBackupPrivilege": 3, "SeDebugPrivilege": 0}) + + def test_an_enabled_privilege_stays_enabled_afterwards(self): + api = self.api() + with native_acl.privilege_scope(api, "SeBackupPrivilege"): + pass + self.assertEqual(api.attributes["SeBackupPrivilege"], 3) + self.assertEqual(api.adjustments[-1], ("SeBackupPrivilege", 2)) + + def test_the_used_for_access_bit_does_not_count_as_a_change(self): + api = FakePrivilegeApi({self.NAME: 0}) + with native_acl.privilege_scope(api, self.NAME): + api.attributes[self.NAME] |= 0x80000000 + api.attributes[self.NAME] |= 0x80000000 + self.assertEqual(api.attributes[self.NAME] & 7, 0) + + def test_a_body_error_propagates_and_the_token_is_restored(self): + api = self.api() + with self.assertRaises(ValueError): + with native_acl.privilege_scope(api, self.NAME): + raise ValueError("body") + self.assertEqual(api.attributes[self.NAME], 0) + + def test_a_restoration_that_fails_raises_even_over_a_body_error(self): + api = self.api(fail=("restore",)) + with self.assertRaises(native_acl.PrivilegeRestorationError) as caught: + with native_acl.privilege_scope(api, self.NAME): + raise ValueError("body") + self.assertIsInstance(caught.exception.__context__, ValueError) + self.assertIn("restore_failed", caught.exception.details["problems"]) + self.assertEqual(caught.exception.details["changed"], [self.NAME]) + + def test_a_restoration_that_reports_success_but_changes_nothing_is_not_trusted(self): + api = self.api(ignore_restore=True) + with self.assertRaises(native_acl.PrivilegeRestorationError) as caught: + with native_acl.privilege_scope(api, self.NAME): + pass + self.assertEqual(caught.exception.details["problems"], ["inventory_changed"]) + + def test_another_privilege_that_changed_is_a_change(self): + api = self.api(side_effect=("SeDebugPrivilege", 2)) + with self.assertRaises(native_acl.PrivilegeRestorationError) as caught: + with native_acl.privilege_scope(api, self.NAME): + pass + self.assertEqual(caught.exception.details["changed"], ["SeDebugPrivilege"]) + + def test_an_inventory_that_cannot_be_read_back_is_not_a_verified_restoration(self): + api = self.api(fail_readback_after=2) + with self.assertRaises(native_acl.PrivilegeRestorationError) as caught: + with native_acl.privilege_scope(api, self.NAME): + pass + self.assertIn("readback_failed", caught.exception.details["problems"]) + + def test_an_absent_privilege_changes_nothing_and_says_so(self): + api = self.api() + with self.assertRaises(native_acl.NativeError) as caught: + with native_acl.privilege_scope(api, "SeNoSuchPrivilege"): + self.fail("must not run") + self.assertEqual(caught.exception.api, "privilege_absent") + self.assertEqual(api.adjustments, []) + + def test_an_enable_that_does_not_take_effect_never_runs_the_body_and_restores(self): + api = self.api(enable_noop=True) + with self.assertRaises(native_acl.NativeError): + with native_acl.privilege_scope(api, self.NAME): + self.fail("must not run") + self.assertEqual(api.attributes[self.NAME], 0) + + def test_an_enable_that_fails_is_reported_as_that_failure(self): + api = self.api(fail=("enable",)) + with self.assertRaises(native_acl.NativeError) as caught: + with native_acl.privilege_scope(api, self.NAME): + self.fail("must not run") + self.assertNotIsInstance(caught.exception, native_acl.PrivilegeRestorationError) + + +class StubC: + """The few ctypes names the process adapter uses, with the Windows-only error slot faked.""" + + def __init__(self): + import ctypes + self.__dict__.update(Structure=ctypes.Structure, c_void_p=ctypes.c_void_p, c_longlong=ctypes.c_longlong, + c_ulonglong=ctypes.c_ulonglong, c_size_t=ctypes.c_size_t, sizeof=ctypes.sizeof, + byref=ctypes.byref, create_unicode_buffer=ctypes.create_unicode_buffer) + self.error = 0 + + def set_last_error(self, value): + self.error = value + + def get_last_error(self): + return self.error + + +class StubNative: + def __init__(self, results, *, restoration_error=None, enable_error=None): + from ctypes import wintypes + self.c, self.w = StubC(), wintypes + self.results, self.entered, self.exited = list(results), 0, 0 + self.restoration_error, self.enable_error = restoration_error, enable_error + self.advapi = self + self.kernel = self + + def CreateProcessAsUserW(self, *args): + created = args[-1]._obj + code, handles = self.results.pop(0) + self.c.error = code + if handles: + created.hProcess, created.hThread = handles + return True + return False + + def privilege_enabled(self, name): + stub = self + from contextlib import contextmanager + + @contextmanager + def scope(): + if stub.enable_error: + raise stub.enable_error + stub.entered += 1 + try: + yield + finally: + stub.exited += 1 + if stub.restoration_error: + raise stub.restoration_error + return scope() + + +class ProcessApiLaunchTests(unittest.TestCase): + def api(self, native): + return native_acl._ProcessApi(native, 1, ["python"], ".") + + def test_a_launch_that_needs_the_quota_privilege_retries_once_inside_the_scope(self): + native = StubNative([(1314, None), (0, (7, 8))]) + api = self.api(native) + self.assertEqual(api.start(), (7, 8, None)) + self.assertEqual((api.quota_enabled, native.entered, native.exited, api.restoration_error), (True, 1, 1, None)) + + def test_any_other_launch_error_is_final_and_enables_nothing(self): + native = StubNative([(2, None)]) + api = self.api(native) + self.assertEqual(api.start(), (None, None, 2)) + self.assertEqual((api.quota_enabled, native.entered), (False, 0)) + + def test_a_privilege_that_cannot_be_enabled_leaves_the_original_failure(self): + native = StubNative([(1314, None)], enable_error=native_acl.NativeError("AdjustTokenPrivileges", 1300)) + api = self.api(native) + self.assertEqual(api.start(), (None, None, 1314)) + self.assertFalse(api.quota_enabled) + + def test_a_failed_privilege_restoration_after_a_started_process_keeps_the_process_and_is_recorded(self): + failure = native_acl.PrivilegeRestorationError({"privilege": "x", "problems": ["inventory_changed"], + "changed": ["x"]}) + native = StubNative([(1314, None), (0, (7, 8))], restoration_error=failure) + api = self.api(native) + self.assertEqual(api.start(), (7, 8, None)) + self.assertIs(api.restoration_error, failure) + + +class ChildEvidenceTests(unittest.TestCase): + GOOD_LAUNCH = {"launched": True, "completed": True, "exit_code": 0, "timed_out": False, + "cleanup_verified": True, "child_stopped": True} + + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="jraphyte-child-evidence-") + self.addCleanup(self.temp.cleanup) + self.out = Path(self.temp.name) / "child.json" + + def report(self, body): + self.out.write_text(body if isinstance(body, str) else json.dumps(body), encoding="utf-8") + + GOOD = {"filesystem": {"type": "NTFS"}, "enabled_privileges": [], "disabled_privileges": [], + "parent_dacl": "D:P", "access_check": {}, "mkdir": {}, "mkdir_CreateDirectoryW": {}, + "file_control": {}, "non_denial_outcomes": [], "probe_cleanup_verified": True, + "verdict": "DENIED", "cause_established": False} + + def test_a_complete_launch_with_a_valid_report_has_no_reasons(self): + self.report(self.GOOD) + child, reasons = native_acl.child_evidence(self.GOOD_LAUNCH, self.out) + self.assertEqual((child, reasons), (self.GOOD, [])) + + def test_each_defect_is_a_named_reason_and_none_is_a_pass(self): + cases = { + "launch_failed": ({**self.GOOD_LAUNCH, "launched": False}, self.GOOD), + "timed_out": ({**self.GOOD_LAUNCH, "timed_out": True}, self.GOOD), + "wait_incomplete": ({**self.GOOD_LAUNCH, "wait_failed": True}, self.GOOD), + "nonzero_or_missing_exit": ({**self.GOOD_LAUNCH, "exit_code": 1}, self.GOOD), + "launch_not_completed": ({**self.GOOD_LAUNCH, "completed": False}, self.GOOD), + "launch_cleanup_unverified": ({**self.GOOD_LAUNCH, "cleanup_verified": False}, self.GOOD), + "child_stop_unestablished": ({**self.GOOD_LAUNCH, "child_stopped": False}, self.GOOD), + "child_report_malformed": (self.GOOD_LAUNCH, "{not json"), + "child_reported_error": (self.GOOD_LAUNCH, {"error": {"type": "OSError", "code": None}}), + "child_verdict_missing_or_unknown": (self.GOOD_LAUNCH, {**self.GOOD, "verdict": "MAYBE"}), + "child_cleanup_unverified": (self.GOOD_LAUNCH, {**self.GOOD, "probe_cleanup_verified": False}), + "child_report_incomplete": (self.GOOD_LAUNCH, {"verdict": "DENIED", "probe_cleanup_verified": True}), + } + for reason, (launch, body) in cases.items(): + with self.subTest(reason): + self.report(body) + _, reasons = native_acl.child_evidence(launch, self.out) + self.assertIn(reason, reasons) + + def test_the_exit_code_alone_is_never_acceptance(self): + self.assertEqual(native_acl.launch_defects(self.GOOD_LAUNCH), []) + for field, value, reason in ( + ("handles_retained", True, "handles_retained"), + ("privilege_restoration", {"verified": False}, "privilege_restoration_unverified"), + ("errors", [{"api": "CloseHandle", "code": None}], "launch_errors"), + ("still_active", True, "wait_incomplete"), + ("completed", False, "launch_not_completed"), + ("cleanup_verified", False, "launch_cleanup_unverified"), + ("child_stopped", False, "child_stop_unestablished"), + ("child_stopped", None, "child_stop_unestablished")): + with self.subTest(field): + self.assertIn(reason, native_acl.launch_defects({**self.GOOD_LAUNCH, field: value})) + self.assertEqual(native_acl.launch_defects({**self.GOOD_LAUNCH, "privilege_restoration": {"verified": True}}), []) + + def test_a_missing_report_is_a_reason(self): + _, reasons = native_acl.child_evidence(self.GOOD_LAUNCH, self.out) + self.assertEqual(reasons, ["child_report_missing"]) + + def test_a_report_that_is_not_an_object_is_an_error_report(self): + self.report("[1]") + child, reasons = native_acl.child_evidence(self.GOOD_LAUNCH, self.out) + self.assertEqual((child, reasons), (None, ["child_reported_error"])) + + def test_no_report_that_is_not_a_complete_object_is_evidence_including_json_null(self): + for label, text in (("null", "null"), ("number", "0"), ("string", '"DENIED"'), ("true", "true"), + ("array", "[]"), ("array of one report", json.dumps([self.GOOD]))): + with self.subTest(label): + self.report(text) + child, reasons = native_acl.child_evidence(self.GOOD_LAUNCH, self.out) + self.assertEqual((child, reasons), (None, ["child_reported_error"])) + for label, body in (("empty object", {}), + ("verdict and cleanup only", {"verdict": "DENIED", "probe_cleanup_verified": True}), + ("only the fields it names", {"cause_established": False})): + with self.subTest(label): + self.report(body) + _, reasons = native_acl.child_evidence(self.GOOD_LAUNCH, self.out) + self.assertTrue(reasons, label) + for field in native_acl.CHILD_REPORT_FIELDS: + with self.subTest("without " + field): + self.report({key: value for key, value in self.GOOD.items() if key != field}) + _, reasons = native_acl.child_evidence(self.GOOD_LAUNCH, self.out) + self.assertTrue(reasons, field) + if field not in ("verdict", "probe_cleanup_verified"): + self.assertEqual(reasons, ["child_report_incomplete"]) + self.assertEqual(set(native_acl.CHILD_REPORT_FIELDS), set(self.GOOD)) + + def test_a_report_with_only_a_launch_defect_free_null_is_never_complete_evidence(self): + for text in ("null", "0", "[]", "{}"): + with self.subTest(text): + self.report(text) + _, reasons = native_acl.child_evidence(self.GOOD_LAUNCH, self.out) + self.assertNotEqual(reasons, []) + + def test_the_section_separates_restoration_from_launch_evidence(self): + class Launcher(FakeNative): + def __init__(self, launch, body, **kwargs): + super().__init__(dacl=PROTECTED, **kwargs) + self.launch, self.body = launch, body + + def run_with_restricted_primary_token(self, argv, cwd, timeout=900.0): + if self.body is not None: + Path(argv[-1]).write_text( + self.body if isinstance(self.body, str) else json.dumps(self.body), encoding="utf-8") + return self.launch + base = Path(self.temp.name) + + count = iter(range(100)) + + def make(label): + path = base / ("%s-%d" % (label, next(count))) + path.mkdir() + return path + done = native_acl.restricted_primary_child_report( + Launcher(self.GOOD_LAUNCH, self.GOOD), make, MACHINE, lambda path: None) + self.assertEqual((done["restoration_verified"], done["evidence_complete"], done["not_run"]), + (True, True, [])) + self.assertIs(done["cause_established"], False) + leaky = native_acl.restricted_primary_child_report( + Launcher(self.GOOD_LAUNCH, self.GOOD, full_creates=True, unremoved=("directory",)), + make, MACHINE, lambda path: None) + self.assertEqual((leaky["restoration_verified"], leaky["evidence_complete"]), (False, True)) + failed = native_acl.restricted_primary_child_report( + Launcher({"launched": False, "winerror": 2, "child_stopped": True}, None), make, MACHINE, + lambda path: None) + self.assertEqual((failed["restoration_verified"], failed["evidence_complete"]), (True, False)) + self.assertIn("launch_failed", failed["not_run"]) + self.assertIn("child_report_missing", failed["not_run"]) + for label, body in (("null", "null"), ("number", "0"), ("array", "[]"), ("empty", {}), + ("incomplete", {"verdict": "DENIED", "probe_cleanup_verified": True}), + ("error", {"error": {"type": "OSError", "code": None}})): + with self.subTest("section with a " + label + " report"): + bad = native_acl.restricted_primary_child_report( + Launcher(self.GOOD_LAUNCH, body), make, MACHINE, lambda path: None) + self.assertEqual((bad["restoration_verified"], bad["evidence_complete"]), (True, False)) + self.assertTrue(bad["not_run"]) + self.assertFalse(native_acl.child_section_verified(bad)) + + def test_a_child_that_may_still_run_leaves_the_fixture_and_asks_the_caller_to_keep_it(self): + class Launcher(FakeNative): + def __init__(self, launch, error=None): + super().__init__(dacl=PROTECTED) + self.launch, self.error, self.restores, self.launched = launch, error, 0, False + + def run_with_restricted_primary_token(self, argv, cwd, timeout=900.0): + self.launched = True + if self.error: + raise self.error + return self.launch + + def apply_dacl(self, path, sddl, protection="auto"): + self.restores += self.launched + return super().apply_dacl(path, sddl, protection) + base = Path(self.temp.name) + count = iter(range(100)) + + def make(label): + path = base / ("%s-%d" % (label, next(count))) + path.mkdir() + return path + for label, launch in (("not stopped", {**self.GOOD_LAUNCH, "child_stopped": False, + "cleanup_verified": False, "handles_retained": True}), + ("stop not reported", {key: value for key, value in self.GOOD_LAUNCH.items() + if key != "child_stopped"})): + with self.subTest(label): + hold, native = [], Launcher(launch) + done = native_acl.restricted_primary_child_report( + native, make, MACHINE, lambda path: None, hold=hold) + self.assertEqual(native.restores, 0) + self.assertEqual(hold, ["child_stop_unestablished"]) + self.assertEqual((done["fixture_retained"], done["restoration_verified"], + done["evidence_complete"]), (True, False, False)) + self.assertEqual(done["restored"], {"verified": False, "skipped": "child_stop_unestablished"}) + self.assertIn("child_stop_unestablished", done["not_run"]) + with self.subTest("launcher raised"): + hold, native = [], Launcher(None, RuntimeError("boom")) + with self.assertRaises(RuntimeError): + native_acl.restricted_primary_child_report(native, make, MACHINE, lambda path: None, hold=hold) + self.assertEqual((native.restores, hold), (0, ["child_stop_unestablished"])) + for label, launch in (("stopped", self.GOOD_LAUNCH), + ("never started", {"launched": False, "winerror": 2, "child_stopped": True})): + with self.subTest(label): + hold, native = [], Launcher(launch) + done = native_acl.restricted_primary_child_report( + native, make, MACHINE, lambda path: None, hold=hold) + self.assertEqual((native.restores, hold, done["fixture_retained"]), (1, [], False)) + self.assertTrue(done["restored"]["verified"]) + + def test_the_section_requires_a_report_object_whatever_the_evidence_reasons_say(self): + from unittest import mock + + class Launcher(FakeNative): + def __init__(self): + super().__init__(dacl=PROTECTED) + + def run_with_restricted_primary_token(self, argv, cwd, timeout=900.0): + return ChildEvidenceTests.GOOD_LAUNCH + base = Path(self.temp.name) + count = iter(range(100)) + + def make(label): + path = base / ("%s-%d" % (label, next(count))) + path.mkdir() + return path + for report in (None, [], 0, "DENIED"): + with self.subTest(repr(report)): + with mock.patch.object(native_acl, "child_evidence", return_value=(report, [])): + done = native_acl.restricted_primary_child_report( + Launcher(), make, MACHINE, lambda path: None) + self.assertEqual((done["evidence_complete"], done["child_verdict"]), (False, None)) + self.assertIsNone(done["child"]) + self.assertFalse(native_acl.child_section_verified(done)) + + def test_the_standalone_section_is_clean_only_with_restoration_and_complete_evidence(self): + for restored, complete, clean in ((True, True, True), (True, False, False), + (False, True, False), (False, False, False)): + self.assertIs(native_acl.child_section_verified( + {"restoration_verified": restored, "evidence_complete": complete}), clean) + + def run_main(self, child, **overrides): + from unittest import mock + import contextlib + import io + import src.paper_pilot_phase_cutover as cutover + good = {"matrix": {"cleanup_verified": True}, "actual_fence_shape": {"verified": True}, + "privilege_isolation": {"verified": True}, + "installation_observation": {"stabilized_install_and_restoration_exact": True, + "restoration_verified": True}} + good.update(overrides) + patches = [mock.patch.object(native_acl, "bootstrap_checkout"), + mock.patch.object(native_acl, "WindowsNative"), + mock.patch.object(cutover, "_current_sid", return_value=MACHINE)] + for name, section in (("candidate_matrix", "matrix"), + ("probe_actual_fence_shape", "actual_fence_shape"), + ("privilege_isolation_on_fence_shape", "privilege_isolation"), + ("installation_observation", "installation_observation")): + patches.append(mock.patch.object(native_acl, name, side_effect=self.producer(good[section]))) + patches.append(mock.patch.object(native_acl, "restricted_primary_child_report", + side_effect=self.producer(child))) + out = io.StringIO() + with contextlib.ExitStack() as stack: + for patch in patches: + stack.enter_context(patch) + stack.enter_context(contextlib.redirect_stdout(out)) + code = native_acl.main([]) + return code, json.loads(out.getvalue()) + + @staticmethod + def producer(body): + if callable(body): + return body + + def produce(*args, **kwargs): + if isinstance(body, Exception): + raise body + return body + return produce + + def test_main_exit_and_json_follow_the_restricted_primary_section_at_runtime(self): + complete = {"restoration_verified": True, "evidence_complete": True, "not_run": []} + code, report = self.run_main(complete) + self.assertEqual((code, report["cleanup_verified"]), (0, True)) + self.assertEqual(report["completed_stages"][-1], "restricted_primary_child") + self.assertEqual(report["restricted_primary_child"], complete) + for body in ({"restoration_verified": True, "evidence_complete": False, + "not_run": ["launch_failed"]}, + {"restoration_verified": False, "evidence_complete": True, "not_run": []}, + {"restoration_verified": False, "evidence_complete": False, + "not_run": ["child_report_missing"]}): + code, report = self.run_main(body) + self.assertEqual((code, report["cleanup_verified"]), (1, False), body) + self.assertIn("restricted_primary_child", report["completed_stages"]) + code, report = self.run_main(RuntimeError("launch")) + self.assertEqual((code, report["cleanup_verified"]), (1, False)) + self.assertEqual(report["restricted_primary_child"]["error"]["type"], "RuntimeError") + self.assertNotIn("restricted_primary_child", report["completed_stages"]) + + def test_main_exit_and_json_fail_closed_on_a_null_or_incomplete_child_report(self): + real_section = native_acl.restricted_primary_child_report + class Launcher(FakeNative): + def __init__(self, text, **kwargs): + super().__init__(dacl=PROTECTED, **kwargs) + self.text = text + + def run_with_restricted_primary_token(self, argv, cwd, timeout=900.0): + Path(argv[-1]).write_text(self.text, encoding="utf-8") + return ChildEvidenceTests.GOOD_LAUNCH + for label, text, reason in (("null", "null", "child_reported_error"), + ("number", "7", "child_reported_error"), + ("empty", "{}", "child_report_incomplete"), + ("incomplete", json.dumps({"verdict": "DENIED", "probe_cleanup_verified": True}), + "child_report_incomplete")): + with self.subTest(label): + def section(native, make_directory, sid, stabilize, hold=None, text=text): + return real_section(Launcher(text), make_directory, sid, lambda path: None, hold=hold) + code, report = self.run_main(section) + self.assertEqual((code, report["cleanup_verified"]), (1, False)) + self.assertEqual(report["restricted_primary_child"].get("error"), None, report) + self.assertFalse(report["restricted_primary_child"]["evidence_complete"]) + self.assertIn(reason, report["restricted_primary_child"]["not_run"]) + + def test_main_keeps_the_fixture_root_while_a_child_may_still_run(self): + roots = [] + + def producer(native, make_directory, sid, stabilize, hold=None): + roots.append(make_directory("probe").parent) + hold.append("child_stop_unestablished") + return {"restoration_verified": False, "evidence_complete": False, + "not_run": ["child_stop_unestablished"], "fixture_retained": True} + code, report = self.run_main(producer) + self.addCleanup(shutil.rmtree, roots[0], ignore_errors=True) + self.assertEqual((code, report["cleanup_verified"], report["fixture_retained"]), (1, False, True)) + self.assertTrue(roots[0].is_dir() and (roots[0] / "probe").is_dir()) + self.assertNotIn(str(roots[0]), json.dumps(report)) + roots.clear() + + def finished(native, make_directory, sid, stabilize, hold=None): + roots.append(make_directory("probe").parent) + return {"restoration_verified": True, "evidence_complete": True, "not_run": []} + code, report = self.run_main(finished) + self.assertEqual((code, report["cleanup_verified"], "fixture_retained" in report), (0, True, False)) + self.assertFalse(roots[0].exists()) + roots.clear() + + def failed(native, make_directory, sid, stabilize, hold=None): + roots.append(make_directory("probe").parent) + hold.append("child_stop_unestablished") + raise RuntimeError("launcher raised") + code, report = self.run_main(failed) + self.addCleanup(shutil.rmtree, roots[0], ignore_errors=True) + self.assertEqual((code, report["cleanup_verified"], report["fixture_retained"]), (1, False, True)) + self.assertTrue(roots[0].is_dir()) + + def test_a_held_fixture_is_unclean_even_when_the_section_body_looks_complete(self): + roots = [] + + def producer(native, make_directory, sid, stabilize, hold=None): + roots.append(make_directory("probe").parent) + hold.append("child_stop_unestablished") + return {"restoration_verified": True, "evidence_complete": True, "not_run": []} + code, report = self.run_main(producer) + self.addCleanup(shutil.rmtree, roots[0], ignore_errors=True) + self.assertEqual((code, report["cleanup_verified"], report["fixture_retained"]), (1, False, True)) + + def test_main_stays_unclean_when_any_earlier_section_is_unverified(self): + complete = {"restoration_verified": True, "evidence_complete": True, "not_run": []} + for section, body in (("matrix", {"cleanup_verified": False}), + ("actual_fence_shape", {"verified": False}), + ("privilege_isolation", {"verified": False}), + ("installation_observation", + {"stabilized_install_and_restoration_exact": False, "restoration_verified": True}), + ("installation_observation", + {"stabilized_install_and_restoration_exact": True, "restoration_verified": False}), + ("installation_observation", {"stabilized_install_and_restoration_exact": True})): + code, report = self.run_main(complete, **{section: body}) + self.assertEqual((code, report["cleanup_verified"]), (1, False), (section, body)) + + def installation_through_main(self, native): + real = native_acl.installation_observation + + def section(_native, sid, make_directory, _stabilize): + return real(native, sid, make_directory, lambda path: None) + complete = {"restoration_verified": True, "evidence_complete": True, "not_run": []} + return self.run_main(complete, installation_observation=section) + + def test_main_is_clean_when_every_fixture_restoration_is_verified(self): + code, report = self.installation_through_main(PerFixtureNative()) + self.assertEqual((code, report["cleanup_verified"]), (0, True)) + self.assertIs(report["installation_observation"]["restoration_verified"], True) + self.assertIn("installation_observation", report["completed_stages"]) + + def test_main_is_unclean_for_each_selective_restoration_failure_or_exception(self): + for label in LABELS: + for how in RESTORATION_FAILURES: + with self.subTest(label=label, how=how): + code, report = self.installation_through_main(PerFixtureNative(bad=(label,), how=how)) + section = report["installation_observation"] + self.assertEqual((code, report["cleanup_verified"]), (1, False)) + self.assertEqual(section["restoration_unverified"], [label]) + self.assertIs(section["restoration_verified"], False) + self.assertNotIn("error", section) + self.assertIn("installation_observation", report["completed_stages"]) + self.assertEqual(report["completed_stages"][-1], "restricted_primary_child") + self.assertNotIn("fixture_retained", report) + self.assertNotIn("refused", json.dumps(report)) + + def test_main_stays_clean_for_an_inexact_unprotected_installation_that_was_restored(self): + code, report = self.installation_through_main(PerFixtureNative(inexact=LABELS[:2])) + self.assertEqual((code, report["cleanup_verified"]), (0, True)) + self.assertIs(report["installation_observation"][LABELS[0]]["installed"]["installed_exactly"], False) + + def test_main_stays_unclean_for_an_inexact_stabilized_installation_even_when_restored(self): + code, report = self.installation_through_main(PerFixtureNative(inexact=(STABILIZED,))) + self.assertEqual((code, report["cleanup_verified"]), (1, False)) + self.assertIs(report["installation_observation"]["restoration_verified"], True) + + def test_main_is_unclean_when_an_installation_raises_and_its_restoration_cannot_be_shown(self): + code, report = self.installation_through_main( + PerFixtureNative(install_raises=(LABELS[0],), bad=(LABELS[0],), how="raises")) + self.assertEqual((code, report["cleanup_verified"]), (1, False)) + self.assertEqual(report["installation_observation"]["restoration_unverified"], [LABELS[0]]) + + +class LaunchingNative: + """Stands in for ``WindowsNative`` where only the restricted-primary launch outcome matters.""" + + def __init__(self, outcome): + self.outcome, self.launches = outcome, [] + + def __call__(self): + return self + + def read_dacl(self, path): + return BASE + + def run_with_restricted_primary_token(self, argv, cwd, timeout=900.0): + self.launches.append((list(argv), timeout)) + if isinstance(self.outcome, BaseException): + raise self.outcome + return dict(self.outcome) + + +STOPPED = {"launched": True, "completed": True, "child_stopped": True} +UNSTOPPED = {"launched": True, "completed": False, "child_stopped": False} +ACCOUNT = "S-1-5-21-4444444444-5555555555-6666666666-1001" +NEVER_STARTED = {"launched": False, "completed": False, "child_stopped": True, "winerror": 2} + + +def run_case(case_class, name): + result = unittest.TestResult() + unittest.TestSuite([case_class(name)]).run(result) + return result + + +class ChildStopFixtureOwnershipTests(unittest.TestCase): + """The direct launcher consumers restore and remove their fixtures only after the launch shows + its child stopped. These are portable models of the teardown; whether a Windows child really + outlives a failed assertion was never observed.""" + + def test_the_hold_follows_the_launch_outcome(self): + for label, outcome, reasons in ( + ("stopped", STOPPED, []), + ("failed before the process was created", NEVER_STARTED, []), + ("child not shown stopped", UNSTOPPED, ["child_stop_unestablished"]), + ("child_stopped absent", {"launched": True}, ["child_stop_unestablished"]), + ("child_stopped truthy but not True", {"child_stopped": 1}, ["child_stop_unestablished"]), + ("launcher raised", RuntimeError("boom"), ["launcher_raised"])): + with self.subTest(label): + hold = native_acl.ChildStopHold() + native = LaunchingNative(outcome) + if isinstance(outcome, BaseException): + with self.assertRaises(RuntimeError): + hold.launch(native, ["x"], Path("."), timeout=3) + else: + self.assertEqual(hold.launch(native, ["x"], Path("."), timeout=3), outcome) + self.assertEqual((hold.reasons, hold.held), (reasons, bool(reasons))) + self.assertEqual(native.launches, [(["x"], 3)]) + + def test_a_second_clean_launch_does_not_clear_an_earlier_hold(self): + hold = native_acl.ChildStopHold() + hold.launch(LaunchingNative(UNSTOPPED), ["x"], Path(".")) + hold.launch(LaunchingNative(STOPPED), ["x"], Path(".")) + self.assertEqual(hold.reasons, ["child_stop_unestablished"]) + + def test_settle_restores_then_removes_only_when_nothing_is_held(self): + temp = tempfile.TemporaryDirectory(prefix="jraphyte-hold-") + events = [] + native_acl.ChildStopHold().settle(temp, lambda: events.append(Path(temp.name).is_dir())) + self.assertEqual(events, [True]) + self.assertFalse(Path(temp.name).exists()) + + def test_settle_keeps_the_directory_restores_nothing_and_survives_collection_when_held(self): + import gc + temp = tempfile.TemporaryDirectory(prefix="jraphyte-hold-") + root = Path(temp.name) + self.addCleanup(shutil.rmtree, root, ignore_errors=True) + (root / "state").mkdir() + hold, restored = native_acl.ChildStopHold(), [] + hold.launch(LaunchingNative(UNSTOPPED), ["x"], root) + with self.assertRaises(AssertionError) as caught: + hold.settle(temp, lambda: restored.append(True)) + self.assertIn("child_stop_unestablished", str(caught.exception)) + self.assertEqual(restored, []) + self.assertFalse(temp._finalizer.alive) + del temp, caught + gc.collect() + self.assertTrue((root / "state").is_dir()) + + def test_a_failed_restoration_keeps_the_directory_and_its_finalizer_detached(self): + temp = tempfile.TemporaryDirectory(prefix="jraphyte-hold-") + root = Path(temp.name) + self.addCleanup(shutil.rmtree, root, ignore_errors=True) + + def failing(): + raise AssertionError("restoration unverified") + with self.assertRaisesRegex(AssertionError, "restoration unverified"): + native_acl.ChildStopHold().settle(temp, failing) + self.assertTrue(root.is_dir()) + self.assertFalse(temp._finalizer.alive) + + def descriptor_case(self, outcome, name="test_probe"): + roots, removed = [], [] + + class Probe(NativeDescriptorTests): + __unittest_skip__ = False + + def setUp(case): + super().setUp() + roots.append(Path(case.temp.name)) + (roots[0] / "parent" / "marker").write_text("x", encoding="utf-8") + + def test_probe(case): + case.deny(FOREIGN) + case.hold.launch(case.native, ["child"], roots[0], timeout=1) + native = LaunchingNative(outcome) + with mock.patch.object(native_acl, "WindowsNative", native), \ + mock.patch.object(windows_fixture_acl, "stabilize_owned_fixture"), \ + mock.patch.object(native_acl, "deny_by_descriptor", + return_value={"installed_exactly": True, "components": {}}), \ + mock.patch.object(native_acl, "deny_present", return_value=True), \ + mock.patch.object(native_acl, "remove_descriptor_deny", + side_effect=lambda *a: removed.append(a[2]) or True): + result = run_case(Probe, name) + self.addCleanup(shutil.rmtree, roots[0], ignore_errors=True) + return result, roots[0], removed + + def test_the_native_descriptor_tests_restore_and_remove_after_a_stopped_or_never_started_child(self): + for label, outcome in (("stopped", STOPPED), ("never started", NEVER_STARTED)): + with self.subTest(label): + result, root, removed = self.descriptor_case(outcome) + self.assertEqual((result.errors, result.failures), ([], [])) + self.assertEqual(removed, [FOREIGN]) + self.assertFalse(root.exists()) + + def test_the_native_descriptor_tests_keep_the_fixture_and_fail_when_the_child_may_still_run(self): + for label, outcome in (("not shown stopped", UNSTOPPED), ("launcher raised", RuntimeError("boom"))): + with self.subTest(label): + result, root, removed = self.descriptor_case(outcome) + problems = " ".join(text for _, text in result.errors + result.failures) + self.assertIn("fixture retained", problems) + self.assertEqual(removed, []) + self.assertTrue((root / "parent" / "marker").is_file()) + + def test_each_native_launcher_test_routes_its_launch_through_the_hold(self): + for name in ("test_a_child_with_a_restricted_primary_token_reports_through_a_file", + "test_a_child_that_outlives_its_timeout_is_terminated_and_is_not_a_completed_run", + "test_a_descendant_the_child_leaves_behind_is_contained_and_terminated", + "test_a_child_that_never_starts_reports_its_windows_error"): + for label, outcome in (("not shown stopped", UNSTOPPED), ("launcher raised", RuntimeError("boom"))): + with self.subTest(name, outcome=label): + result, root, removed = self.descriptor_case(outcome, name) + problems = " ".join(text for _, text in result.errors + result.failures) + self.assertIn("fixture retained", problems) + self.assertEqual(removed, []) + self.assertTrue((root / "parent" / "marker").is_file()) + + def owned_tree_case(self, outcome): + calls, roots = [], [] + + class Probe(unittest.TestCase): + def test_probe(case): + tree = v1_v4.OwnedTree(case) + roots.append(tree.base) + (tree.base / "state").mkdir() + tree.hold.launch(LaunchingNative(outcome), ["child"], tree.base, timeout=1) + with mock.patch.object(v1_v4, "_call", side_effect=lambda *a: calls.append(a)), \ + mock.patch.object(v1_v4, "_current_sid", return_value=ACCOUNT): + result = run_case(Probe, "test_probe") + self.addCleanup(shutil.rmtree, roots[0], ignore_errors=True) + return result, roots[0], calls + + def test_the_owned_tree_lifts_acls_and_removes_itself_after_a_stopped_child(self): + result, root, calls = self.owned_tree_case(STOPPED) + self.assertEqual((result.errors, result.failures), ([], [])) + self.assertTrue(calls and all(call[0] == "icacls.exe" for call in calls)) + self.assertFalse(root.exists()) + + def test_the_owned_tree_touches_nothing_and_fails_when_the_child_may_still_run(self): + for label, outcome in (("not shown stopped", UNSTOPPED), ("launcher raised", RuntimeError("boom"))): + with self.subTest(label): + result, root, calls = self.owned_tree_case(outcome) + problems = " ".join(text for _, text in result.errors + result.failures) + self.assertIn("fixture retained", problems) + self.assertEqual(calls, []) + self.assertTrue((root / "state").is_dir()) + + def strict_producer_case(self, outcome): + calls, roots = [], [] + + class Probe(v1_v4.V1FenceTrusteeTests): + __unittest_skip__ = False + + def setUp(case): + case.tree = v1_v4.OwnedTree(case) + roots.append(case.tree.base) + name = "test_the_strict_positive_producer_path_runs_in_a_restricted_primary_token_child" + with mock.patch.object(v1_v4, "_call", side_effect=lambda *a: calls.append(a)), \ + mock.patch.object(v1_v4, "_current_sid", return_value=ACCOUNT), \ + mock.patch.object(v1_v4.windows_native_acl, "WindowsNative", LaunchingNative(outcome)): + result = run_case(Probe, name) + self.addCleanup(shutil.rmtree, roots[0], ignore_errors=True) + return result, roots[0], calls + + def test_the_strict_producer_test_routes_its_launch_through_the_hold(self): + result, root, calls = self.strict_producer_case(UNSTOPPED) + problems = " ".join(text for _, text in result.errors + result.failures) + self.assertIn("child_stop_unestablished", problems) + self.assertIn("fixture retained", problems) + self.assertEqual(calls, []) + self.assertTrue(root.is_dir()) + result, root, calls = self.strict_producer_case(NEVER_STARTED) + problems = " ".join(text for _, text in result.errors + result.failures) + self.assertNotIn("fixture retained", problems) + self.assertIn("launched", problems) + self.assertFalse(root.exists()) + + +@unittest.skipUnless(WINDOWS, "real descriptor, token and access-check APIs") +class NativeDescriptorTests(unittest.TestCase): + def setUp(self): + self.native = native_acl.WindowsNative() + self.temp = tempfile.TemporaryDirectory(prefix="jraphyte-native-acl-") + self.hold = native_acl.ChildStopHold() + self.denied = [] + self.addCleanup(self.settle) + self.parent = Path(self.temp.name) / "parent" + self.parent.mkdir() + windows_fixture_acl.stabilize_owned_fixture( + Path(self.temp.name), [("parent", self.parent)], read=self.native.read_dacl) + + def settle(self): + self.hold.settle(self.temp, lambda: [self.remove(sid) for sid in reversed(self.denied)]) + + def deny(self, sid): + self.denied.append(sid) + done = native_acl.deny_by_descriptor(self.native, self.parent, sid) + self.assertTrue(done["installed_exactly"], + native_acl.components_message("installation", done["components"])) + return done + + def remove(self, sid): + if native_acl.deny_present(self.native, self.parent, sid): + self.assertTrue(native_acl.remove_descriptor_deny(self.native, self.parent, sid)) + + def test_an_unresolvable_sid_is_installed_read_back_exactly_and_removed(self): + before, owner = self.native.read_dacl(self.parent), self.native.read_owner(self.parent) + done = self.deny(FOREIGN) + ace_list = native_acl.split_dacl(self.native.read_dacl(self.parent))[1] + self.assertEqual(ace_list, [done["ace"]] + native_acl.split_dacl(before)[1]) + self.assertEqual(done["ace"].split(";"), ["(D", "", "LC", "", "", FOREIGN + ")"]) + self.assertEqual(self.native.read_owner(self.parent), owner) + self.assertTrue(native_acl.remove_descriptor_deny(self.native, self.parent, FOREIGN)) + self.assertEqual(native_acl.split_dacl(self.native.read_dacl(self.parent))[1], + native_acl.split_dacl(before)[1]) + self.assertTrue(native_acl.restoration(self.native, self.parent, before, owner)["verified"]) + + def test_the_token_lists_enabled_and_disabled_privileges_by_name(self): + privileges = dict(self.native.privileges()) + self.assertTrue(privileges["SeChangeNotifyPrivilege"]) + self.assertTrue(all(name.startswith("Se") and name.endswith("Privilege") for name in privileges)) + + def test_access_check_honours_a_descriptor_deny_of_the_running_account(self): + from src.paper_pilot_phase_cutover import _current_sid + sid = _current_sid() + for stripped in (True, False): + self.assertTrue(self.native.access_mask(self.parent, stripped) & native_acl.FILE_ADD_SUBDIRECTORY, + "the disposable parent must grant the right before any deny") + self.deny(sid) + for stripped in (True, False): + mask = self.native.access_mask(self.parent, stripped) + self.assertEqual(mask & native_acl.FILE_ADD_SUBDIRECTORY, 0, stripped) + self.assertTrue(mask & native_acl.FILE_ADD_FILE, stripped) + + def test_the_probe_sees_creation_and_removes_what_it_created_when_nothing_denies_it(self): + report = native_acl.recreation_report(self.native, self.parent, self.parent / "state") + for label in ("stripped", "full"): + self.assertEqual(report["mkdir"][label], {"created": True, "removed": True}, label) + self.assertEqual(report["mkdir_CreateDirectoryW"][label], {"created": True, "removed": True}, label) + self.assertEqual(report["file_control"][label], {"created": True, "removed": True}, label) + self.assertTrue(report["access_check"][label]["add_subdirectory"], label) + self.assertEqual(report["verdict"], "DACL_GRANTS_ADD_SUBDIRECTORY") + self.assertEqual(sorted(self.parent.iterdir()), []) + self.assertIn(report["filesystem"], ("NTFS", "ReFS")) + + def test_failures_that_are_not_access_denied_carry_their_own_windows_error(self): + missing = self.parent / "no-such-parent" / "child" + existing = self.parent / "already-there" + existing.mkdir() + for stripped in (True, False): + for route in ROUTES: + with self.subTest(stripped=stripped, route=route): + self.assertEqual(self.native.try_create_directory(missing, stripped, route), + {"created": False, "winerror": 3}) + self.assertEqual(self.native.try_create_directory(existing, stripped, route), + {"created": False, "winerror": 183}) + self.assertEqual(sorted(path.name for path in self.parent.iterdir()), ["already-there"]) + + def test_a_deny_that_windows_spells_as_an_alias_is_removed_by_its_full_sid(self): + before, owner = self.native.read_dacl(self.parent), self.native.read_owner(self.parent) + done = self.deny(USERS) + stored = native_acl.split_dacl(self.native.read_dacl(self.parent))[1] + self.assertEqual(stored[0], "(D;;LC;;;BU)") + self.assertNotIn(done["ace"], stored) + self.assertEqual(self.native.canonical_trustee(USERS), "BU") + with self.assertRaises(ValueError): + native_acl.without_ace(self.native.read_dacl(self.parent), done["ace"]) + self.assertTrue(native_acl.remove_descriptor_deny(self.native, self.parent, USERS)) + self.assertTrue(native_acl.restoration(self.native, self.parent, before, owner)["verified"]) + self.assertEqual(native_acl.split_dacl(self.native.read_dacl(self.parent)), + native_acl.split_dacl(before)) + + def test_removal_keeps_other_trustees_and_never_confuses_distinct_rid500_sids(self): + from src.paper_pilot_phase_cutover import _current_sid + match = re.fullmatch(r"(S-1-5-21-[0-9]+-[0-9]+-[0-9]+)-[0-9]+", _current_sid()) + if match is None: + self.skipTest("a machine or domain account is required to name this host's RID-500 SID") + local500 = match.group(1) + "-500" + before, owner = self.native.read_dacl(self.parent), self.native.read_owner(self.parent) + self.deny(FOREIGN) + self.deny(USERS) + self.deny(local500) + stored = native_acl.split_dacl(self.native.read_dacl(self.parent))[1] + self.assertEqual(stored[0], "(D;;LC;;;" + self.native.canonical_trustee(local500) + ")") + self.assertIn("(D;;LC;;;" + FOREIGN + ")", stored) + self.assertNotEqual(self.native.canonical_trustee(local500), FOREIGN) + self.assertTrue(native_acl.remove_descriptor_deny(self.native, self.parent, local500)) + self.assertTrue(native_acl.deny_present(self.native, self.parent, FOREIGN)) + self.assertTrue(native_acl.deny_present(self.native, self.parent, USERS)) + self.assertFalse(native_acl.deny_present(self.native, self.parent, local500)) + with self.assertRaises(ValueError): + native_acl.remove_descriptor_deny(self.native, self.parent, local500) + self.assertTrue(native_acl.remove_descriptor_deny(self.native, self.parent, FOREIGN)) + self.assertTrue(native_acl.deny_present(self.native, self.parent, USERS)) + self.assertTrue(native_acl.remove_descriptor_deny(self.native, self.parent, USERS)) + self.assertTrue(native_acl.restoration(self.native, self.parent, before, owner)["verified"]) + + def test_a_protected_dacl_stays_protected_through_install_and_removal(self): + control, aces = native_acl.split_dacl(self.native.read_dacl(self.parent)) + self.native.apply_dacl(self.parent, native_acl.join_dacl("PAI", aces)) + before, owner = self.native.read_dacl(self.parent), self.native.read_owner(self.parent) + self.assertTrue(native_acl.split_dacl(before)[0].startswith("P"), before) + self.deny(FOREIGN) + self.assertTrue(native_acl.split_dacl(self.native.read_dacl(self.parent))[0].startswith("P")) + self.assertTrue(native_acl.remove_descriptor_deny(self.native, self.parent, FOREIGN)) + self.assertTrue(native_acl.restoration(self.native, self.parent, before, owner)["verified"]) + + def test_a_stabilized_parent_installs_and_restores_exactly_with_per_component_facts(self): + before, owner = self.native.read_dacl(self.parent), self.native.read_owner(self.parent) + self.assertTrue(native_acl.split_dacl(before)[0].startswith("P"), before) + done = self.deny(FOREIGN) + components = done["components"] + self.assertTrue(components["installed_exactly"], native_acl.components_message("installation", components)) + self.assertTrue(components["control"]["unchanged"]) + self.assertEqual(components["aces"]["count_after"], components["aces"]["count_before"] + 1) + self.assertEqual((components["aces"]["unexpected_after"], components["aces"]["missing_after"]), ([], [])) + self.assertTrue(native_acl.remove_descriptor_deny(self.native, self.parent, FOREIGN)) + again = native_acl.restoration_components(self.native, self.parent, before, owner) + self.assertTrue(again["verified"], native_acl.components_message("restoration", again)) + self.assertNotIn("S-1-5-21-%s" % FOREIGN.split("-")[4], json.dumps(components)) + + def test_a_privilege_that_is_present_but_disabled_is_enabled_only_inside_the_context(self): + disabled = [name for name, enabled in self.native.privileges() if not enabled] + if not disabled: + self.skipTest("this token has no disabled privilege to enable") + name = disabled[0] + with self.native.privilege_enabled(name): + self.assertTrue(dict(self.native.privileges())[name]) + self.assertFalse(dict(self.native.privileges())[name]) + self.assertEqual(len(self.native.privilege_luid(name)), 8) + + def test_privilege_isolation_on_the_fence_shape_reports_without_a_cause_and_restores(self): + from src.paper_pilot_phase_cutover import _current_sid + counter = iter(range(1000)) + + def make(label): + path = Path(self.temp.name) / ("isolation-%d" % next(counter)) + path.mkdir() + windows_fixture_acl.stabilize_owned_fixture( + Path(self.temp.name), [(label, path)], read=self.native.read_dacl) + return path + done = native_acl.privilege_isolation_on_fence_shape(self.native, make, _current_sid()) + self.assertTrue(done["verified"], native_acl.components_message("isolation", done)) + isolation = done["isolation"] + self.assertIs(isolation["cause_established"], False) + self.assertEqual(sorted(isolation["outcomes"]["remove_only"]), isolation["enabled"]) + self.assertIn("SeChangeNotifyPrivilege", isolation["enabled"]) + for outcome in (isolation["outcomes"]["original_process_token"], + isolation["outcomes"]["impersonated_copy_all_removed"]): + self.assertIn("created" if outcome["created"] else "winerror", outcome) + + def test_a_child_with_a_restricted_primary_token_reports_through_a_file(self): + out = Path(self.temp.name) / "child.json" + target = self.parent / "state" + launch = self.hold.launch( + self.native, + [sys.executable, str(SCRIPT), "--child-recreation-probe", str(self.parent), str(target), str(out)], + Path(self.temp.name), timeout=120) + self.assertEqual(native_acl.launch_defects(launch), [], launch) + self.assertEqual({key: launch[key] for key in ("launched", "completed", "exit_code", "timed_out", + "cleanup_verified", "job_active_processes", + "handles_retained", "errors", "child_stopped")}, + {"launched": True, "completed": True, "exit_code": 0, "timed_out": False, + "cleanup_verified": True, "job_active_processes": 0, + "handles_retained": False, "errors": [], "child_stopped": True}, launch) + body = json.loads(out.read_text(encoding="utf-8")) + self.assertNotIn("error", body) + self.assertEqual(body["verdict"], "DACL_GRANTS_ADD_SUBDIRECTORY") + self.assertEqual(body["enabled_privileges"], ["SeChangeNotifyPrivilege"]) + self.assertFalse(target.exists()) + self.assertTrue(body["probe_cleanup_verified"]) + + def test_a_child_that_outlives_its_timeout_is_terminated_and_is_not_a_completed_run(self): + launch = self.hold.launch( + self.native, [sys.executable, "-c", "import time; time.sleep(120)"], Path(self.temp.name), timeout=2) + self.assertEqual((launch["launched"], launch["timed_out"], launch["completed"], launch["terminated"], + launch["final_wait"], launch["cleanup_verified"], launch["job_active_processes"], + launch["child_stopped"]), + (True, True, False, True, "signaled", True, 0, True), launch) + + def test_a_descendant_the_child_leaves_behind_is_contained_and_terminated(self): + code = ("import subprocess, sys; " + "subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(120)']); ") + launch = self.hold.launch( + self.native, [sys.executable, "-c", code], Path(self.temp.name), timeout=60) + self.assertEqual((launch["exit_code"], launch["descendants_terminated"], launch["completed"], + launch["cleanup_verified"], launch["job_active_processes"], + launch["child_stopped"]), + (0, True, False, True, 0, True), launch) + + def test_the_privilege_inventory_is_identical_after_a_scope_over_an_enabled_and_a_disabled_privilege(self): + def state(): + return {name: value & native_acl.PRIVILEGE_STATE_MASK + for name, value in self.native.privilege_attributes().items()} + before = state() + for name in sorted(before): + if name in ("SeChangeNotifyPrivilege", "SeIncreaseQuotaPrivilege"): + with self.native.privilege_enabled(name): + self.assertTrue(self.native.privilege_attributes()[name] & 2) + self.assertEqual(state(), before, name) + + def test_a_child_that_never_starts_reports_its_windows_error(self): + launch = self.hold.launch( + self.native, [str(Path(self.temp.name) / "no-such.exe")], Path(self.temp.name), timeout=5) + self.assertEqual((launch["launched"], launch["exit_code"]), (False, None)) + self.assertIn(launch["winerror"], (2, 3)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/installed_phase_authority_check.py b/tools/installed_phase_authority_check.py new file mode 100644 index 0000000..5b684fc --- /dev/null +++ b/tools/installed_phase_authority_check.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +"""Exercise the INSTALLED trace_gc wheel's phase authority and trustee handling. + +Run it with the wheel installed in a clean environment, from a directory outside the +checkout, in isolated mode so no checkout directory or PYTHONPATH can shadow the wheel:: + + python -I tools/installed_phase_authority_check.py \\ + --declared-source phase_authority.py= [--declared-source ...] [--require-windows] + +Each ``--declared-source NAME=SHA256`` names a file inside the installed ``trace_gc`` +package and the digest of the reviewed checkout source; the installed bytes must be +identical, proving the wheel under test was built from the reviewed source. Only the +standard library and ``trace_gc`` are imported (never ``src`` or a checkout path). + +Two groups of checks are reported separately: + +* ``portable_checks`` run on every platform: installation provenance, the fail-closed + off-Windows contract (a numeric SID is never guessed, alias text is never mapped back + to a numeric SID, the real ``PhaseAuthority`` constructor refuses to run). +* ``windows_checks`` run only on Windows: real DACL spelling by the operating system, + the real ``PhaseAuthority`` constructor over a real signed activation pointer, and + acceptance/rejection through ``write()`` and ``staging()`` with owned state unchanged + after every rejection. + +``native_windows`` is ``RUN`` or ``NOT_RUN``; off Windows nothing native is claimed. +The one deny for a fabricated foreign-domain RID-500 SID is installed and removed by numeric +descriptor operations (no account lookup, which ``icacls`` needs and cannot do for it) through +``tools/windows_native_acl.py``, loaded by path from beside this script as a source-only fixture +utility: it is never put on ``sys.path`` or counted as the wheel, its digest is reported, and +checks show it added no ``trace_gc``/``src``/``tools`` import and that every ``trace_gc`` module +still comes from the installed package. Only ACEs this script added beneath its own temporary +directory are ever removed, the removal is verified, and a failed removal fails the run. One +JSON object is printed; the exit status is 1 on any failed check, on unverified +cleanup, or (with ``--require-windows``) when not on Windows. +""" +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import sqlite3 +import subprocess +import sys +import tempfile + +USERS_SID = "S-1-5-32-545" # serialized by Windows as the alias BU +GUESTS_SID = "S-1-5-32-546" # an unrelated trustee, serialized as BG +FOREIGN_RID500_SID = "S-1-5-21-1111111111-2222222222-3333333333-500" # fabricated domain +PARENT_ACE = "(D;;LC;;;{sid})" +NAMES = ("checkpoint.sqlite3", "graph.sqlite3", "budget.sqlite3", + "application-journal.sqlite3", "controller.lock") +PREFIX = "jraphyte-installed-phase-" +RUN_ID = "installed-run" +LIMITS = {"retrieval_requests": 5, "model_calls": 5, "retries": 5, + "solver_expansions": 5, "review_actions": 5, "request_bytes": 5} + + +def call(*args: str) -> subprocess.CompletedProcess: + return subprocess.run(list(args), capture_output=True, text=True, timeout=30) + + +def icacls(*args: str) -> None: + if call("icacls.exe", *args).returncode != 0: + raise RuntimeError("icacls failed") + + +def current_sid() -> str: + out = call("whoami.exe", "/user", "/fo", "csv", "/nh").stdout.strip() + match = re.search(r'"(S-1-5-[0-9-]+)"\s*$', out) + if match is None: + raise RuntimeError("current SID unavailable") + return match.group(1) + + +def sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def load_fixture_utility(name: str = "windows_native_acl"): + """Load ``.py`` (default ``windows_native_acl.py``) from beside this script, by path, as a + source-only utility. + + It is fixture machinery (numeric-SID descriptor writes, which ``icacls`` cannot do for a SID + no account database knows), not part of the wheel under test. It is never put on + ``sys.path`` or in ``sys.modules`` and imports nothing but the standard library; the digest of + the file is reported and the load is reported as isolated only if ``sys.path`` is unchanged + and no ``trace_gc``, ``src`` or ``tools`` module appeared. + """ + path = Path(__file__).resolve().with_name(name + ".py") + paths, modules = list(sys.path), set(sys.modules) + spec = importlib.util.spec_from_file_location("jraphyte_fixture_" + name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + appeared = {name.split(".")[0] for name in set(sys.modules) - modules} + isolated = sys.path == paths and not appeared & {"trace_gc", "src", "tools"} + return module, {"sha256": sha(path.read_bytes()), "isolated": isolated} + + +def descriptor_fixture(): + utility, info = load_fixture_utility() + return utility, utility.WindowsNative(), info + + +def stabilize_parent(base_dir: Path, path: Path, read) -> None: + """Protect the owned fixture directory ``path`` (strictly inside ``base_dir``) with exactly the + reviewed explicit allows and verify it, so no later deny copies inherited ACEs into it. Raises + the fixture utility's error, which carries fixed problem codes only.""" + stabilizer, _ = load_fixture_utility("windows_fixture_acl") + stabilizer.stabilize_owned_fixture( + base_dir, [("old-app", path)], read=read, + run=lambda arguments, **_: call(*arguments)) + + +class Report: + def __init__(self): + self.data = {"python": sys.version.split()[0], "platform": sys.platform, + "native_windows": "RUN" if os.name == "nt" else "NOT_RUN", + "portable_checks": {}, "windows_checks": {}} + self.ok = True + self.group = "portable_checks" + + def check(self, name: str, passed) -> None: + self.data[self.group][name] = bool(passed) + self.ok = self.ok and bool(passed) + + +def portable(report: Report, declared: dict[str, str]) -> None: + from trace_gc import phase_authority as authority + from trace_gc.errors import ContractError + report.group = "portable_checks" + package = Path(authority.__file__).resolve().parent + parts = {part.lower() for part in package.parts} + report.check("imported_from_site_packages", "site-packages" in parts) + report.check("isolated_mode", bool(sys.flags.isolated)) + report.check("no_checkout_in_cwd", not (Path.cwd() / "src").exists() + and not (Path.cwd() / "trace_gc").exists()) + report.check("no_source_package_imported", + not any(name == "src" or name.startswith("src.") for name in sys.modules)) + report.check("no_checkout_on_sys_path", + not any((Path(item or ".") / "src" / "paper_pilot_phase_cutover.py").exists() + for item in sys.path)) + report.check("declared_sources_declared", bool(declared)) + digests = {} + for name, expected in sorted(declared.items()): + target = (package / name).resolve() + inside = target.is_relative_to(package) and target.is_file() + digests[name] = sha(target.read_bytes()) if inside else None + report.check("installed_matches_reviewed_source:" + name, digests[name] == expected) + report.data["installed_source_sha256"] = digests + + original = authority._is_windows + authority._is_windows = lambda: False + try: + def code(call_it): + try: + call_it() + except ContractError as error: + return error.code + return None + report.check("numeric_sid_fails_closed_without_windows", + code(lambda: authority.canonical_dacl_trustee(FOREIGN_RID500_SID)) + == "PHASE_PLATFORM") + report.check("numeric_query_against_alias_never_guessed", + code(lambda: authority.dacl_has_ace("D:(D;;LC;;;LA)", PARENT_ACE, + FOREIGN_RID500_SID)) == "PHASE_PLATFORM") + report.check("alias_text_never_mapped_without_windows", + authority.canonical_dacl_trustee("LA") == "LA" + and authority.dacl_has_ace("D:(D;;LC;;;LA)", PARENT_ACE, "LA") + and not authority.dacl_has_ace("D:(D;;LC;;;LA)", PARENT_ACE, "BA")) + finally: + authority._is_windows = original + if os.name != "nt": + report.check("phase_authority_constructor_refuses_off_windows", + code(lambda: authority.PhaseAuthority({}, None)) == "PHASE_PLATFORM") + + +def database_dump(path: Path) -> str: + connection = sqlite3.connect(f"file:{path.as_posix()}?mode=ro", uri=True) + try: + return "\n".join(connection.iterdump()) + finally: + connection.close() + + +def native(report: Report) -> None: + from trace_gc import phase_authority as authority + from trace_gc.budget import RunBudget + from trace_gc.canonical import bytes_digest, dumps + from trace_gc.errors import ContractError + from trace_gc.phase_authority import PhaseAuthority, binding_sha256 + from trace_gc.trust import IssuerPolicy, Signer, TrustStore + report.group = "windows_checks" + + runner = current_sid() + prefix = re.fullmatch(r"(S-1-5-21-[0-9]+-[0-9]+-[0-9]+)-[0-9]+", runner) + if prefix is None: + raise RuntimeError("machine/domain account SID required") + local_rid500 = prefix.group(1) + "-500" + plain = runner if not runner.endswith("-500") else prefix.group(1) + "-1001" + owned_sids = {USERS_SID, GUESTS_SID, FOREIGN_RID500_SID, runner, local_rid500, plain} + checkout_modules = {name for name in sys.modules if name.split(".")[0] in ("src", "tools")} + utility, descriptor, fixture_info = descriptor_fixture() + report.data["fixture_utility_sha256"] = fixture_info["sha256"] + _, stabilizer_info = load_fixture_utility("windows_fixture_acl") + report.data["fixture_acl_sha256"] = stabilizer_info["sha256"] + report.check("fixture_utility_loaded_without_path_or_import_changes", + fixture_info["isolated"] and stabilizer_info["isolated"]) + cleanup_failures = [] + + report.check("numeric_users_sid_is_serialized_as_alias", + authority.canonical_dacl_trustee(USERS_SID) == "BU") + report.check("foreign_rid500_stays_numeric", + authority.canonical_dacl_trustee(FOREIGN_RID500_SID) == FOREIGN_RID500_SID) + report.check("plain_account_stays_numeric", + authority.canonical_dacl_trustee(plain) == plain) + report.check("distinct_rid500_and_plain_never_collapse", + len({authority.canonical_dacl_trustee(s) + for s in (local_rid500, FOREIGN_RID500_SID, plain)}) == 3) + + base_dir = None + handles = [] + with tempfile.TemporaryDirectory(prefix=PREFIX) as temp: + base_dir = Path(temp) + parent = base_dir / "old-app" + old = parent / "state" + archived = base_dir / "archive" / "state" + phase = base_dir / "phase" + for directory in (parent, archived, phase): + directory.mkdir(parents=True) + (phase / "phase.lock").write_bytes(b"0") + try: + stabilize_parent(base_dir, parent, authority.directory_dacl_sddl) + report.check("owned_fixture_parent_stabilized_before_pins", True) + except Exception as error: # fixed codes only + report.data["fixture_problems"] = [list(item) for item in getattr(error, "problems", [])] + report.check("owned_fixture_parent_stabilized_before_pins", False) + raise RuntimeError("owned fixture parent was not stabilized") from None + files = {} + for name in NAMES: + body = ("installed:" + name).encode() + (archived / name).write_bytes(body) + files[name] = bytes_digest(body) + receipt = base_dir / "fence.json" + pointer = phase / "active.json" + signer = Signer.ephemeral("installed-phase-reviewer") + trust = TrustStore() + trust.enroll(signer.issuer, IssuerPolicy( + signer.public_key(), "installed-phase-reviewer", frozenset({"AUTHORIZATION"}), + frozenset({"PHASE_ACTIVATE"}), frozenset({"isolated-test"}), frozenset({"LIVE"}), + can_review=True)) + + def leftovers(path: Path) -> list[str]: + owned = {authority.canonical_dacl_trustee(sid) for sid in owned_sids} + users = authority.canonical_dacl_trustee(USERS_SID) + found = [] + for ace in authority.canonical_dacl_aces(authority.directory_dacl_sddl(path)): + fields = ace[1:-1].split(";") + if len(fields) == 6 and "ID" not in fields[1] and ( + (fields[0] == "D" and fields[5] in owned) + or (fields[0] == "A" and fields[5] == users)): + found.append(fields[0]) + return found + + def lift(path: Path, *, strict: bool = True) -> bool: + for sid in sorted(owned_sids - {FOREIGN_RID500_SID}): + call("icacls.exe", str(path), "/remove:d", "*" + sid) + call("icacls.exe", str(path), "/remove:g", "*" + USERS_SID) + exact = True + for _ in range(4): + if not utility.deny_present(descriptor, path, FOREIGN_RID500_SID): + break + exact = utility.remove_descriptor_deny(descriptor, path, FOREIGN_RID500_SID) and exact + clean = exact and not leftovers(path) and not utility.deny_present( + descriptor, path, FOREIGN_RID500_SID) + if strict and not clean: + raise RuntimeError("an owned ACE was not removed") + return clean + + def write(deny_sid, *, dacl_sha=None): + raw = authority.directory_dacl_sddl(parent) + body = {"version": "paper-pilot-phase-namespace-fence-v1", + "old_root_path": str(old), "archived_root_path": str(archived), + "old_parent_dacl_sha256": dacl_sha or bytes_digest(raw.encode("utf-8")), + "archived_files_sha256": files, "deny_sid": deny_sid, + "status": "OLD_PATH_FENCED_NEW_PHASE_ALLOWED"} + receipt.write_bytes((dumps(body) + "\n").encode("utf-8")) + return raw + + def binding(activation_sha): + return {"phase_id": "installed-phase", "run_id": RUN_ID, + "capsule_sha256": "a" * 64, "implementation_sha256": "b" * 64, + "activation_path": str(pointer), "activation_sha256": activation_sha, + "activation_epoch": 1, "phase_lock_path": str(phase / "phase.lock"), + "fence_receipt_path": str(receipt), + "fence_receipt_sha256": bytes_digest(receipt.read_bytes()), + "old_root_path": str(old), "archived_old_root_path": str(archived), + "capsule_path": str(phase / "capsule.json"), + "authorization_path": str(phase / "auth.json"), + "authorization_sha256": "c" * 64} + + def bound(*, activate: bool) -> PhaseAuthority: + probe = PhaseAuthority(binding("0" * 64), trust) + try: + signed = signer.issue("AUTHORIZATION", probe.activation_payload(), + lifetime_seconds=3600) + finally: + probe.close() + raw = (dumps(signed) + "\n").encode("utf-8") + instance = PhaseAuthority(binding(bytes_digest(raw)), trust) + handles.append(instance) + pointer.unlink(missing_ok=True) + if activate: + pointer.write_bytes(raw) + return instance + + def attempt(scope) -> str: + try: + with scope(): + return "entered" + except ContractError as error: + return error.code + except Exception as error: # bounded type only + return "other:" + type(error).__name__ + + def accepted() -> bool: + instance = bound(activate=True) + return attempt(instance.write) == "entered" and attempt(instance.staging) == "entered" + + def rejected() -> bool: + instance = bound(activate=True) + return attempt(instance.write) == "PHASE_FENCE" and attempt(instance.staging) == "PHASE_FENCE" + + def state_hash() -> str: + return sha(b"".join(bytes_digest((archived / name).read_bytes()).encode() + for name in NAMES) + authority.directory_dacl_sddl(parent).encode()) + + try: + icacls(str(parent), "/deny", "*" + USERS_SID + ":(AD)") + raw = write(USERS_SID) + report.check("real_dacl_spells_numeric_sid_as_alias", + "(D;;LC;;;BU)" in raw and USERS_SID not in raw) + report.check("alias_deny_accepted_write_and_staging", accepted()) + instance = bound(activate=True) + instance.check() + report.check("check_accepts_current_phase", True) + + inactive = bound(activate=False) + report.check("fenced_inactive_stages_but_rejects_current_write", + attempt(inactive.staging) == "entered" + and attempt(inactive.write) == "PHASE_INACTIVE") + + # State-unchanged proof: a persisted-binding ledger, consumed while current, + # is rejected and left byte-identical once the fence is invalid. + current = bound(activate=True) + ledger_path = phase / "budget.sqlite3" + ledger_path.unlink(missing_ok=True) + connection = sqlite3.connect(ledger_path) + try: + connection.execute("CREATE TABLE trace_phase_binding (id INTEGER PRIMARY KEY " + "CHECK(id=1), binding_sha256 TEXT NOT NULL)") + connection.execute("INSERT INTO trace_phase_binding VALUES (1,?)", + (binding_sha256(current.binding),)) + connection.commit() + finally: + connection.close() + ledger = RunBudget(ledger_path, RUN_ID, LIMITS, phase_authority=current) + try: + ledger.consume("model_calls") + before = database_dump(ledger_path) + (archived / "graph.sqlite3").write_bytes(b"changed") + try: + ledger.consume("model_calls") + consume_code = None + except ContractError as error: + consume_code = error.code + report.check("changed_archive_rejects_ledger_write", consume_code == "PHASE_FENCE") + report.check("rejected_write_leaves_ledger_unchanged", + database_dump(ledger_path) == before) + (archived / "graph.sqlite3").write_bytes(b"installed:graph.sqlite3") + finally: + ledger.close() + + write(GUESTS_SID) + report.check("other_trustee_rejected", rejected()) + write(USERS_SID, dacl_sha="0" * 64) + report.check("changed_raw_digest_rejected", rejected()) + write(USERS_SID) + (archived / "graph.sqlite3").write_bytes(b"changed") + report.check("changed_archive_bytes_rejected", rejected()) + (archived / "graph.sqlite3").write_bytes(b"installed:graph.sqlite3") + report.check("restored_archive_bytes_accepted_again", accepted()) + + for label, args in (("mask", ("/deny", "*" + USERS_SID + ":(WD)")), + ("inheritance_flag", ("/deny", "*" + USERS_SID + ":(OI)(AD)")), + ("ace_type", ("/grant", "*" + USERS_SID + ":(AD)"))): + lift(parent) + icacls(str(parent), *args) + write(USERS_SID) + snapshot = state_hash() + report.check("wrong_" + label + "_rejected", rejected()) + report.check("wrong_" + label + "_leaves_state_unchanged", + state_hash() == snapshot) + lift(parent) + + icacls(str(parent), "/deny", "*" + local_rid500 + ":(AD)") + write(local_rid500) + report.check("machine_rid500_deny_accepted", accepted()) + for label, wrong in (("foreign_domain_rid500", FOREIGN_RID500_SID), + ("plain_account", plain), ("users_group", USERS_SID)): + write(wrong) + report.check("machine_rid500_deny_rejected_for_" + label, rejected()) + lift(parent) + + installed = utility.deny_by_descriptor(descriptor, parent, FOREIGN_RID500_SID) + report.data["foreign_rid500_install_components"] = installed["components"] + raw = authority.directory_dacl_sddl(parent) + report.check("foreign_rid500_deny_installed_by_descriptor_exactly", + installed["installed_exactly"] + and installed["ace"] == "(D;;LC;;;" + FOREIGN_RID500_SID + ")" + and utility.split_dacl(raw)[1][0] == installed["ace"]) + write(local_rid500) + report.check("foreign_rid500_deny_rejected_for_local_account", rejected()) + write(FOREIGN_RID500_SID) + report.check("foreign_rid500_deny_accepted_for_itself", accepted()) + lift(parent) + report.check("foreign_rid500_deny_removed_between_cases", + not utility.deny_present(descriptor, parent, FOREIGN_RID500_SID)) + + icacls(str(parent), "/deny", "*" + runner + ":(AD)") + write(runner) + report.check("natural_runner_deny_accepted", accepted()) + lift(parent) + write(runner) + report.check("absent_deny_rejected", rejected()) + finally: + for item in handles: + if not item._lock_file.closed: + item.close() + for path in [parent, *parent.rglob("*"), *archived.rglob("*")]: + if path.exists() and not lift(path, strict=False): + cleanup_failures.append(path.name) + package = Path(authority.__file__).resolve().parent + report.check("owned_dacl_cleanup_verified", not cleanup_failures) + report.check("all_trace_gc_modules_from_installed_package_after_fixtures", + all(Path(getattr(module, "__file__", None) or "/").resolve().is_relative_to(package) + for name, module in list(sys.modules.items()) + if name == "trace_gc" or name.startswith("trace_gc.")) + and not {name for name in sys.modules + if name.split(".")[0] in ("src", "tools")} - checkout_modules) + report.check("owned_tree_removed", base_dir is not None and not base_dir.exists()) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--declared-source", action="append", default=[], + metavar="NAME=SHA256") + parser.add_argument("--require-windows", action="store_true") + args = parser.parse_args() + declared = {} + for item in args.declared_source: + name, separator, digest = item.partition("=") + if not separator or not re.fullmatch(r"[0-9a-f]{64}", digest): + parser.error("--declared-source expects NAME=<64 hex sha256>") + declared[name] = digest + report = Report() + try: + portable(report, declared) + if os.name == "nt": + native(report) + except Exception as error: # bounded type only, never paths or names + report.data["error_type"] = type(error).__name__ + report.ok = False + if args.require_windows and os.name != "nt": + report.data["error"] = "Windows required" + report.ok = False + print(json.dumps(report.data, indent=2)) + return 0 if report.ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/sdist_discovery.py b/tools/sdist_discovery.py new file mode 100644 index 0000000..53d5fd5 --- /dev/null +++ b/tools/sdist_discovery.py @@ -0,0 +1,110 @@ +"""Strict discovery of a freshly built sdist, its extracted root and its wheel. + +Build backends disagree about the archive spelling: setuptools 68 writes ``trace-gc-0.4.0.tar.gz`` +with the root ``trace-gc-0.4.0`` and setuptools 84 writes ``trace_gc-0.4.0.tar.gz``. Nothing here +assumes either spelling. The one archive in the output directory is used, the extracted root is +whatever single top-level directory the archive holds, and zero or several candidates fail. +""" +from __future__ import annotations + +import argparse +from pathlib import Path, PurePosixPath +import re +import sys +import tarfile + + +class DiscoveryError(Exception): + pass + + +def _normalized(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def _single(directory: Path, suffix: str, project: str | None) -> Path: + if not directory.is_dir(): + raise DiscoveryError("not a directory: %s" % directory) + found = sorted(path for path in directory.iterdir() if path.is_file() and path.name.endswith(suffix)) + if not found: + raise DiscoveryError("no %s file in %s" % (suffix, directory)) + if len(found) > 1: + raise DiscoveryError("ambiguous: %d %s files in %s: %s" + % (len(found), suffix, directory, ", ".join(path.name for path in found))) + if project is not None: + distribution = found[0].name[:-len(suffix)].rsplit("-", 1)[0] if suffix == ".tar.gz" \ + else found[0].name.split("-", 1)[0] + if _normalized(distribution) != _normalized(project): + raise DiscoveryError("%s is not a distribution of %s" % (found[0].name, project)) + return found[0] + + +def find_wheel(dist: Path, project: str | None = None) -> Path: + return _single(dist, ".whl", project) + + +def find_sdist(dist: Path, project: str | None = None) -> Path: + return _single(dist, ".tar.gz", project) + + +def extract_sdist(dist: Path, into: Path, project: str | None = None) -> Path: + archive = find_sdist(dist, project) + if into.exists() and (not into.is_dir() or any(into.iterdir())): + raise DiscoveryError("extraction target is not an empty directory: %s" % into) + with tarfile.open(archive) as handle: + members = handle.getmembers() + roots = set() + for member in members: + name = PurePosixPath(member.name) + if name.is_absolute() or ".." in name.parts or "\\" in member.name or not name.parts: + raise DiscoveryError("unsafe member name %r in %s" % (member.name, archive.name)) + if not (member.isfile() or member.isdir()): + raise DiscoveryError("member %r in %s is neither a file nor a directory" % (member.name, archive.name)) + roots.add(name.parts[0]) + if len(roots) != 1: + raise DiscoveryError("%s must hold exactly one top-level directory, found %s" + % (archive.name, sorted(roots))) + into.mkdir(parents=True, exist_ok=True) + if hasattr(tarfile, "data_filter"): + handle.extractall(into, filter="data") + else: + handle.extractall(into) + root = into / next(iter(roots)) + if not root.is_dir(): + raise DiscoveryError("%s does not extract to a directory" % archive.name) + info = root / "PKG-INFO" + if not info.is_file(): + raise DiscoveryError("%s has no PKG-INFO; it is not an sdist" % root.name) + if project is not None: + declared = re.search(r"^Name:\s*(\S+)\s*$", info.read_text(encoding="utf-8"), re.MULTILINE) + if declared is None or _normalized(declared.group(1)) != _normalized(project): + raise DiscoveryError("PKG-INFO of %s does not name %s" % (root.name, project)) + return root + + +def _emit(path: Path) -> None: + sys.stdout.buffer.write((str(path.resolve()) + "\n").encode("utf-8")) + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + for name in ("extract", "wheel"): + command = commands.add_parser(name) + command.add_argument("--dist", type=Path, required=True) + command.add_argument("--project") + if name == "extract": + command.add_argument("--into", type=Path, required=True) + args = parser.parse_args(argv) + try: + result = extract_sdist(args.dist, args.into, args.project) if args.command == "extract" \ + else find_wheel(args.dist, args.project) + except (DiscoveryError, tarfile.TarError, OSError) as error: + print("sdist discovery failed: %s" % error, file=sys.stderr) + return 1 + _emit(result) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/windows_native_acl.py b/tools/windows_native_acl.py new file mode 100644 index 0000000..092b086 --- /dev/null +++ b/tools/windows_native_acl.py @@ -0,0 +1,1445 @@ +#!/usr/bin/env python3 +"""Native Windows descriptor and directory-creation helpers for owned disposable fixtures. + +``icacls`` resolves every trustee through an account lookup, so it cannot install an ACE for a +SID that no machine knows (a fabricated domain RID-500 SID). ``deny_by_descriptor`` parses a +numeric-SID SDDL string and installs it with ``SetNamedSecurityInfoW``; no lookup happens, and the +installed ACE list, owner and digest are read back from the real descriptor. + +The directory-creation probe reports, from the real kernel, whether a parent deny ACE refuses +``mkdir`` for the full process token and for a copy with every privilege removed, what +``AccessCheck`` computes for each, which privileges are enabled, and the volume file system. It +changes no production behaviour, emits no path, account name or exception text, and every +directory it creates is removed again and verified. + +Run as a script (``python tools/windows_native_acl.py``, from any directory) it first makes the +checkout importable, then prints one sanitized JSON report naming each stage it completed +(``bootstrap``, ``checkout_imports``, ``native_api``, ``identity``) and, on Windows, the sections +``matrix`` (candidate deny shapes), ``actual_fence_shape``, ``privilege_isolation`` (which single +privilege removals change the outcome), ``installation_observation`` (per-component facts for an +unprotected and a stabilized fixture) and ``restricted_primary_child`` (the same recreation report +from a child whose primary token has every privilege removed). Every section is an observation: +``cause_established`` is always false and nothing is accepted on it. All fixtures are owned +temporary directories; the exit status is nonzero on Windows unless every cleanup is verified. +``--stages-only`` stops after the identity stage. Loaded by path (the installed-wheel check) the +module edits neither ``sys.path`` nor ``sys.modules``. +""" +from __future__ import annotations + +from collections import Counter +from contextlib import contextmanager +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import struct +import sys +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[1] + +PARENT_RIGHTS = "LC" # FILE_ADD_SUBDIRECTORY, spelled by icacls as (AD) +FILE_ADD_FILE = 0x2 +FILE_ADD_SUBDIRECTORY = 0x4 +BYPASS_PRIVILEGES = ("SeBackupPrivilege", "SeRestorePrivilege") +_DACL = re.compile(r"D:([A-Z]*)((?:\([^)]*\))*)") +_NUMERIC_SID = re.compile(r"S-1-[0-9]+(?:-[0-9]+)+") + + +def redact(text): + """Mask machine and directory sub-authorities; byte-identical in effect to the sanitizer of + ``tools/windows_fence_diagnostics.py`` but local, so loading this module by path never edits + ``sys.path`` or imports checkout code.""" + text = re.sub(r"S-1-5-21-\d+-\d+-\d+-(\d+)", r"S-1-5-21--\1", text) + return re.sub(r"S-1-12-1(?:-\d+){4}", "S-1-12-1-", text) + + +class NativeError(Exception): + """A Win32 call failed; only the API name and the numeric error are kept.""" + + def __init__(self, api: str, code: int | None): + super().__init__("%s failed with %s" % (api, code)) + self.api, self.code = api, code + + +def split_dacl(sddl: str) -> tuple[str, list[str]]: + match = _DACL.fullmatch(sddl) + if match is None: + raise ValueError("not a plain DACL string") + return match.group(1), re.findall(r"\([^)]*\)", match.group(2)) + + +def join_dacl(control: str, aces: list[str]) -> str: + return "D:" + control + "".join(aces) + + +def with_ace_first(sddl: str, ace: str) -> str: + control, aces = split_dacl(sddl) + return join_dacl(control, [ace, *aces]) + + +def canonical_ace(native, ace: str) -> str: + """Spell only the trustee of one six-field ACE as this machine's Windows writes it. + + A strictly numeric SID is translated forward by the operating system (the alias for a + well-known or machine-local SID, itself for any other), so Windows reading back ``LA`` or + ``BU`` matches the numeric SID that was installed, while two distinct accounts that share a + RID (a machine and a foreign domain RID-500) never collapse. Type, flags, rights and object + fields are compared exactly. + """ + fields = ace[1:-1].split(";") + if len(fields) != 6: + return ace + if _NUMERIC_SID.fullmatch(fields[5]): + fields[5] = native.canonical_trustee(fields[5]) + return "(" + ";".join(fields) + ")" + + +def canonical_aces(native, sddl: str) -> list[str]: + return [canonical_ace(native, ace) for ace in split_dacl(sddl)[1]] + + +def without_ace(sddl: str, ace: str, native=None) -> str: + """Remove the first ACE equal to ``ace``; with ``native`` the trustee is compared canonically.""" + control, aces = split_dacl(sddl) + key = (lambda text: canonical_ace(native, text)) if native is not None else (lambda text: text) + wanted = key(ace) + for index, present in enumerate(aces): + if key(present) == wanted: + del aces[index] + return join_dacl(control, aces) + raise ValueError("ACE absent") + + +def ace_present(native, sddl: str, ace: str) -> bool: + wanted = canonical_ace(native, ace) + return wanted in canonical_aces(native, sddl) + + +def restoration(native, path, dacl: str, owner: str) -> dict: + """Compare the live descriptor with a recorded one: control and protection flags, the ordered + ACEs (trustees compared canonically) and the owner. Booleans only, so it is safe to report.""" + control, aces = split_dacl(dacl) + now_control, now_aces = split_dacl(native.read_dacl(path)) + result = {"control": control == now_control, + "aces": [canonical_ace(native, ace) for ace in aces] + == [canonical_ace(native, ace) for ace in now_aces], + "owner": owner == native.read_owner(path)} + result["verified"] = all(result.values()) + return result + + +def _inherited(ace: str) -> bool: + fields = ace[1:-1].split(";") + return len(fields) == 6 and "ID" in re.findall("..", fields[1]) + + +def _spelled(native, sddl: str) -> list[str]: + return canonical_aces(native, sddl) + + +def _shown(aces: list[str]) -> list[str]: + return [redact(ace) for ace in aces] + + +def installation_components(native, before: str, after: str, ace: str, + owner_before: str, owner_after: str) -> dict: + """Per-component facts of one descriptor installation, safe to print: the DACL control and + protection flags, the ordered ACEs (trustees canonical, machine SIDs redacted) with the count, + explicit and inherited tallies and any ACE that is unexpected or missing, and the owner. + ``installed_exactly`` is true only if the control is unchanged, the ACEs are exactly the new + one first and the old ones after it in their order, and the owner is unchanged.""" + control_before, control_after = split_dacl(before)[0], split_dacl(after)[0] + old, new = _spelled(native, before), _spelled(native, after) + expected = [canonical_ace(native, ace), *old] + unexpected = Counter(new) - Counter(expected) + missing = Counter(expected) - Counter(new) + components = { + "control": {"before": control_before, "after": control_after, + "unchanged": control_before == control_after}, + "aces": {"expected": _shown(expected), "before": _shown(old), "after": _shown(new), + "count_before": len(old), "count_expected": len(expected), "count_after": len(new), + "explicit_before": sum(not _inherited(item) for item in old), + "explicit_after": sum(not _inherited(item) for item in new), + "inherited_before": sum(_inherited(item) for item in old), + "inherited_after": sum(_inherited(item) for item in new), + "new_ace_first": bool(new) and new[0] == expected[0], + "exact": new == expected, + "unexpected_after": _shown(sorted(unexpected.elements())), + "missing_after": _shown(sorted(missing.elements()))}, + "owner": {"before": redact(owner_before), "after": redact(owner_after), + "unchanged": owner_before == owner_after}} + components["installed_exactly"] = (components["control"]["unchanged"] + and components["aces"]["exact"] + and components["owner"]["unchanged"]) + return components + + +def restoration_components(native, path, dacl: str, owner: str) -> dict: + """Per-component facts of a restoration check (see ``restoration``), safe to print.""" + control, aces = split_dacl(dacl) + now_control, now_aces = split_dacl(native.read_dacl(path)) + expected = [canonical_ace(native, item) for item in aces] + now = [canonical_ace(native, item) for item in now_aces] + now_owner = native.read_owner(path) + components = { + "control": {"expected": control, "now": now_control, "same": control == now_control}, + "aces": {"expected": _shown(expected), "now": _shown(now), + "count_expected": len(expected), "count_now": len(now), "same": expected == now, + "unexpected_now": _shown(sorted((Counter(now) - Counter(expected)).elements())), + "missing_now": _shown(sorted((Counter(expected) - Counter(now)).elements()))}, + "owner": {"expected": redact(owner), "now": redact(now_owner), "same": owner == now_owner}} + components["verified"] = (components["control"]["same"] and components["aces"]["same"] + and components["owner"]["same"]) + return components + + +def components_message(label: str, components: dict) -> str: + return label + ": " + json.dumps(components, sort_keys=True) + + +def sha256_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +WAIT_OBJECT_0, WAIT_TIMEOUT, WAIT_FAILED = 0, 0x102, 0xFFFFFFFF +STILL_ACTIVE = 259 +SE_PRIVILEGE_ENABLED = 0x2 +PRIVILEGE_STATE_MASK = 0x7 # default-enabled, enabled, removed; "used for access" changes on use + + +class PrivilegeRestorationError(NativeError): + """The process token's privilege inventory could not be shown unchanged after a scope.""" + + def __init__(self, details: dict): + super().__init__("privilege_restoration", None) + self.details = details + + +def privilege_state(api) -> dict: + return {name: attributes & PRIVILEGE_STATE_MASK + for name, attributes in api.privilege_attributes().items()} + + +@contextmanager +def privilege_scope(api, name: str): + """Enable ``name`` for the body and put the token back exactly as it was. + + The exact original state of every privilege is captured first; afterwards the privilege is set + back to what it was (enabled stays enabled, disabled returns to disabled), the whole inventory + is read back and compared, and a restoration that cannot be shown raises + ``PrivilegeRestorationError`` even when the body failed (the body's error is its context). + ``api`` offers ``privilege_attributes() -> {name: attributes}`` and + ``adjust_privilege(name, attributes)``. + """ + before = privilege_state(api) + if name not in before: + raise NativeError("privilege_absent", None) + original = before[name] + try: + api.adjust_privilege(name, SE_PRIVILEGE_ENABLED) + if not privilege_state(api).get(name, 0) & SE_PRIVILEGE_ENABLED: + raise NativeError("AdjustTokenPrivileges", None) + yield + finally: + problems, after = [], None + try: + api.adjust_privilege(name, original & SE_PRIVILEGE_ENABLED) + except Exception: + problems.append("restore_failed") + try: + after = privilege_state(api) + except Exception: + problems.append("readback_failed") + if after is not None and after != before: + problems.append("inventory_changed") + if problems: + raise PrivilegeRestorationError({ + "privilege": name, "problems": problems, + "changed": sorted(key for key in (set(before) | set(after or {})) + if (after or {}).get(key) != before.get(key))}) + + +def _wait_name(status) -> str: + return {WAIT_OBJECT_0: "signaled", WAIT_TIMEOUT: "timeout", + WAIT_FAILED: "failed"}.get(status, "unexpected") + + +def run_contained(api, timeout: float, grace_ms: int = 30000): + """Start one process inside its own kill-on-close job, wait for it, and report what is KNOWN. + + ``api`` supplies ``create_job``, ``start`` (suspended), ``assign``, ``resume``, ``wait``, + ``terminate_job``, ``terminate_process``, ``exit_code``, ``active_processes``, ``close`` and + ``pause`` (each failure carries its Windows error). ``completed`` is true only when the process + was contained and resumed, exited inside the timeout with a real exit code (never the + ``STILL_ACTIVE`` placeholder), and left no descendant behind. ``cleanup_verified`` additionally + requires the process signaled, the job empty and every handle closed; otherwise the handles + are returned so the caller keeps ownership of them. ``child_stopped`` is the one fact a caller + may rely on before it restores or removes anything the child could still touch: true only when + nothing was started, or the process was seen signaled and the job holds no process (a failed + handle close alone does not change it). Returns ``(report, retained_handles)``. + """ + report = {"launched": False, "completed": False, "exit_code": None, "timed_out": False, + "wait_failed": False, "still_active": False, "terminated": False, + "final_wait": None, "descendants_terminated": False, "job_active_processes": None, + "cleanup_verified": False, "handles_retained": False, "winerror": None, + "child_stopped": False, "terminate_errors": [], "errors": []} + + def note(api_name, code): + report["errors"].append({"api": api_name, "code": code}) + + def terminate(job, process, contained): + # Ending a job ends only the processes assigned to it: a child whose assignment failed or + # is unknown is owned but outside the job, so it is terminated directly. + if contained: + ok, error = api.terminate_job(job, 1) + if not ok: + report["terminate_errors"].append({"api": "TerminateJobObject", "code": error}) + else: + ok = False + if not ok: + ok, error = api.terminate_process(process, 1) + if not ok: + report["terminate_errors"].append({"api": "TerminateProcess", "code": error}) + report["terminated"] = report["terminated"] or ok + + job, error = api.create_job() + if job is None: + report["winerror"] = error + report["child_stopped"] = True + note("CreateJobObject", error) + return report, [] + held = [job] + process, thread, error = api.start() + if process is None: + report["winerror"] = error + report["child_stopped"] = True + note("CreateProcessAsUserW", error) + if not api.close(job): + note("CloseHandle", None) + report["handles_retained"] = True + return report, held + return report, [] + held += [thread, process] + contained = False + try: + started = False + contained, error = api.assign(job, process) + if not contained: + note("AssignProcessToJobObject", error) + else: + ok, error = api.resume(thread) + if not ok: + note("ResumeThread", error) + else: + started = True + report["launched"] = started + signaled = False + if started: + status, error = api.wait(process, int(timeout * 1000)) + if status == WAIT_OBJECT_0: + signaled = True + elif status == WAIT_TIMEOUT: + report["timed_out"] = True + else: + report["wait_failed"] = True + note("WaitForSingleObject", error) + if not signaled: + terminate(job, process, contained) + status, error = api.wait(process, grace_ms) + report["final_wait"] = _wait_name(status) + signaled = status == WAIT_OBJECT_0 + if not signaled: + note("WaitForSingleObject", error) + if signaled: + ok, code, error = api.exit_code(process) + if not ok: + note("GetExitCodeProcess", error) + elif code == STILL_ACTIVE: + report["still_active"] = True + else: + report["exit_code"] = code + count, error = api.active_processes(job) + if count is None: + note("QueryInformationJobObject", error) + elif count > 0: + report["descendants_terminated"] = True + terminate(job, process, contained) + remaining = grace_ms + while count and remaining > 0: + api.pause(100) + remaining -= 100 + count, error = api.active_processes(job) + if count is None: + note("QueryInformationJobObject", error) + break + report["job_active_processes"] = count + report["child_stopped"] = bool(signaled and count == 0) + report["completed"] = (started and not report["timed_out"] and not report["wait_failed"] + and report["exit_code"] is not None + and not report["descendants_terminated"]) + report["cleanup_verified"] = (signaled and not report["still_active"] and count == 0) + except BaseException: + try: + terminate(job, process, contained) + except Exception: + pass + api.retain(held) + raise + if not report["cleanup_verified"]: + report["handles_retained"] = True + return report, held + left = [handle for handle in held if not api.close(handle)] + if left: + note("CloseHandle", None) + report["cleanup_verified"], report["completed"] = False, False + report["handles_retained"] = True + return report, left + + +class _ProcessApi: + """The kernel seam ``run_contained`` drives, over one restricted primary token.""" + + def __init__(self, native, primary, argv, cwd): + self.native, self.primary, self.argv, self.cwd = native, primary, argv, cwd + self.quota_enabled, self.restoration_error = False, None + c, w = native.c, native.w + + class StartupInfo(c.Structure): + _fields_ = [("cb", w.DWORD), ("lpReserved", w.LPWSTR), ("lpDesktop", w.LPWSTR), + ("lpTitle", w.LPWSTR), ("dwX", w.DWORD), ("dwY", w.DWORD), + ("dwXSize", w.DWORD), ("dwYSize", w.DWORD), ("dwXCountChars", w.DWORD), + ("dwYCountChars", w.DWORD), ("dwFillAttribute", w.DWORD), ("dwFlags", w.DWORD), + ("wShowWindow", w.WORD), ("cbReserved2", w.WORD), ("lpReserved2", c.c_void_p), + ("hStdInput", c.c_void_p), ("hStdOutput", c.c_void_p), ("hStdError", c.c_void_p)] + + class ProcessInformation(c.Structure): + _fields_ = [("hProcess", c.c_void_p), ("hThread", c.c_void_p), + ("dwProcessId", w.DWORD), ("dwThreadId", w.DWORD)] + + class BasicLimit(c.Structure): + _fields_ = [("PerProcessUserTimeLimit", c.c_longlong), ("PerJobUserTimeLimit", c.c_longlong), + ("LimitFlags", w.DWORD), ("MinimumWorkingSetSize", c.c_size_t), + ("MaximumWorkingSetSize", c.c_size_t), ("ActiveProcessLimit", w.DWORD), + ("Affinity", c.c_size_t), ("PriorityClass", w.DWORD), ("SchedulingClass", w.DWORD)] + + class IoCounters(c.Structure): + _fields_ = [(name, c.c_ulonglong) for name in ( + "ReadOperationCount", "WriteOperationCount", "OtherOperationCount", + "ReadTransferCount", "WriteTransferCount", "OtherTransferCount")] + + class ExtendedLimit(c.Structure): + _fields_ = [("BasicLimitInformation", BasicLimit), ("IoInfo", IoCounters), + ("ProcessMemoryLimit", c.c_size_t), ("JobMemoryLimit", c.c_size_t), + ("PeakProcessMemoryUsed", c.c_size_t), ("PeakJobMemoryUsed", c.c_size_t)] + + class Accounting(c.Structure): + _fields_ = [("TotalUserTime", c.c_longlong), ("TotalKernelTime", c.c_longlong), + ("ThisPeriodTotalUserTime", c.c_longlong), ("ThisPeriodTotalKernelTime", c.c_longlong), + ("TotalPageFaultCount", w.DWORD), ("TotalProcesses", w.DWORD), + ("ActiveProcesses", w.DWORD), ("TotalTerminatedProcesses", w.DWORD)] + self._types = (StartupInfo, ProcessInformation, ExtendedLimit, Accounting) + + def _error(self): + return self.native.c.get_last_error() + + def create_job(self): + c, kernel = self.native.c, self.native.kernel + extended = self._types[2]() + extended.BasicLimitInformation.LimitFlags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + c.set_last_error(0) + job = kernel.CreateJobObjectW(None, None) + if not job: + return None, self._error() + c.set_last_error(0) + if not kernel.SetInformationJobObject(job, 9, c.byref(extended), c.sizeof(extended)): + error = self._error() + kernel.CloseHandle(job) + return None, error + return job, None + + def _create(self): + c, native = self.native.c, self.native + info, created = self._types[0](), self._types[1]() + info.cb = c.sizeof(info) + import subprocess + command = c.create_unicode_buffer(subprocess.list2cmdline(self.argv)) + c.set_last_error(0) + started = native.advapi.CreateProcessAsUserW( + self.primary, None, command, None, None, False, 0x08000004, None, str(self.cwd), + c.byref(info), c.byref(created)) + if not started: + return None, None, self._error() + return created.hProcess, created.hThread, None + + def start(self): + attempt = self._create() + if attempt[0] is None and attempt[2] == 1314: + try: + with self.native.privilege_enabled("SeIncreaseQuotaPrivilege"): + self.quota_enabled = True + attempt = self._create() + except PrivilegeRestorationError as error: + self.restoration_error = error + except NativeError: + pass + return attempt + + def assign(self, job, process): + self.native.c.set_last_error(0) + ok = bool(self.native.kernel.AssignProcessToJobObject(job, process)) + return ok, None if ok else self._error() + + def resume(self, thread): + self.native.c.set_last_error(0) + previous = self.native.kernel.ResumeThread(thread) + ok = previous != 0xFFFFFFFF + return ok, None if ok else self._error() + + def wait(self, handle, milliseconds): + self.native.c.set_last_error(0) + status = self.native.kernel.WaitForSingleObject(handle, milliseconds) + return status, self._error() if status == WAIT_FAILED else None + + def terminate_job(self, job, code): + self.native.c.set_last_error(0) + ok = bool(self.native.kernel.TerminateJobObject(job, code)) + return ok, None if ok else self._error() + + def terminate_process(self, process, code): + self.native.c.set_last_error(0) + ok = bool(self.native.kernel.TerminateProcess(process, code)) + return ok, None if ok else self._error() + + def exit_code(self, process): + code = self.native.w.DWORD() + self.native.c.set_last_error(0) + ok = bool(self.native.kernel.GetExitCodeProcess(process, self.native.c.byref(code))) + return ok, code.value if ok else None, None if ok else self._error() + + def active_processes(self, job): + c, kernel = self.native.c, self.native.kernel + accounting = self._types[3]() + c.set_last_error(0) + if not kernel.QueryInformationJobObject(job, 1, c.byref(accounting), c.sizeof(accounting), None): + return None, self._error() + return accounting.ActiveProcesses, None + + def close(self, handle): + return bool(self.native.kernel.CloseHandle(handle)) + + def pause(self, milliseconds): + time.sleep(milliseconds / 1000) + + def retain(self, handles): + self.native.retained_handles.extend(handles) + + +class WindowsNative: + """ctypes access to the descriptor, token, access-check and volume APIs (Windows only).""" + + def __init__(self): + import ctypes + from ctypes import wintypes + if os.name != "nt": + raise NativeError("platform", None) + self.c, self.w = ctypes, wintypes + self.retained_handles: list = [] + self.advapi = ctypes.WinDLL("advapi32", use_last_error=True) + self.kernel = ctypes.WinDLL("kernel32", use_last_error=True) + v, p, d = ctypes.c_void_p, ctypes.POINTER, wintypes.DWORD + for library, name, argtypes, restype in ( + (self.kernel, "GetCurrentProcess", (), v), + (self.kernel, "CloseHandle", (v,), wintypes.BOOL), + (self.kernel, "LocalFree", (v,), v), + (self.kernel, "GetVolumePathNameW", (wintypes.LPCWSTR, wintypes.LPWSTR, d), wintypes.BOOL), + (self.kernel, "GetVolumeInformationW", + (wintypes.LPCWSTR, wintypes.LPWSTR, d, p(d), p(d), p(d), wintypes.LPWSTR, d), wintypes.BOOL), + (self.kernel, "CreateDirectoryW", (wintypes.LPCWSTR, v), wintypes.BOOL), + (self.kernel, "WaitForSingleObject", (v, d), d), + (self.kernel, "GetExitCodeProcess", (v, p(d)), wintypes.BOOL), + (self.kernel, "TerminateProcess", (v, wintypes.UINT), wintypes.BOOL), + (self.kernel, "CreateJobObjectW", (v, wintypes.LPCWSTR), v), + (self.kernel, "SetInformationJobObject", (v, ctypes.c_int, v, d), wintypes.BOOL), + (self.kernel, "AssignProcessToJobObject", (v, v), wintypes.BOOL), + (self.kernel, "TerminateJobObject", (v, wintypes.UINT), wintypes.BOOL), + (self.kernel, "QueryInformationJobObject", (v, ctypes.c_int, v, d, p(d)), wintypes.BOOL), + (self.kernel, "ResumeThread", (v,), d), + (self.advapi, "LookupPrivilegeValueW", (wintypes.LPCWSTR, wintypes.LPCWSTR, v), wintypes.BOOL), + (self.advapi, "AdjustTokenPrivileges", (v, wintypes.BOOL, v, d, v, v), wintypes.BOOL), + (self.advapi, "CreateProcessAsUserW", + (v, wintypes.LPCWSTR, wintypes.LPWSTR, v, v, wintypes.BOOL, d, v, wintypes.LPCWSTR, v, v), + wintypes.BOOL), + (self.advapi, "OpenProcessToken", (v, d, p(v)), wintypes.BOOL), + (self.advapi, "GetTokenInformation", (v, ctypes.c_int, v, d, p(d)), wintypes.BOOL), + (self.advapi, "LookupPrivilegeNameW", (wintypes.LPCWSTR, v, wintypes.LPWSTR, p(d)), wintypes.BOOL), + (self.advapi, "CreateRestrictedToken", (v, d, d, v, d, v, d, v, p(v)), wintypes.BOOL), + (self.advapi, "DuplicateTokenEx", (v, d, v, ctypes.c_int, ctypes.c_int, p(v)), wintypes.BOOL), + (self.advapi, "SetThreadToken", (v, v), wintypes.BOOL), + (self.advapi, "RevertToSelf", (), wintypes.BOOL), + (self.advapi, "AccessCheck", (v, v, d, v, v, p(d), p(d), p(wintypes.BOOL)), wintypes.BOOL), + (self.advapi, "GetFileSecurityW", (wintypes.LPCWSTR, d, v, d, p(d)), wintypes.BOOL), + (self.advapi, "GetNamedSecurityInfoW", (wintypes.LPCWSTR, ctypes.c_int, d, v, v, v, v, p(v)), d), + (self.advapi, "SetNamedSecurityInfoW", (wintypes.LPWSTR, ctypes.c_int, d, v, v, v, v), d), + (self.advapi, "GetSecurityDescriptorDacl", (v, p(wintypes.BOOL), p(v), p(wintypes.BOOL)), wintypes.BOOL), + (self.advapi, "ConvertStringSecurityDescriptorToSecurityDescriptorW", + (wintypes.LPCWSTR, d, p(v), v), wintypes.BOOL), + (self.advapi, "ConvertSecurityDescriptorToStringSecurityDescriptorW", + (v, d, d, p(wintypes.LPWSTR), v), wintypes.BOOL)): + function = getattr(library, name) + function.argtypes, function.restype = argtypes, restype + + def _fail(self, api: str): + raise NativeError(api, self.c.get_last_error()) + + def _ok(self, api: str, result) -> None: + if not result: + self._fail(api) + + # ---- descriptor ------------------------------------------------------------------- + + def _encode(self, descriptor, information: int) -> str: + text = self.w.LPWSTR() + self._ok("ConvertSecurityDescriptorToStringSecurityDescriptorW", + self.advapi.ConvertSecurityDescriptorToStringSecurityDescriptorW( + descriptor, 1, information, self.c.byref(text), None)) + try: + return text.value + finally: + self.kernel.LocalFree(self.c.cast(text, self.c.c_void_p)) + + def _named_descriptor(self, path, information: int) -> str: + descriptor = self.c.c_void_p() + code = self.advapi.GetNamedSecurityInfoW(str(path), 1, information, None, None, None, None, + self.c.byref(descriptor)) + if code: + raise NativeError("GetNamedSecurityInfoW", code) + try: + return self._encode(descriptor, information) + finally: + self.kernel.LocalFree(descriptor) + + def canonical_trustee(self, sid: str) -> str: + """The spelling of one numeric SID in this machine's DACL text; no account lookup.""" + cache = self.__dict__.setdefault("_trustees", {}) + if sid not in cache: + descriptor = self.c.c_void_p() + self._ok("ConvertStringSecurityDescriptorToSecurityDescriptorW", + self.advapi.ConvertStringSecurityDescriptorToSecurityDescriptorW( + "D:(D;;DCLC;;;" + sid + ")", 1, self.c.byref(descriptor), None)) + try: + text = self._encode(descriptor, 4) + finally: + self.kernel.LocalFree(descriptor) + match = re.fullmatch(r"D:\(D;;DCLC;;;([^;()]+)\)", text) + if match is None: + raise NativeError("trustee encoding", None) + cache[sid] = match.group(1) + return cache[sid] + + def read_dacl(self, path) -> str: + return self._named_descriptor(Path(path).resolve(strict=True), 4) + + def read_owner(self, path) -> str: + text = self._named_descriptor(Path(path).resolve(strict=True), 1) + if not text.startswith("O:"): + raise NativeError("owner encoding", None) + return text + + def apply_dacl(self, path, sddl: str, protection: str = "auto") -> None: + """Write the DACL. ``auto`` marks the DACL protected if the control says ``P`` and + unprotected otherwise; ``none`` sends neither protection flag (observation only).""" + control, _ = split_dacl(sddl) + if protection not in ("auto", "none"): + raise ValueError("protection must be 'auto' or 'none'") + protection = (0 if protection == "none" + else 0x80000000 if "P" in control else 0x20000000) + descriptor = self.c.c_void_p() + self._ok("ConvertStringSecurityDescriptorToSecurityDescriptorW", + self.advapi.ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl, 1, self.c.byref(descriptor), None)) + try: + present, defaulted, dacl = self.w.BOOL(), self.w.BOOL(), self.c.c_void_p() + self._ok("GetSecurityDescriptorDacl", self.advapi.GetSecurityDescriptorDacl( + descriptor, self.c.byref(present), self.c.byref(dacl), self.c.byref(defaulted))) + if not present.value: + raise NativeError("GetSecurityDescriptorDacl", None) + code = self.advapi.SetNamedSecurityInfoW(str(Path(path).resolve(strict=True)), 1, 4 | protection, + None, None, dacl, None) + if code: + raise NativeError("SetNamedSecurityInfoW", code) + finally: + self.kernel.LocalFree(descriptor) + + # ---- token ------------------------------------------------------------------------ + + def privilege_luid(self, name: str) -> bytes: + luid = self.c.create_string_buffer(8) + self._ok("LookupPrivilegeValueW", self.advapi.LookupPrivilegeValueW(None, name, luid)) + return luid.raw + + def _restrict(self, process, delete): + """A restricted copy of ``process``: ``delete=None`` removes every privilege + (``DISABLE_MAX_PRIVILEGE``); a tuple of names removes exactly those privileges.""" + restricted = self.c.c_void_p() + if delete is None: + self._ok("CreateRestrictedToken", self.advapi.CreateRestrictedToken( + process, 0x1, 0, None, 0, None, 0, None, self.c.byref(restricted))) + else: + names = tuple(delete) + entries = b"".join(self.privilege_luid(name) + struct.pack(" dict: + """Every privilege of this process token with its exact attribute bits.""" + process = self.c.c_void_p() + self._ok("OpenProcessToken", self.advapi.OpenProcessToken( + self.kernel.GetCurrentProcess(), 0x8, self.c.byref(process))) + try: + size = self.w.DWORD() + self.advapi.GetTokenInformation(process, 3, None, 0, self.c.byref(size)) + buffer = self.c.create_string_buffer(size.value) + self._ok("GetTokenInformation", self.advapi.GetTokenInformation( + process, 3, buffer, size, self.c.byref(size))) + (count,) = struct.unpack_from(" list[tuple[str, bool]]: + return [(name, bool(attributes & SE_PRIVILEGE_ENABLED)) + for name, attributes in self.privilege_attributes().items()] + + def adjust_privilege(self, name: str, attributes: int) -> None: + process = self.c.c_void_p() + self._ok("OpenProcessToken", self.advapi.OpenProcessToken( + self.kernel.GetCurrentProcess(), 0x20 | 0x8, self.c.byref(process))) + try: + state = self.c.create_string_buffer( + struct.pack(" int: + """Rights ``AccessCheck`` grants this token on ``path`` under its current DACL.""" + target = str(Path(path).resolve(strict=True)) + size = self.w.DWORD() + self.advapi.GetFileSecurityW(target, 1 | 2 | 4, None, 0, self.c.byref(size)) + descriptor = self.c.create_string_buffer(size.value) + self._ok("GetFileSecurityW", self.advapi.GetFileSecurityW( + target, 1 | 2 | 4, descriptor, size, self.c.byref(size))) + mapping = (self.w.DWORD * 4)(0x120089, 0x120116, 0x1200A0, 0x1F01FF) + privilege_set = self.c.create_string_buffer(1024) + privilege_length, granted, status = self.w.DWORD(1024), self.w.DWORD(), self.w.BOOL() + with self.impersonation_token(stripped) as token: + self._ok("AccessCheck", self.advapi.AccessCheck( + descriptor, token, 0x02000000, mapping, privilege_set, self.c.byref(privilege_length), + self.c.byref(granted), self.c.byref(status))) + return granted.value if status.value else 0 + + def privilege_enabled(self, name: str): + """Enable one privilege that is present in this process token, and put the token back + exactly as it was (see ``privilege_scope``). Process-local; no host or account setting + changes.""" + return privilege_scope(self, name) + + def run_with_restricted_primary_token(self, argv: list[str], cwd, timeout: float = 900.0) -> dict: + """Start ``argv`` as a new process whose PRIMARY token is a copy of this process token with + every privilege removed, inside an owned kill-on-close job, wait for it, and report what is + known (see ``run_contained``). + + This is a supported-context launcher for tests: the child's original process token is + the restricted token (not a thread impersonating one), so what the child does is what a + process started without the bypass privileges does. Handles are not inherited and no + console is shown; a child reports through files it is given. If Windows refuses the + launch with ``ERROR_PRIVILEGE_NOT_HELD`` the disabled ``SeIncreaseQuotaPrivilege`` of this + process is enabled for the launch only and the launch is retried once; the token's + privilege inventory must then read back unchanged or the report says it did not. + """ + process = self.c.c_void_p() + self._ok("OpenProcessToken", self.advapi.OpenProcessToken( + self.kernel.GetCurrentProcess(), 0x1 | 0x2 | 0x4 | 0x8, self.c.byref(process))) + opened = [process] + try: + restricted = self._restrict(process, None) + opened.append(restricted) + primary = self.c.c_void_p() + self._ok("DuplicateTokenEx", self.advapi.DuplicateTokenEx( + restricted, 0xF01FF, None, 2, 1, self.c.byref(primary))) + opened.append(primary) + api = _ProcessApi(self, primary, argv, cwd) + report, retained = run_contained(api, timeout) + finally: + for handle in opened: + if handle.value: + self.kernel.CloseHandle(handle) + self.retained_handles.extend(retained) + report["increase_quota_enabled_for_launch"] = api.quota_enabled + if api.restoration_error is not None: + report["privilege_restoration"] = {"verified": False, **api.restoration_error.details} + report["cleanup_verified"], report["completed"] = False, False + elif api.quota_enabled: + report["privilege_restoration"] = {"verified": True} + return report + + def filesystem(self, path) -> str: + volume = self.c.create_unicode_buffer(260) + self._ok("GetVolumePathNameW", self.kernel.GetVolumePathNameW( + str(Path(path).resolve(strict=True)), volume, 260)) + name = self.c.create_unicode_buffer(64) + self._ok("GetVolumeInformationW", self.kernel.GetVolumeInformationW( + volume.value, None, 0, None, None, None, name, 64)) + return name.value + + def try_create_directory(self, path, stripped: bool, route: str = "os.mkdir", delete=(), + impersonate: bool = False) -> dict: + """Try to create ``path``; remove it again if it was created and verify the removal. + ``stripped`` removes every privilege from an impersonation copy, ``delete`` only the named + ones; neither changes the process token itself.""" + target = Path(path) + + def attempt() -> dict: + if route == "CreateDirectoryW": + if self.kernel.CreateDirectoryW(str(target), None): + return {"created": True} + return {"created": False, "winerror": self.c.get_last_error()} + try: + os.mkdir(target) + return {"created": True} + except OSError as error: + return {"created": False, "winerror": getattr(error, "winerror", None)} + if stripped or delete or impersonate: + with self.impersonation_token(stripped, delete) as token, self.impersonating(token): + outcome = attempt() + else: + outcome = attempt() + if outcome["created"]: + try: + os.rmdir(target) + except OSError as error: + outcome["removal_error"] = getattr(error, "winerror", None) or error.errno + outcome["removed"] = not target.exists() + return outcome + + def try_create_file(self, path, stripped: bool) -> dict: + target = Path(path) + + def attempt() -> dict: + try: + with open(target, "xb"): + pass + return {"created": True} + except OSError as error: + return {"created": False, "winerror": getattr(error, "winerror", None)} + if stripped: + with self.impersonation_token(True) as token, self.impersonating(token): + outcome = attempt() + else: + outcome = attempt() + if outcome["created"]: + try: + target.unlink() + except OSError as error: + outcome["removal_error"] = getattr(error, "winerror", None) or error.errno + outcome["removed"] = not target.exists() + return outcome + + +def deny_by_descriptor(native, path, sid: str, rights: str = PARENT_RIGHTS, protection: str = "auto") -> dict: + """Install ``(D;;rights;;;sid)`` first in the DACL of ``path`` without any account lookup. + + ``installed_exactly`` is true only if the read-back keeps the control and protection flags, + holds the new ACE first and the old ACEs after it in their old order (trustees compared + canonically), and leaves the owner unchanged. ``components`` carries the sanitized + per-component facts behind that boolean. + """ + before_owner = native.read_owner(path) + before = native.read_dacl(path) + ace = "(D;;%s;;;%s)" % (rights, sid) + if protection == "auto": + native.apply_dacl(path, with_ace_first(before, ace)) + else: + native.apply_dacl(path, with_ace_first(before, ace), protection) + after = native.read_dacl(path) + owner_after = native.read_owner(path) + components = installation_components(native, before, after, ace, before_owner, owner_after) + return {"ace": ace, "before": before, "after": after, "owner_before": before_owner, + "owner_after": owner_after, "installed_exactly": components["installed_exactly"], + "components": components} + + +def deny_present(native, path, sid: str, rights: str = PARENT_RIGHTS) -> bool: + return ace_present(native, native.read_dacl(path), "(D;;%s;;;%s)" % (rights, sid)) + + +def remove_descriptor_deny(native, path, sid: str, rights: str = PARENT_RIGHTS) -> bool: + """Remove the one ACE ``deny_by_descriptor`` added, wherever Windows spells its trustee as an + alias, and report whether the result is exactly the old DACL minus that ACE. + + Raises ``ValueError`` if no such ACE is present. True means the ACE is gone, every other ACE + is still present in its order, the control and protection flags are unchanged and the owner is + unchanged; any drift is reported as False rather than hidden. + """ + ace = "(D;;%s;;;%s)" % (rights, sid) + owner = native.read_owner(path) + current = native.read_dacl(path) + updated = without_ace(current, ace, native) + native.apply_dacl(path, updated) + after = native.read_dacl(path) + return (split_dacl(after)[0] == split_dacl(current)[0] + and canonical_aces(native, after) == canonical_aces(native, updated) + and native.read_owner(path) == owner) + + +ERROR_ACCESS_DENIED = 5 +CREATION_PROBES = (("os.mkdir", "mkdir"), ("CreateDirectoryW", "mkdir_CreateDirectoryW")) + + +def non_denial_outcomes(report: dict) -> list: + """Failed creations whose Windows error is not ``ERROR_ACCESS_DENIED``: a missing parent, an + existing target, an invalid path or a resource error says nothing about the fence.""" + return [{"route": route, "token": label, "winerror": report[key][label].get("winerror")} + for route, key in CREATION_PROBES for label in ("stripped", "full") + if not report[key][label]["created"] + and report[key][label].get("winerror") != ERROR_ACCESS_DENIED] + + +def probe_cleanup_verified(report: dict) -> bool: + """True only if everything a probe created was verifiably removed again.""" + outcomes = [report[key][label] for _, key in CREATION_PROBES for label in ("stripped", "full")] + outcomes += [report["file_control"][label] for label in ("stripped", "full")] + return all(outcome.get("removed") is True for outcome in outcomes if outcome["created"]) + + +def classify(report: dict) -> str: + """Label a recreation outcome from a probe report; a label is an observation, not a proven cause.""" + def created(label): + return report["mkdir"][label]["created"] or report["mkdir_CreateDirectoryW"][label]["created"] + if created("stripped"): + return ("DACL_GRANTS_ADD_SUBDIRECTORY" if report["access_check"]["stripped"]["add_subdirectory"] + else "KERNEL_CREATE_IGNORES_DACL") + if not created("full"): + return "REFUSED_WITHOUT_ACCESS_DENIED" if non_denial_outcomes(report) else "DENIED" + enabled = set(report["enabled_privileges"]) + if enabled & set(BYPASS_PRIVILEGES) and not report["access_check"]["full"]["add_subdirectory"]: + return "FULL_TOKEN_CREATES_WHILE_BYPASS_PRIVILEGE_ENABLED" + return "FULL_TOKEN_CREATES_UNEXPLAINED" + + +# Only a refusal for the full original process token passes. Every creation verdict is a failure; +# the privilege label records a correlation, never an established cause. +ACCEPTED_VERDICTS = ("DENIED",) + + +def recreation_report(native, parent, target) -> dict: + """Sanitized facts about whether ``target`` can be created in ``parent`` (stripped first).""" + parent, target = Path(parent), Path(target) + privileges = native.privileges() + report = { + "filesystem": native.filesystem(parent), + "enabled_privileges": sorted(name for name, enabled in privileges if enabled), + "disabled_privileges": sorted(name for name, enabled in privileges if not enabled), + "parent_dacl": redact(native.read_dacl(parent)), + "access_check": {}, + "mkdir": {}, + "mkdir_CreateDirectoryW": {}, + "file_control": {}, + } + for label, stripped in (("stripped", True), ("full", False)): + mask = native.access_mask(parent, stripped) + report["access_check"][label] = {"mask": "0x%X" % mask, + "add_subdirectory": bool(mask & FILE_ADD_SUBDIRECTORY), + "add_file": bool(mask & FILE_ADD_FILE)} + for label, stripped in (("stripped", True), ("full", False)): + report["mkdir"][label] = native.try_create_directory(target, stripped) + report["mkdir_CreateDirectoryW"][label] = native.try_create_directory(target, stripped, "CreateDirectoryW") + report["file_control"][label] = native.try_create_file(parent / "recreation-control.bin", stripped) + report["non_denial_outcomes"] = non_denial_outcomes(report) + report["probe_cleanup_verified"] = probe_cleanup_verified(report) + report["verdict"] = classify(report) + report["cause_established"] = False + return report + + +def require_recreation_denied(native, parent, target) -> dict: + """Fail unless creating ``target`` is refused with ``ERROR_ACCESS_DENIED`` for the full process + token and for the privilege-stripped copy, by both ``os.mkdir`` and ``CreateDirectoryW``, and + everything the probe created was removed again. Creation with the full token fails even when a + bypass privilege is enabled; the stripped result only explains it.""" + report = recreation_report(native, parent, target) + if report["verdict"] not in ACCEPTED_VERDICTS or not report["probe_cleanup_verified"]: + raise AssertionError("old path recreation is not denied: " + json.dumps(report, sort_keys=True)) + return report + + +CANDIDATE_SHAPES = { + "control_no_deny": "", + "current_LC": "(D;;LC;;;{sid})", + "DCLC": "(D;;DCLC;;;{sid})", + "DCLCDT": "(D;;DCLCDT;;;{sid})", + "LC_container_inherit": "(D;OICI;LC;;;{sid})", + "wide_without_dacl_rights": "(D;;CCDCLCSWWPDT;;;{sid})", +} + + +def candidate_matrix(native, sid: str, make_directory) -> dict: + """Try each deny shape on its own owned parent and report the outcomes (diagnostic only).""" + matrix, cleanup_ok = {}, True + for label, template in CANDIDATE_SHAPES.items(): + entry = {} + try: + parent = make_directory(label) + original, owner = native.read_dacl(parent), native.read_owner(parent) + ace = template.format(sid=sid) + try: + if ace: + native.apply_dacl(parent, with_ace_first(original, ace)) + entry["installed_dacl"] = redact(native.read_dacl(parent)) + entry["add_subdirectory_granted"] = { + "stripped": bool(native.access_mask(parent, True) & FILE_ADD_SUBDIRECTORY), + "full": bool(native.access_mask(parent, False) & FILE_ADD_SUBDIRECTORY)} + entry["mkdir"] = {name: native.try_create_directory(parent / "child", stripped) + for name, stripped in (("stripped", True), ("full", False))} + entry["mkdir_CreateDirectoryW"] = { + name: native.try_create_directory(parent / "child", stripped, "CreateDirectoryW") + for name, stripped in (("stripped", True), ("full", False))} + entry["probe_cleanup_verified"] = all( + outcome.get("removed") is True + for key in ("mkdir", "mkdir_CreateDirectoryW") for outcome in entry[key].values() + if outcome["created"]) + cleanup_ok = cleanup_ok and entry["probe_cleanup_verified"] + finally: + native.apply_dacl(parent, original) + entry["restored"] = restoration(native, parent, original, owner) + cleanup_ok = cleanup_ok and entry["restored"]["verified"] + except Exception as error: + entry["error"] = {"type": type(error).__name__, "code": getattr(error, "code", None)} + cleanup_ok = False + matrix[label] = entry + return {"shapes": matrix, "cleanup_verified": cleanup_ok} + + +def probe_actual_fence_shape(native, make_directory, sid: str) -> dict: + """The recreation report for the exact production parent deny, then a verified restoration.""" + parent = make_directory("actual_fence_shape") + original, owner = native.read_dacl(parent), native.read_owner(parent) + native.apply_dacl(parent, with_ace_first(original, "(D;;%s;;;%s)" % (PARENT_RIGHTS, sid))) + try: + report = recreation_report(native, parent, parent / "state") + finally: + native.apply_dacl(parent, original) + restored = restoration(native, parent, original, owner) + return {"report": report, "restored": restored, + "verified": restored["verified"] and report["probe_cleanup_verified"]} + + +def privilege_isolation(native, parent, target) -> dict: + """Observation only: whether creating ``target`` in ``parent`` changes when exactly one + privilege is removed from, or exactly one privilege is kept in, an impersonation copy of the + process token. It names which single removals turn the full token's creation into an + ``ERROR_ACCESS_DENIED`` refusal; it establishes no cause and nothing is accepted on it.""" + parent, target = Path(parent), Path(target) + privileges = native.privileges() + every = sorted(name for name, _ in privileges) + enabled = sorted(name for name, on in privileges if on) + outcomes = {"original_process_token": native.try_create_directory(target, False), + "impersonated_copy_unchanged": native.try_create_directory(target, False, impersonate=True), + "impersonated_copy_all_removed": native.try_create_directory(target, True), + "remove_only": {}, "keep_only": {}} + for name in enabled: + outcomes["remove_only"][name] = native.try_create_directory(target, False, delete=(name,)) + outcomes["keep_only"][name] = native.try_create_directory( + target, False, delete=tuple(other for other in every if other != name)) + + def denied(outcome): + return not outcome["created"] and outcome.get("winerror") == ERROR_ACCESS_DENIED + everything = [outcomes[key] for key in ("original_process_token", "impersonated_copy_unchanged", + "impersonated_copy_all_removed")] + everything += list(outcomes["remove_only"].values()) + list(outcomes["keep_only"].values()) + return {"enabled": enabled, "outcomes": outcomes, + "single_removals_that_turn_creation_into_access_denied": + [name for name, outcome in outcomes["remove_only"].items() + if denied(outcome) and outcomes["original_process_token"]["created"]], + "single_retentions_that_still_create": + [name for name, outcome in outcomes["keep_only"].items() if outcome["created"]], + "probe_cleanup_verified": all(item.get("removed") is True for item in everything + if item["created"]), + "cause_established": False} + + +def privilege_isolation_on_fence_shape(native, make_directory, sid: str) -> dict: + """``privilege_isolation`` on the exact production parent deny, then a verified restoration.""" + parent = make_directory("privilege_isolation") + original, owner = native.read_dacl(parent), native.read_owner(parent) + native.apply_dacl(parent, with_ace_first(original, "(D;;%s;;;%s)" % (PARENT_RIGHTS, sid))) + try: + isolation = privilege_isolation(native, parent, parent / "state") + finally: + native.apply_dacl(parent, original) + restored = restoration_components(native, parent, original, owner) + return {"isolation": isolation, "restored": restored, + "verified": restored["verified"] and isolation["probe_cleanup_verified"]} + + +def installation_observation(native, sid: str, make_directory, stabilize) -> dict: + """Observation only: install ``(D;;LC;;;sid)`` first by descriptor on an unprotected fixture + that inherits its allows, with and without the protection flag ``apply_dacl`` normally sends, + and on a stabilized (protected, explicit) fixture, with per-component facts for each. + + Whether an installation was exact is kept apart from whether the fixture was put back: only + the stabilized installation gates ``stabilized_install_and_restoration_exact`` (the unprotected + shapes are diagnostics and may be inexact), while ``restoration_verified`` is false unless EVERY + fixture whose original descriptor was captured shows a verified restoration. A restoration that + is unverified or raised is listed in ``restoration_unverified``; a fixture that failed before + anything was captured has nothing to restore and is not counted.""" + result, unverified = {}, [] + for label, prepared, protection in (("unprotected_inherited_auto_flag", False, "auto"), + ("unprotected_inherited_no_flag", False, "none"), + ("stabilized_protected_auto_flag", True, "auto")): + entry, captured = {}, False + try: + parent = make_directory(label) + if prepared: + stabilize(parent) + original, owner = native.read_dacl(parent), native.read_owner(parent) + captured = True + entry["initial_control"] = split_dacl(original)[0] + try: + entry["installed"] = deny_by_descriptor(native, parent, sid, protection=protection)["components"] + finally: + native.apply_dacl(parent, original) + entry["restored"] = restoration_components(native, parent, original, owner) + except Exception as error: + entry["error"] = {"type": type(error).__name__, "code": getattr(error, "code", None)} + if captured: + entry["restoration_verified"] = entry.get("restored", {}).get("verified") is True + if not entry["restoration_verified"]: + unverified.append(label) + result[label] = entry + result["stabilized_install_and_restoration_exact"] = bool( + result["stabilized_protected_auto_flag"].get("installed", {}).get("installed_exactly") + and result["stabilized_protected_auto_flag"].get("restored", {}).get("verified")) + result["restoration_unverified"] = unverified + result["restoration_verified"] = not unverified + return result + + +def installation_section_verified(body: dict) -> bool: + """The standalone installation section is clean only with an exact stabilized installation AND a + verified restoration of every fixture that was captured; a body lacking either is not clean.""" + return bool(body.get("stabilized_install_and_restoration_exact") is True + and body.get("restoration_verified") is True) + + +def child_recreation_probe(parent: str, target: str, output: str) -> int: + """Child entry: the recreation report of the process this was started as, written to a file.""" + try: + body = recreation_report(WindowsNative(), parent, target) + except Exception as error: + body = {"error": {"type": type(error).__name__, "code": getattr(error, "code", None)}} + Path(output).write_text(json.dumps(body, sort_keys=True), encoding="utf-8") + return 0 + + +CHILD_VERDICTS = ("DENIED", "REFUSED_WITHOUT_ACCESS_DENIED", "FULL_TOKEN_CREATES_WHILE_BYPASS_PRIVILEGE_ENABLED", + "FULL_TOKEN_CREATES_UNEXPLAINED", "DACL_GRANTS_ADD_SUBDIRECTORY", "KERNEL_CREATE_IGNORES_DACL") + + +def launch_defects(launch: dict) -> list: + """Why a restricted-primary launch is not an accepted, completed and cleaned-up run. Every + launcher consumer requires this to be empty; the exit code alone is never acceptance.""" + reasons = [] + if not launch.get("launched"): + reasons.append("launch_failed") + if launch.get("timed_out"): + reasons.append("timed_out") + if launch.get("wait_failed") or launch.get("still_active"): + reasons.append("wait_incomplete") + if launch.get("exit_code") != 0: + reasons.append("nonzero_or_missing_exit") + if launch.get("completed") is not True: + reasons.append("launch_not_completed") + if launch.get("cleanup_verified") is not True: + reasons.append("launch_cleanup_unverified") + if launch.get("handles_retained"): + reasons.append("handles_retained") + if launch.get("privilege_restoration", {}).get("verified", True) is not True: + reasons.append("privilege_restoration_unverified") + if launch.get("errors"): + reasons.append("launch_errors") + if launch.get("child_stopped") is not True: + reasons.append("child_stop_unestablished") + return reasons + + +CHILD_REPORT_FIELDS = ("filesystem", "enabled_privileges", "disabled_privileges", "parent_dacl", "access_check", + "mkdir", "mkdir_CreateDirectoryW", "file_control", "non_denial_outcomes", + "probe_cleanup_verified", "verdict", "cause_established") + + +def child_evidence(launch: dict, out: Path): + """The child's report and the reasons, if any, it is not complete evidence. A missing, + malformed, non-object, error or incomplete report or any launch defect is a reason (the probe + was NOT_RUN or incomplete), never a pass. Whether a report was read is decided by the parse, not + by the parsed value, so a JSON ``null`` is a non-object report like any other.""" + reasons = launch_defects(launch) + child, parsed = None, False + if not out.exists(): + reasons.append("child_report_missing") + else: + try: + child, parsed = json.loads(out.read_text(encoding="utf-8")), True + except (OSError, ValueError): + reasons.append("child_report_malformed") + if parsed: + if not isinstance(child, dict): + reasons.append("child_reported_error") + child = None + elif "error" in child: + reasons.append("child_reported_error") + else: + if child.get("verdict") not in CHILD_VERDICTS: + reasons.append("child_verdict_missing_or_unknown") + if child.get("probe_cleanup_verified") is not True: + reasons.append("child_cleanup_unverified") + if any(field not in child for field in CHILD_REPORT_FIELDS): + reasons.append("child_report_incomplete") + return child, reasons + + +def restricted_primary_child_report(native, make_directory, sid: str, stabilize, hold=None) -> dict: + """The actual-fence-shape recreation report taken by a child whose PRIMARY token has every + privilege removed, beside the same report for this (original) process. + + ``restoration_verified`` (the fixture and this process's own probes) is kept apart from + ``evidence_complete`` (the child launched, finished cleanly in time, left nothing behind and + wrote a valid report); ``not_run`` lists why evidence is incomplete. + + The fixture's DACL is restored only when the child is known stopped (or was never started). + Otherwise the fixture is left as it is, ``"child_stop_unestablished"`` is appended to ``hold`` + (the caller must then not remove the fixture root) and the restoration is reported unverified; + this holds as well when the launcher raises.""" + parent = make_directory("restricted_primary_child") + stabilize(parent) + original, owner = native.read_dacl(parent), native.read_owner(parent) + out = Path(parent).parent / "restricted_primary_child.json" + out.unlink(missing_ok=True) + native.apply_dacl(parent, with_ace_first(original, "(D;;%s;;;%s)" % (PARENT_RIGHTS, sid))) + child_may_run = False + try: + original_report = recreation_report(native, parent, parent / "state") + child_may_run = True + launch = native.run_with_restricted_primary_token( + [sys.executable, str(Path(__file__).resolve()), "--child-recreation-probe", + str(parent), str(parent / "state"), str(out)], ROOT, timeout=300) + child_may_run = launch.get("child_stopped") is not True + child, reasons = child_evidence(launch, out) + finally: + if child_may_run: + if hold is not None: + hold.append("child_stop_unestablished") + restored = {"verified": False, "skipped": "child_stop_unestablished"} + else: + native.apply_dacl(parent, original) + restored = restoration_components(native, parent, original, owner) + is_report = isinstance(child, dict) + return {"launch": launch, "original_process_verdict": original_report["verdict"], + "child": child if is_report else None, "child_verdict": child.get("verdict") if is_report else None, + "restored": restored, "fixture_retained": child_may_run, + "restoration_verified": bool(restored["verified"] and original_report["probe_cleanup_verified"]), + "evidence_complete": not reasons and is_report, "not_run": reasons, + "cause_established": False} + + +@contextmanager +def owned_fixture_root(prefix: str, hold: list): + """An owned temporary root that is removed on exit unless ``hold`` is non-empty, which means a + child may still be running under it: then it is left in place and its handles stay owned.""" + root = tempfile.mkdtemp(prefix=prefix) + try: + yield Path(root) + finally: + if not hold: + shutil.rmtree(root) + + +class ChildStopHold: + """Couples fixture restoration and removal to authoritative child-stop evidence for callers that + launch through ``run_with_restricted_primary_token``: a launch whose child is not shown stopped, + or a launcher that raised, keeps the owned fixture and its temporary root and reports failure.""" + + def __init__(self): + self.reasons = [] + + @property + def held(self) -> bool: + return bool(self.reasons) + + def launch(self, native, argv, cwd, **kwargs) -> dict: + try: + launch = native.run_with_restricted_primary_token(argv, cwd, **kwargs) + except BaseException: + self.reasons.append("launcher_raised") + raise + if launch.get("child_stopped") is not True: + self.reasons.append("child_stop_unestablished") + return launch + + def settle(self, temp, restore) -> None: + """Run ``restore`` then remove ``temp``, unless the hold is set: then detach the temporary + directory's own finalizer (so garbage collection or interpreter exit cannot remove it either) + and fail without touching the fixture; a failed ``restore`` is retained the same way.""" + finalizer = getattr(temp, "_finalizer", None) + if self.reasons: + if finalizer is not None: + finalizer.detach() + raise AssertionError("fixture retained, restoration and removal skipped (%s): %s" + % (", ".join(self.reasons), temp.name)) + try: + restore() + except BaseException: + if finalizer is not None: + finalizer.detach() + raise + temp.cleanup() + + +def child_section_verified(body: dict) -> bool: + """The restricted-primary section is clean only when the fixture was restored AND the child's + evidence is complete; a restored fixture alone is never enough.""" + return bool(body["restoration_verified"] and body["evidence_complete"]) + + +def bootstrap_checkout() -> None: + """Make the checkout importable for the script entry only; never called when this file is + loaded by path, so the installed-wheel check keeps ``sys.path`` and its imports untouched.""" + if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def main(argv: list[str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else argv + if argv[:1] == ["--child-recreation-probe"] and len(argv) == 4: + return child_recreation_probe(*argv[1:]) + report = {"os_name": os.name, "completed_stages": []} + clean = True + stage = "bootstrap" + try: + bootstrap_checkout() + report["completed_stages"].append(stage) + stage = "checkout_imports" + from src.paper_pilot_phase_cutover import _current_sid + from tools import windows_fixture_acl + report["completed_stages"].append(stage) + stage = "native_api" + native = WindowsNative() + report["completed_stages"].append(stage) + stage = "identity" + sid = _current_sid() + report["runner_sid"] = redact(sid) + report["completed_stages"].append(stage) + if argv == ["--stages-only"]: + report["cleanup_verified"] = True + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + retain = [] + with owned_fixture_root("jraphyte-recreation-probe-", retain) as base: + + def make_directory(label: str) -> Path: + path = base / label + path.mkdir() + return path + + def stabilize(path) -> None: + windows_fixture_acl.stabilize_owned_fixture( + base, [("fixture", path)], read=native.read_dacl) + + def stable_directory(label: str) -> Path: + path = make_directory(label) + stabilize(path) + return path + sections = ( + ("matrix", lambda: candidate_matrix(native, sid, stable_directory), + lambda body: body["cleanup_verified"]), + ("actual_fence_shape", lambda: probe_actual_fence_shape(native, stable_directory, sid), + lambda body: body["verified"]), + ("privilege_isolation", lambda: privilege_isolation_on_fence_shape(native, stable_directory, sid), + lambda body: body["verified"]), + ("installation_observation", lambda: installation_observation(native, sid, make_directory, stabilize), + installation_section_verified), + ("restricted_primary_child", lambda: restricted_primary_child_report(native, make_directory, sid, stabilize, hold=retain), + child_section_verified)) + for section, produce, verified in sections: + try: + report[section] = produce() + report["completed_stages"].append(section) + clean = bool(verified(report[section])) and clean + except Exception as error: + report[section] = {"error": {"type": type(error).__name__, + "code": getattr(error, "code", None)}} + clean = False + if retain: + report["fixture_retained"] = True + clean = False + except Exception as error: + report["error"] = {"type": type(error).__name__, "code": getattr(error, "code", None), + "stage": stage} + clean = os.name != "nt" + report["cleanup_verified"] = clean + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 if clean else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/trace_gc/phase_authority.py b/trace_gc/phase_authority.py index 37e8ea5..eb235ac 100644 --- a/trace_gc/phase_authority.py +++ b/trace_gc/phase_authority.py @@ -8,8 +8,10 @@ from contextlib import contextmanager import ctypes +import functools from pathlib import Path import os +import re import threading from .canonical import bytes_digest, digest, dumps, loads @@ -63,6 +65,97 @@ def directory_dacl_sddl(path: str | Path) -> str: kernel.LocalFree(descriptor) +_NUMERIC_SID = re.compile(r"S-1-[0-9]+(?:-[0-9]+)+") + + +def _is_windows() -> bool: + return os.name == "nt" + + +@functools.lru_cache(maxsize=256) +def _windows_trustee(sid: str) -> str: + """Spell one numeric SID exactly as this machine's Windows writes it in a DACL. + + The numeric SID is parsed into an in-memory descriptor and encoded back by the + same conversion ``directory_dacl_sddl`` uses, so the result is whatever the + OS would have serialized (an alias such as ``BU``, or ``LA`` only when the SID + is this machine's own RID-500 account). No account lookup, DACL or file is + touched, and an alias is never mapped back to a numeric SID. + """ + advapi = ctypes.WinDLL("advapi32", use_last_error=True) + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + kernel.LocalFree.argtypes = (ctypes.c_void_p,) + kernel.LocalFree.restype = ctypes.c_void_p + parse = advapi.ConvertStringSecurityDescriptorToSecurityDescriptorW + parse.argtypes = (ctypes.c_wchar_p, ctypes.c_uint32, + ctypes.POINTER(ctypes.c_void_p), ctypes.c_void_p) + parse.restype = ctypes.c_int + encode = advapi.ConvertSecurityDescriptorToStringSecurityDescriptorW + encode.argtypes = (ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, + ctypes.POINTER(ctypes.c_wchar_p), ctypes.c_void_p) + encode.restype = ctypes.c_int + descriptor = ctypes.c_void_p() + require(bool(parse("D:(D;;DCLC;;;" + sid + ")", 1, ctypes.byref(descriptor), None)), + "PHASE_FENCE", "cannot parse SID trustee") + try: + value = ctypes.c_wchar_p() + require(bool(encode(descriptor, 1, 4, ctypes.byref(value), None)), + "PHASE_FENCE", "cannot encode SID trustee") + try: + text = value.value + finally: + kernel.LocalFree(ctypes.cast(value, ctypes.c_void_p)) + finally: + kernel.LocalFree(descriptor) + match = re.fullmatch(r"D:\(D;;DCLC;;;([^;()]+)\)", text) + require(match is not None, "PHASE_FENCE", "unexpected SID trustee encoding") + return match.group(1) + + +def canonical_dacl_trustee(trustee: str) -> str: + """Return ``trustee`` spelled as Windows serializes it in this machine's DACL text. + + Only a strictly numeric ``S-1-...`` SID is translated, forward to the OS + spelling, so distinct account/domain SIDs that share a RID (for example two + RID-500 administrators) stay distinct. Aliases and any other text are + returned unchanged. Off Windows the alias relation cannot be established, so + a numeric SID fails closed (``PHASE_PLATFORM``) instead of being compared + literally; a receipt written on another machine is judged only by what this + machine's DACL text and this machine's serialization say. + """ + if _NUMERIC_SID.fullmatch(trustee) is None: + return trustee + require(_is_windows(), "PHASE_PLATFORM", + "SID/SDDL trustee aliases can only be resolved on Windows") + return _windows_trustee(trustee) + + +def canonical_dacl_ace(ace: str) -> str: + """Normalize only the trustee field of one six-field ACE; all else stays exact.""" + fields = ace[1:-1].split(";") + if len(fields) != 6: + return ace + fields[5] = canonical_dacl_trustee(fields[5]) + return "(" + ";".join(fields) + ")" + + +def canonical_dacl_aces(sddl: str) -> list[str]: + """ACEs in DACL order with trustees spelled canonically (type, flags, rights untouched).""" + return [canonical_dacl_ace(ace) for ace in re.findall(r"\([^)]*\)", sddl)] + + +def dacl_has_ace(sddl: str, template: str, sid: str) -> bool: + """Whether the DACL holds the exact ACE ``template`` for ``sid`` under any trustee spelling.""" + return canonical_dacl_ace(template.format(sid=sid)) in canonical_dacl_aces(sddl) + + +def dacl_has_deny_trustee(sddl: str, sid: str) -> bool: + """Whether any plain deny ACE in the DACL names ``sid``, in any trustee spelling.""" + trustee = canonical_dacl_trustee(sid) + return any(ace.startswith("(D;") and ace.endswith(";;;" + trustee + ")") + for ace in canonical_dacl_aces(sddl)) + + class PhaseAuthority: """One exact phase, one shared cross-process writer lock and current pointer.""" @@ -172,7 +265,8 @@ def verify_fence(self): fence["old_root_path"] == str(old) and fence["archived_root_path"] == str(archived) and fence["status"] == "OLD_PATH_FENCED_NEW_PHASE_ALLOWED" and - type(deny_sid) is str and f"(D;;LC;;;{deny_sid})" in sddl and + type(deny_sid) is str and + dacl_has_ace(sddl, "(D;;LC;;;{sid})", deny_sid) and bytes_digest(sddl.encode("utf-8")) == fence["old_parent_dacl_sha256"] and set(fence["archived_files_sha256"]) == names and not old.exists() and archived.is_dir() and