From 62217c55aff9ad81db9529778f3b9bc31cc9d3c4 Mon Sep 17 00:00:00 2001 From: verlyn13 Date: Sat, 17 Oct 2026 09:22:36 -0800 Subject: [PATCH 1/3] chore(project): adopt Specification 1 Bind the public immutable Project Manifest Release 1 and its separate bootstrap, declare the native verification wrapper, and add offline declaration checks and regressions to make verify. Preserve the Bash/POSIX learner runtime and optional ShellCheck behavior. Prepare the verify workflow with explicit Python and checksum-pinned ephemeral Git. Record local qualification separately from hosted, adoption and runtime acceptance. Local focused 23 tests and the no-argument native gate passed; the final documentation-only closeout passed its scoped checks. --- .gitattributes | 2 + .github/workflows/verify.yml | 35 + AGENTS.md | 23 +- CONTRIBUTING.md | 35 +- Makefile | 19 +- README.md | 12 +- STATUS.md | 20 +- docs/project-manifest.md | 94 ++ project.yaml | 31 +- scripts/check-project-manifest.py | 204 +++ scripts/prepare-ci-git.py | 145 ++ scripts/verify-project.sh | 9 + tests/test_project_manifest.py | 258 ++++ vendor/project-manifest/release-1/LICENSE | 21 + .../release-1/docs/acquisition-candidate.md | 192 +++ .../release-1/docs/enum-provenance.md | 117 ++ .../release-1/docs/field-table.md | 86 ++ .../docs/offline-binding-candidate.md | 218 +++ .../release-1/docs/specification-candidate.md | 186 +++ .../release-1/docs/validation-candidate.md | 317 +++++ .../project-manifest/release-1/release.json | 1 + .../src/project_manifest/__init__.py | 16 + .../src/project_manifest/__main__.py | 14 + .../release-1/src/project_manifest/acquire.py | 1110 +++++++++++++++ .../src/project_manifest/candidate.py | 296 ++++ .../release-1/src/project_manifest/cli.py | 49 + .../src/project_manifest/validation.py | 266 ++++ .../release-1/tests/acquisition_fixtures.py | 286 ++++ .../release-1/tests/compare_loaders.py | 311 ++++ .../release-1/tests/run_corpus.py | 40 + .../release-1/tests/test_acquisition.py | 1260 +++++++++++++++++ .../release-1/tests/test_candidate.py | 732 ++++++++++ .../release-1/tests/test_cli.py | 101 ++ .../release-1/tests/test_validation.py | 431 ++++++ vendor/project-manifest/release_verifier.py | 277 ++++ 35 files changed, 7177 insertions(+), 37 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/verify.yml create mode 100644 docs/project-manifest.md create mode 100644 scripts/check-project-manifest.py create mode 100644 scripts/prepare-ci-git.py create mode 100755 scripts/verify-project.sh create mode 100644 tests/test_project_manifest.py create mode 100644 vendor/project-manifest/release-1/LICENSE create mode 100644 vendor/project-manifest/release-1/docs/acquisition-candidate.md create mode 100644 vendor/project-manifest/release-1/docs/enum-provenance.md create mode 100644 vendor/project-manifest/release-1/docs/field-table.md create mode 100644 vendor/project-manifest/release-1/docs/offline-binding-candidate.md create mode 100644 vendor/project-manifest/release-1/docs/specification-candidate.md create mode 100644 vendor/project-manifest/release-1/docs/validation-candidate.md create mode 100644 vendor/project-manifest/release-1/release.json create mode 100644 vendor/project-manifest/release-1/src/project_manifest/__init__.py create mode 100644 vendor/project-manifest/release-1/src/project_manifest/__main__.py create mode 100644 vendor/project-manifest/release-1/src/project_manifest/acquire.py create mode 100644 vendor/project-manifest/release-1/src/project_manifest/candidate.py create mode 100644 vendor/project-manifest/release-1/src/project_manifest/cli.py create mode 100644 vendor/project-manifest/release-1/src/project_manifest/validation.py create mode 100644 vendor/project-manifest/release-1/tests/acquisition_fixtures.py create mode 100644 vendor/project-manifest/release-1/tests/compare_loaders.py create mode 100644 vendor/project-manifest/release-1/tests/run_corpus.py create mode 100644 vendor/project-manifest/release-1/tests/test_acquisition.py create mode 100644 vendor/project-manifest/release-1/tests/test_candidate.py create mode 100644 vendor/project-manifest/release-1/tests/test_cli.py create mode 100644 vendor/project-manifest/release-1/tests/test_validation.py create mode 100644 vendor/project-manifest/release_verifier.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f539fbc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/project.yaml text eol=lf -filter -ident -working-tree-encoding +/vendor/project-manifest/** -text -filter -ident -working-tree-encoding diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..2edf7c1 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,35 @@ +name: verify + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Prepare contributor Python + id: python + uses: actions/setup-python@v6 + with: + python-version: "3.13.16" + - name: Prepare Git build prerequisites + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential zlib1g-dev + - name: Prepare checksum-bound Git 2.56 + id: git + env: + PREPARED_PYTHON: ${{ steps.python.outputs.python-path }} + run: '"$PREPARED_PYTHON" -I -S -B scripts/prepare-ci-git.py' + - name: Verify source and revision declaration + env: + PREPARED_PYTHON: ${{ steps.python.outputs.python-path }} + PREPARED_GIT_BIN: ${{ steps.git.outputs.git_bin }} + SHELLTUTOR_MANIFEST_REV: ${{ github.sha }} + run: | + export PATH="$PREPARED_GIT_BIN:$PATH" + make verify PYTHON="$PREPARED_PYTHON" diff --git a/AGENTS.md b/AGENTS.md index 068599c..c0c6687 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,8 @@ title: shelltutor Agents Contract category: governance component: agents-contract status: active -version: 0.1.0 -last_updated: 2026-07-14 +version: 0.2.0 +last_updated: 2026-10-17 tags: [agents, claude, codex, authority, boundary] priority: high --- @@ -69,7 +69,9 @@ operator-named comments). network access, privilege handling, sandbox boundaries) only when the user request clearly calls for it. - **Restricted work** — Do not introduce dependencies that require - installation outside `bash` and a standard POSIX userland; do not access + installation outside `bash` and a standard POSIX userland in the learner + runtime. Contributor-only declaration validation uses prepared Python 3.11+ + and Git 2.56+ as documented below. Do not access files outside this repository during normal operation; do not perform account, billing, or credential changes. @@ -90,6 +92,21 @@ When authority is unclear, ask before acting. ## Validation +- `make verify` is the contributor source gate. It includes the offline + Specification 1 declaration check and its regression tests, followed by the + existing static checks, optional ShellCheck and smoke checks. It installs + nothing. `scripts/verify-project.sh` is its no-argument manifest entrypoint. +- Adoption: root `project.yaml` follows Specification 1, with repo-owned + immutable Release 1 binding from `jefahnierocks/project-manifest`; see + [the binding and contributor workflow](docs/project-manifest.md). No parent + release pin or global runtime default is inherited. +- Keep the vendored release and separate bootstrap byte-exact; never format, + patch or import them before the consumer verifies their independent pins. +- A declaration verdict does not prove tutor portability, native gate success, + producer admission or runtime acceptance. Local no-argument validation reads + tracked working bytes; CI also selects the exact commit with + `SHELLTUTOR_MANIFEST_REV`. That selector does not turn the native gate into + a historical-source execution. - Run the tutor (`./shelltutor`) and complete at least the lesson surface affected by a change. - For portability changes, validate on both Linux and macOS when possible, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e1a35e4..5a4b812 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,8 +3,8 @@ title: shelltutor Contributing category: process component: contributing status: active -version: 0.1.0 -last_updated: 2026-07-14 +version: 0.2.0 +last_updated: 2026-10-17 tags: [contributing, verification, change-discipline] priority: medium --- @@ -33,16 +33,17 @@ implying success. ## Quality Gates -The quality gates are wrapped in `Makefile` and run locally without external -infrastructure (CI activation is deferred per the GitHub Posture section -below). The intent is a one-command pre-commit verification for -contributors. +The quality gates are wrapped in `Makefile` and run locally without network +access or tool installation. Prepare contributor Python 3.11+ and Git 2.56+ +first; `make verify` refuses unavailable declaration prerequisites. CI runs the +same gate on every push and pull request. The learner runtime remains Bash-only. ```bash make check # FF-001 + FF-002 + FF-004 + FF-007 static analysis (always runs) make lint # shellcheck shelltutor (FF-005; skips quietly if absent) make smoke # FF-006a minimal static smoke test -make verify # check + lint + smoke (full gate) +make verify # released manifest check/tests + check + lint + smoke +make manifest-check # declaration only; not a substitute for make verify make self-test # exercise the checkers against built-in fixtures make lesson-flow # FF-006b PTY harness (optional; Python 3.9+; not in verify) ``` @@ -71,7 +72,15 @@ without being flagged. ## Contributor Tooling Floor The runtime tutor (`shelltutor`) is bash-only and POSIX-userland only, -per AGENTS.md §Authority Levels. Optional contributor-side tooling +per AGENTS.md §Authority Levels. Required contributor declaration verification +uses Python 3.11+ (stdlib only) and Git 2.56+; select an already prepared Python +with `make verify PYTHON=/absolute/path/to/python3` and put the prepared Git +first on command-scoped `PATH`. No global tool pin, mise trust, environment +activation or package installation is performed by the gate. The hosted setup +uses Python 3.13.16 and checksum-pinned Git 2.56.0; see +[the preparation and verdict contract](docs/project-manifest.md). + +Optional contributor-side tooling under `scripts/sim/` (FF-006b PTY harness) requires Python 3.9+ — syntax and stdlib use must not exceed that floor so the harness runs without a venv whenever Python 3.9+ is installed on macOS or Linux. @@ -110,7 +119,9 @@ machine. ## GitHub Posture -Following the Jefahnierocks workspace posture, this repository intentionally -omits CI workflows, `CODEOWNERS`, PR templates, branch protection, and -Actions secrets until a real activation trigger exists. Until then, local -commit discipline is the enforcement layer. +Owner-directed Specification 1 adoption is the activation trigger for the +`verify` workflow. It runs on push and pull request without path or branch +filters, job conditions or allowed failures. Its source preparation is separate +from actual hosted qualification and branch-protection readback; record those +results in `STATUS.md` before claiming enforcement. No deployment or secrets +are needed. `CODEOWNERS`, PR templates and unrelated automation remain deferred. diff --git a/Makefile b/Makefile index 45bba71..bfc800e 100644 --- a/Makefile +++ b/Makefile @@ -2,9 +2,8 @@ # # Bash-only / POSIX-userland operations per AGENTS.md "Authority Levels". # Optional contributor-side tools (shellcheck) are invoked only when -# present. CI activation is deferred per CONTRIBUTING.md "GitHub Posture"; -# these targets give contributors a one-command pre-commit verification -# without imposing a CI dependency. +# present. Contributor manifest verification uses prepared Python 3.11+ and +# Git 2.56+; the learner-facing tutor still needs only Bash/POSIX tools. # # Targets: # check FF-001 + FF-002 + FF-004 + FF-007 static analysis (default) @@ -14,9 +13,10 @@ # self-test run checker self-tests against built-in fixtures # help list targets -.PHONY: help check check-safety check-governance check-portability lint smoke lesson-flow verify self-test +.PHONY: help check check-safety check-governance check-portability lint smoke lesson-flow verify self-test manifest-check manifest-self-test SCRIPT := shelltutor +PYTHON ?= python3 # Optional pass-through args for `lesson-flow`, e.g.: # make lesson-flow SIMARGS='--variant current-rc -v' @@ -28,7 +28,8 @@ help: @printf ' lint shellcheck $(SCRIPT) (FF-005)\n' @printf ' smoke FF-006a minimal static smoke test\n' @printf ' lesson-flow FF-006b PTY harness (optional; Python 3.9+; not in verify)\n' - @printf ' verify check + lint + smoke\n' + @printf ' verify manifest check/tests + check + lint + smoke\n' + @printf ' manifest-check released declaration checks (prepared Python/Git)\n' @printf ' self-test run checker self-tests\n' check: check-safety check-governance check-portability @@ -75,7 +76,13 @@ lesson-flow: printf 'lesson-flow: python3 not found; skipping (FF-006b; see docs/simulation-design-plan.md)\n'; \ fi -verify: check lint smoke +manifest-check: + @"$(PYTHON)" -I -S -B scripts/check-project-manifest.py + +manifest-self-test: + @"$(PYTHON)" -I -S -B -m unittest discover -s tests -p 'test_project_manifest.py' -v + +verify: manifest-check manifest-self-test check lint smoke self-test: @./scripts/check-safety.sh --self-test diff --git a/README.md b/README.md index 2893586..d43fecb 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ title: shelltutor category: reference component: project-overview status: active -version: 0.1.0 -last_updated: 2026-07-14 +version: 0.2.0 +last_updated: 2026-10-17 tags: [shell, bash, tutor, education, unix, posix] priority: high --- @@ -53,7 +53,7 @@ shelltutor/ ├── CONTRIBUTING.md # Change discipline + Quality Gates ├── STATUS.md # Current truth, posture, deferrals ├── ROADMAP.md # Phase sequence; Phase 3 carries the curriculum spec -├── project.yaml # Meta-inventory project-intelligence manifest +├── project.yaml # Specification 1 project/workstation declaration ├── shelltutor # The tutor itself (single bash script, executable) ├── Makefile # Local quality gates and optional lesson-flow target ├── scripts/ # Static checkers, smoke test, and optional PTY harness @@ -83,6 +83,12 @@ The tutor is a single bash script with no installation step: ./shelltutor -h # help ``` +Contributors use `make verify` after preparing Python 3.11+ and Git 2.56+. +These are validation tools, not learner prerequisites. See +[project declaration and verification](docs/project-manifest.md) for offline +release binding, linked-worktree arguments and CI setup. The tutor remains a +single Bash script with no installation step. + Requirements: - A POSIX-ish terminal on the target Linux or macOS surface; cross-platform diff --git a/STATUS.md b/STATUS.md index 9f54a92..b2ce829 100644 --- a/STATUS.md +++ b/STATUS.md @@ -3,8 +3,8 @@ title: shelltutor Status category: status component: project-status status: active -version: 0.1.0 -last_updated: 2026-07-14 +version: 0.2.0 +last_updated: 2026-10-17 tags: [status, posture, deferrals] priority: high --- @@ -34,9 +34,16 @@ Current truth for the `shelltutor` repository. - Day-1 documentation set (`README.md`, `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, `STATUS.md`, `ROADMAP.md`) plus `docs/contracts.md` is in place. -- Root `project.yaml` v0.1 projects this file into meta-inventory as a - public-available, linkable working prototype. It deliberately carries no - deployed-system or operational-state claim. +- Root `project.yaml` follows Specification 1 and projects this file as a + public-available, linkable working prototype. Required operational state is + `unverified`; it makes no deployed-system claim. The development check points + to `scripts/verify-project.sh`, which delegates to the existing `make verify`. +- Owner-directed adoption source is prepared with the public immutable Release 1 + bundle and separate bootstrap. Contributor Python/Git prerequisites are separate + from the Bash/POSIX learner runtime. On 2026-10-17, all 23 focused regression + tests and the no-argument native gate passed locally with prepared Python 3.13.16 + and Git 2.56.0. Hosted qualification, adoption acceptance and effective branch + enforcement remain pending; local source validation is not runtime acceptance. - The tutor script (`shelltutor`) was imported from prior-art at `fedora-top:~/Projects/shelltutor` (commit `136f6a3`, 2026-05-17). - **ROADMAP Phase 1 — User-Agnostic Refactor — is complete** (commit @@ -122,7 +129,8 @@ Current truth for the `shelltutor` repository. |------------------|--------|--------------------| | `LICENSE` file | Not yet chosen; repo is public but default copyright applies | Before inviting outside reuse/contribution or tagging a release | | `CHANGELOG.md` | Nothing released yet; no tagged history | First tagged release | -| GitHub-side automation (workflows, `CODEOWNERS`, PR templates, branch protection) | Workspace posture defers these until a real trigger exists | Real activation trigger | +| `CODEOWNERS`, PR templates and unrelated GitHub automation | Not needed by the declaration migration | A project-specific need | +| Hosted `verify` qualification and branch enforcement | Specification 1 adoption supplies the activation trigger; source is prepared | Actual hosted checks and effective protection readback | | Cross-platform CI | Same as above; manual validation suffices for now | When manual validation stops scaling | | Package distribution (Homebrew tap, AUR, etc.) | Single-file script; install is `clone + run` | If users ask for managed install | diff --git a/docs/project-manifest.md b/docs/project-manifest.md new file mode 100644 index 0000000..8d78892 --- /dev/null +++ b/docs/project-manifest.md @@ -0,0 +1,94 @@ +--- +title: Project declaration and contributor verification +category: reference +component: project-interface +status: active +version: 0.1.0 +last_updated: 2026-10-17 +tags: [manifest, contributors, verification] +priority: medium +--- + +# Project declaration and contributor verification + +`project.yaml` follows Specification 1 from +[project-manifest Release 1](https://github.com/jefahnierocks/project-manifest/releases/tag/release-1). +It is the one project declaration for inventory and workstation consumers. +`STATUS.md` remains the detailed status of record. Declaration values neither +execute commands nor prove a deployment, producer admission or runtime acceptance. + +The repo owns the offline binding in `scripts/check-project-manifest.py`: + +| Binding | Exact value | +| --- | --- | +| Release source | `56e3920089726e585eaa0fd7f4dc94e6938a1e0c` | +| Domain-separated release-table digest | `f7ff9dd0972af5d3a6a3348c3e092de1a6acd97fa3975e13e522cf80ce5e7df2` | +| Separate bootstrap SHA-256 | `2ccf58e0da97bbbd9dae173d1cca047d465c4a2d950ee6e56052309a9689cf4f` | + +The twenty-file payload plus descriptor live in `vendor/project-manifest/release-1/`. +The separately pinned bootstrap is `vendor/project-manifest/release_verifier.py`. +These are unmodified public MIT artifacts, with the license retained. That license +does not decide shelltutor's still-deferred product license. Git attributes protect +their literal bytes and the root manifest's LF representation. Re-vendoring is a +reviewed project change, not an automatic dependency update. + +## Contributor entrypoint + +Prepare Python 3.11+ and Git 2.56+ outside the gate, then run `make verify` or +`scripts/verify-project.sh`. The latter takes no arguments and delegates to Make. +The gate uses no network and installs nothing. A selected interpreter can be passed +as `PYTHON=/absolute/path/to/python3`; put prepared Git first on a command-scoped +`PATH`. Never change global runtimes to satisfy this repo. + +`make verify` checks the independently bound release, the declaration and consumer +regressions, then preserves the existing static, optional ShellCheck and smoke +checks. `make self-test` still exercises the four native checkers. Python/Git are +contributor tools only: a learner still runs `./shelltutor` with Bash/POSIX tools. +The optional PTY harness retains its separate Python 3.9+ floor and its existing +non-gating status. The five-stage human portability walks remain separate. + +The declaration maps only `check` to the executable wrapper. Setup/build/dev/test/ +format roles are null rather than inventing commands; native checker self-tests +remain available through Make. Activation is `none`, with no secrets, stacks or +host requirements. Empty platforms makes no architecture qualification claim. + +## Observation selection + +No arguments selects tracked working bytes. A newly added target must be in the +index before Git can include it in this observation. Direct staged checking uses: + +```bash +python3 -I -S -B scripts/check-project-manifest.py --staged +``` + +For a linked worktree, explicitly provide its reviewed Git common directory with +`--git-common-dir /absolute/common/directory`, or set `SHELLTUTOR_GIT_COMMON_DIR` +for `make verify`. No other checkout is inferred or searched. Inherited Git +overrides are refused; the gate never silently removes them. + +`--rev FULL_COMMIT_OID`, or `SHELLTUTOR_MANIFEST_REV` for Make/CI, checks the +declaration and references in that commit's tree. Native checks still execute the +current checkout; a revision selector does not certify a historical native gate. +Each observation uses one captured UTC date. Exit 0 means the released declaration +checks and this repo's expected command/activation shape passed; exit 1 means an +invalid declaration, binding mismatch or shape disagreement; exit 2 means a check +could not run. The JSON keeps declaration, native-shape and runtime claims separate. + +## Hosted preparation + +The new `verify` workflow is a source proposal until its first actual hosted run +passes. It uses Ubuntu 24.04, explicitly selected Python 3.13.16 from setup-python, +and the Git-only `scripts/prepare-ci-git.py`. Preparation downloads Git 2.56.0 from +the fixed kernel.org URL, checks its pinned official HTTPS SHA-256, and builds into +one fresh runner-owned temporary prefix with no Rust or install hardlinks. It does +not install a Python floor, Gitleaks or project dependencies. Compiler/zlib build +prerequisites are prepared explicitly in the ephemeral runner. This is not a tar +signature verification claim. + +Only the verification step prepends that Git to PATH. `make verify` receives the +explicit prepared Python and full GitHub commit OID. Both push and pull_request +run without branch/path filters; the job is named `verify`. Workflow existence does +not prove effective required checks. Actual hosted qualification and protection +readback are recorded separately before landing/enforcement claims. +See [the current qualification status](../STATUS.md) for completed and pending +checks. diff --git a/project.yaml b/project.yaml index 8ac709d..84787b8 100644 --- a/project.yaml +++ b/project.yaml @@ -1,20 +1,37 @@ -# Meta-inventory interop header; contract: -# meta-inventory/docs/decisions/0002-project-intelligence-spec.md §D3 -# (enum registry: meta-inventory/docs/ontology/dimensions.yaml) -spec_version: 0.1 +spec_version: "1" project: id: shelltutor - display_name: shelltutor + display_name: "shelltutor" owner_entity: jefahnierocks role: canonical +canonical: null peers: [] status: posture: working-prototype + operational_state: unverified local_phase: "Pre-release implementation: Phase 3 complete; Phase 2 portability validation and Phase 4 release posture pending" - as_of: "2026-07-14" + as_of: "2026-10-17" authority: - status_of_record: STATUS.md + status_of_record: "STATUS.md" presentation: visibility: public-available public_alias: null link_policy: link-ok +development: + platforms: [] + commands: + setup: null + check: + runner: script + target: "scripts/verify-project.sh" + test: null + build: null + dev: null + format_check: null + format_write: null + environment: + owner: none + template: null + secret_launcher: null + stacks: {} + host_requirements: [] diff --git a/scripts/check-project-manifest.py b/scripts/check-project-manifest.py new file mode 100644 index 0000000..1874b63 --- /dev/null +++ b/scripts/check-project-manifest.py @@ -0,0 +1,204 @@ +"""Shelltutor's offline Release 1 binding; never a learner dependency.""" + +import argparse +from contextlib import contextmanager +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import sys +from types import ModuleType + +ROOT = Path(__file__).resolve().parent.parent +SOURCE = "56e3920089726e585eaa0fd7f4dc94e6938a1e0c" +TABLE_DIGEST = "f7ff9dd0972af5d3a6a3348c3e092de1a6acd97fa3975e13e522cf80ce5e7df2" +BOOTSTRAP_DIGEST = "2ccf58e0da97bbbd9dae173d1cca047d465c4a2d950ee6e56052309a9689cf4f" +BINDING_FORMAT = "project-manifest-release-1" +PROTOCOL = "project-manifest-git-observation-1" +NAMESPACE = "_shelltutor_project_manifest" +RUNTIME_MODULES = ("candidate", "validation", "acquire") +MAX_FILE_BYTES = 1_048_576 + + +class Refused(Exception): + def __init__(self, code, exit_code=2): + self.code, self.exit_code = code, exit_code + + +def require(condition, code, exit_code=2): + if not condition: + raise Refused(code, exit_code) + + +def identity(info): + return (info.st_dev, info.st_ino, info.st_mode, info.st_nlink, + info.st_size, info.st_mtime_ns, info.st_ctime_ns) + + +def capture(path): + """Bounded no-follow read, including every directory component.""" + path = Path(path) + require(path.is_absolute() and ".." not in path.parts, "SOURCE_PATH") + parent = os.open("/", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + descriptor = None + try: + for part in path.parts[1:-1]: + child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=parent) + previous, parent = parent, child + os.close(previous) + descriptor = os.open(path.name, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW, + dir_fd=parent) + before = os.fstat(descriptor) + require(stat.S_ISREG(before.st_mode) and before.st_nlink == 1 + and before.st_size <= MAX_FILE_BYTES, "SOURCE_FILE") + chunks, count = [], 0 + while True: + chunk = os.read(descriptor, min(65_536, MAX_FILE_BYTES + 1 - count)) + if not chunk: + break + chunks.append(chunk) + count += len(chunk) + require(count <= MAX_FILE_BYTES, "SOURCE_BOUND") + require(count == before.st_size and identity(before) == identity(os.fstat(descriptor)) + == identity(os.stat(path.name, dir_fd=parent, follow_symlinks=False)), + "SOURCE_CHANGED") + return b"".join(chunks) + finally: + try: + if descriptor is not None: + os.close(descriptor) + finally: + os.close(parent) + + +def execute_module(name, data, filename, *, package=False): + require(name not in sys.modules, "MODULE_COLLISION") + module = ModuleType(name) + module.__file__ = str(filename) + module.__package__ = name if package else name.rpartition(".")[0] + if package: + module.__path__ = [] + sys.modules[name] = module + try: + exec(compile(data, str(filename), "exec"), module.__dict__) + except BaseException: + sys.modules.pop(name, None) + raise + return module + + +@contextmanager +def released_api(root): + """Import only captured bytes from an independently pinned public release.""" + root = Path(root) + bootstrap_path = root / "vendor/project-manifest/release_verifier.py" + bundle = root / "vendor/project-manifest/release-1" + bootstrap_data = capture(bootstrap_path) + require(hashlib.sha256(bootstrap_data).hexdigest() == BOOTSTRAP_DIGEST, + "BOOTSTRAP_DIGEST", 1) + loaded = [] + try: + verifier_name = NAMESPACE + "_bootstrap" + verifier = execute_module(verifier_name, bootstrap_data, bootstrap_path) + loaded.append(verifier_name) + verified = verifier.verify_release(str(bundle), expected_digest=TABLE_DIGEST, + expected_format=BINDING_FORMAT) + require(verified.exit_code == 0, "RELEASE_BINDING", verified.exit_code) + descriptor = verified.descriptor + require(descriptor["source_revision"] == SOURCE + and descriptor["observation_protocol"] == PROTOCOL, + "RELEASE_IDENTITY", 1) + rows = {row["path"]: row for row in descriptor["files"]} + captured = {} + for name in RUNTIME_MODULES: + path = "src/project_manifest/" + name + ".py" + data = capture(bundle / path) + require(len(data) == rows[path]["bytes"] + and hashlib.sha256(data).hexdigest() == rows[path]["sha256"], + "PAYLOAD_CHANGED", 1) + captured[name] = data + execute_module(NAMESPACE, b"", bundle / "src/project_manifest", package=True) + loaded.append(NAMESPACE) + for name, data in captured.items(): + full_name = NAMESPACE + "." + name + execute_module(full_name, data, bundle / "src/project_manifest" / (name + ".py")) + loaded.append(full_name) + yield sys.modules[NAMESPACE + ".acquire"] + require(capture(bootstrap_path) == bootstrap_data, "BOOTSTRAP_CHANGED", 1) + repeated = verifier.verify_release(str(bundle), expected_digest=TABLE_DIGEST, + expected_format=BINDING_FORMAT) + require(repeated.exit_code == 0 and repeated.descriptor == descriptor, + "RELEASE_CHANGED", 1) + finally: + for name in reversed(loaded): + sys.modules.pop(name, None) + + +def native_shape(record): + """Match this repo's declared entrypoint; this does not execute native checks.""" + expected = {role: None for role in + ("setup", "check", "test", "build", "dev", "format_check", "format_write")} + expected["check"] = {"runner": "script", "target": "scripts/verify-project.sh"} + development = record["development"] + return (development["commands"] == expected + and development["environment"] == { + "owner": "none", "template": None, "secret_launcher": None} + and development["stacks"] == {} and development["host_requirements"] == []) + + +def check(root, *, mode="worktree", rev=None, common_dir=None): + require(sys.version_info >= (3, 11) and sys.flags.isolated + and sys.flags.no_site and sys.dont_write_bytecode, "PYTHON_RUNTIME") + require(mode in {"worktree", "staged", "rev"}, "SOURCE_SELECTION") + require((mode == "rev" and isinstance(rev, str) + and re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", rev)) + or (mode != "rev" and rev is None), "SOURCE_SELECTION") + day = datetime.now(timezone.utc).date().isoformat() + with released_api(root) as api: + result = api.observe_released_repository(str(root), mode=mode, rev=rev, + common_dir=common_dir, reference_date=day) + require(result.diagnostics.get("scope") == PROTOCOL, "OBSERVATION_PROTOCOL") + report = dict(result.diagnostics) + report.update(consumer="shelltutor", release_id="1", release_source=SOURCE, + release_table_digest=TABLE_DIGEST, native_contract="not_checked", + native_gate="not_checked", runtime_acceptance="not_checked") + if result.exit_code == 0: + require(mode != "rev" or report["commit_oid"] == rev, "REVISION_CHANGED") + matched = native_shape(result.record) + report["native_contract"] = "matched" if matched else "mismatch" + if not matched: + report["exit_code"] = 1 + report["consumer_findings"] = ["NATIVE_DECLARATION_MISMATCH"] + return report + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + selection = parser.add_mutually_exclusive_group() + selection.add_argument("--staged", action="store_true") + selection.add_argument("--rev") + parser.add_argument("--git-common-dir", default=os.environ.get("SHELLTUTOR_GIT_COMMON_DIR")) + args = parser.parse_args(argv) + try: + environment_rev = os.environ.get("SHELLTUTOR_MANIFEST_REV") + require(not (args.staged and environment_rev), "SOURCE_SELECTION") + require(not (args.rev and environment_rev and args.rev != environment_rev), "SOURCE_SELECTION") + rev = args.rev or environment_rev or None + mode = "staged" if args.staged else "rev" if rev else "worktree" + report = check(ROOT, mode=mode, rev=rev, common_dir=args.git_common_dir) + except Refused as error: + report = {"consumer": "shelltutor", "exit_code": error.exit_code, + "declaration_checks": "not_checked", "consumer_findings": [error.code]} + except (OSError, ValueError, TypeError, KeyError, RuntimeError, RecursionError): + report = {"consumer": "shelltutor", "exit_code": 2, + "declaration_checks": "not_checked", "consumer_findings": ["CONSUMER_UNAVAILABLE"]} + print(json.dumps(report, sort_keys=True)) + return report["exit_code"] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepare-ci-git.py b/scripts/prepare-ci-git.py new file mode 100644 index 0000000..ab49270 --- /dev/null +++ b/scripts/prepare-ci-git.py @@ -0,0 +1,145 @@ +"""Prepare Git only in an owned GitHub Linux runner directory. + +Derived from project-manifest prepare-linux-tools.py at 9797b48786f6da0e4e5167a25c2a873e6945269c +(source SHA256 6ce866d65811bc1e0f9e05ef5185494b489f260aa8e2d359989e3fd18a790504). +MIT licensed; see vendor/project-manifest/release-1/LICENSE. +Never called by make verify or the learner-facing tutor. +""" +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import platform +import signal +import stat +import subprocess +import sys +import tarfile +import tempfile +import time +from urllib.request import Request, urlopen + +ROOT = Path(__file__).resolve().parent.parent +MAX_DOWNLOAD = 64 * 1024 * 1024 +MAX_EXPANDED = 512 * 1024 * 1024 +MAX_MEMBERS = 25_000 +GIT = { + "version": "2.56.0", + "url": "https://www.kernel.org/pub/software/scm/git/git-2.56.0.tar.xz", + "checksum": "sha256:26c56c296b38c0695b26fa95f475f1d01704d2d38e73465ca30b0b2f5dc789d3", +} + +class PreparationError(Exception): + pass + + +def require(condition, code): + if not condition: + raise PreparationError(code) + + +def download(info, path): + digest = hashlib.sha256() + size = 0 + deadline = time.monotonic() + 60 + with urlopen(Request(info["url"], headers={"User-Agent": "shelltutor-source-qualification"}), timeout=30) as response: + with path.open("xb") as output: + while chunk := response.read(65_536): + size += len(chunk) + require(size <= MAX_DOWNLOAD and time.monotonic() <= deadline, "DOWNLOAD_BOUND") + digest.update(chunk) + output.write(chunk) + require("sha256:" + digest.hexdigest() == info["checksum"], "ARCHIVE_CHECKSUM") + + +def extract(archive, destination): + require(not destination.exists(), "EXTRACTION_TARGET_EXISTS") + with tarfile.open(archive, "r:*") as source: + members = source.getmembers() + require(len(members) <= MAX_MEMBERS, "ARCHIVE_MEMBER_BOUND") + require(sum(member.size for member in members) <= MAX_EXPANDED, "ARCHIVE_BYTE_BOUND") + for member in members: + parts = PurePosixPath(member.name).parts + require(parts and not member.name.startswith("/") and ".." not in parts + and "\\" not in member.name and "\x00" not in member.name, "ARCHIVE_PATH") + require(member.isfile() or member.isdir() or member.issym(), "ARCHIVE_ENTRY") + if member.issym(): + require(not member.linkname.startswith("/") and "\\" not in member.linkname, "ARCHIVE_LINK") + tarfile.data_filter(member, str(destination)) + destination.mkdir() + source.extractall(destination, members=members, filter="data") + + +def run(argv, *, cwd, seconds=600): + # No shell or inherited process group. Only this owned child group is cancelled. + process = subprocess.Popen(argv, cwd=cwd, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + try: + process.communicate(timeout=seconds) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.communicate() + raise PreparationError("PREPARATION_TIMEOUT") from None + require(process.returncode == 0, "PREPARATION_COMMAND") + + +def observed(argv): + result = subprocess.run(argv, capture_output=True, check=False, timeout=10) + require(result.returncode == 0 and len(result.stdout) + len(result.stderr) <= 16_384, "TOOL_OBSERVATION") + return result.stdout.decode("utf-8").strip() + + +def prepare(): + require(not sys.argv[1:], "NO_ARGUMENTS") + require(platform.system() == "Linux" and platform.machine() == "x86_64" + and os.environ.get("GITHUB_ACTIONS") == "true" + and os.environ.get("RUNNER_OS") == "Linux" + and os.environ.get("RUNNER_ARCH") == "X64", "HOSTED_LINUX_ONLY") + require(sys.version_info[:3] == (3, 13, 16) and sys.flags.isolated + and sys.flags.no_site and sys.dont_write_bytecode + and hasattr(tarfile, "data_filter"), "PREPARED_PYTHON_REQUIRED") + parent = Path(os.environ.get("RUNNER_TEMP", "")) + require(parent.is_absolute() and parent.is_dir() and not parent.is_symlink(), "RUNNER_TEMP_REQUIRED") + parent = parent.resolve() + require(parent != ROOT and ROOT not in parent.parents, "TOOL_PREFIX_IN_CHECKOUT") + for name in ("GITHUB_OUTPUT",): + require(name in os.environ and Path(os.environ[name]).is_absolute(), "RUNNER_OUTPUT_REQUIRED") + container = Path(tempfile.mkdtemp(prefix="shelltutor-git-", dir=parent)) + require(container.resolve().parent == parent and container.stat().st_uid == os.getuid(), + "TOOL_PREFIX_OWNERSHIP") + (container / "qualification-owner").write_text("Owned shelltutor CI Git preparation\n") + archive, destination = container / "git.archive", container / "source" + download(GIT, archive) + extract(archive, destination) + prefix = container / "git-prefix" + run(["make", "-j2", "prefix=" + str(prefix), "NO_OPENSSL=YesPlease", + "NO_CURL=YesPlease", "NO_EXPAT=YesPlease", "NO_GETTEXT=YesPlease", + "NO_TCLTK=YesPlease", "NO_PERL=YesPlease", "NO_PYTHON=YesPlease", + "NO_RUST=YesPlease", "NO_INSTALL_HARDLINKS=YesPlease", "all", "install"], + cwd=destination / ("git-" + GIT["version"])) + binary = prefix / "bin/git" + info = binary.lstat() + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1 + and os.access(binary, os.X_OK) and container in binary.resolve().parents, + "PREPARED_GIT_FILE") + version = observed([str(binary), "--version"]) + require(version == "git version " + GIT["version"], "PREPARED_GIT_VERSION") + report = {"scope": "shelltutor-owned-ci-git", "git_version": version, + "git_sha256": hashlib.sha256(binary.read_bytes()).hexdigest(), + "source_checksum": GIT["checksum"], "single_link": True, + "checksum_provenance": "official-HTTPS-SHA256; tar-signature-not-verified"} + (container / "tool-observation.json").write_text(json.dumps(report, sort_keys=True) + "\n") + require(not any(c in str(binary) for c in "\r\n"), "OUTPUT_PATH") + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as stream: + stream.write("git_bin=" + str(binary.parent) + "\n") + print(json.dumps(report, sort_keys=True)) + + +if __name__ == "__main__": + try: + prepare() + except (PreparationError, OSError, ValueError, KeyError, tarfile.TarError) as error: + print(json.dumps({"scope": "shelltutor-owned-ci-git", "exit_code": 2, + "reason": str(error) if isinstance(error, PreparationError) else type(error).__name__}, + sort_keys=True)) + raise SystemExit(2) diff --git a/scripts/verify-project.sh b/scripts/verify-project.sh new file mode 100755 index 0000000..17e5cf6 --- /dev/null +++ b/scripts/verify-project.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Stable no-argument project check; the Makefile owns the native gate. +set -eu +if [ "$#" -ne 0 ]; then + printf 'usage: scripts/verify-project.sh\n' >&2 + exit 2 +fi +cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P)" +exec make verify diff --git a/tests/test_project_manifest.py b/tests/test_project_manifest.py new file mode 100644 index 0000000..5085ebc --- /dev/null +++ b/tests/test_project_manifest.py @@ -0,0 +1,258 @@ +"""Owned synthetic repositories exercise the real bound release consumer.""" + +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import tarfile +from types import SimpleNamespace +import unittest +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +CONSUMER = ROOT / "scripts/check-project-manifest.py" +GIT = shutil.which("git") + + +class ManifestTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="shelltutor-manifest-test-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + self.env = {key: value for key, value in os.environ.items() + if not key.startswith(("GIT_", "SHELLTUTOR_"))} + self.env.update(HOME=str(self.root), XDG_CONFIG_HOME=str(self.root / "config"), + LC_ALL="C", GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) + self.assertIsNotNone(GIT, "prepared Git 2.56+ is a contributor prerequisite") + self.git("init", "--quiet") + (self.root / "scripts").mkdir() + shutil.copyfile(CONSUMER, self.root / "scripts/check-project-manifest.py") + shutil.copytree(ROOT / "vendor", self.root / "vendor") + shutil.copyfile(ROOT / "project.yaml", self.root / "project.yaml") + shutil.copyfile(ROOT / ".gitattributes", self.root / ".gitattributes") + (self.root / "STATUS.md").write_text("# Synthetic project status\n") + wrapper = self.root / "scripts/verify-project.sh" + wrapper.write_text("#!/usr/bin/env bash\nexit 0\n") + wrapper.chmod(0o755) + self.git("add", "--", ".") + self.git("commit", "--quiet", "-m", "Synthetic fixture") + self.rev = self.git("rev-parse", "HEAD").strip() + + def git(self, *args): + result = subprocess.run([GIT, "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", + "-c", "commit.gpgsign=false", "-c", "user.name=Fixture", + "-c", "user.email=fixture@example.invalid", *args], + cwd=self.root, env=self.env, capture_output=True, text=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stderr) + return result.stdout + + def invoke(self, *args, expected=0, environment=None): + env = {key: value for key, value in self.env.items() if not key.startswith("GIT_")} + env.update(environment or {}) + result = subprocess.run([sys.executable, "-I", "-S", "-B", + str(self.root / "scripts/check-project-manifest.py"), *args], + cwd=self.root, env=env, capture_output=True, text=True, timeout=45) + self.assertEqual(result.returncode, expected, result.stdout + result.stderr) + record = json.loads(result.stdout) + self.assertEqual(record["exit_code"], expected) + return record + + def test_real_release_worktree_staged_and_revision(self): + for args, mode in [((), "worktree"), (("--staged",), "staged"), (("--rev", self.rev), "rev")]: + with self.subTest(mode=mode): + result = self.invoke(*args) + self.assertEqual(result["mode"], mode) + self.assertEqual(result["declaration_checks"], "passed") + self.assertEqual(result["native_contract"], "matched") + self.assertEqual(result["complete_v1_validity"], "not_checked") + self.assertEqual(result["runtime_acceptance"], "not_checked") + + def test_unknown_key_is_invalid_not_unavailable(self): + with (self.root / "project.yaml").open("a") as stream: + stream.write('surplus: "not permitted"\n') + result = self.invoke(expected=1) + self.assertEqual(result["declaration_checks"], "failed") + self.assertEqual(result["findings"][0]["code"], "KEY_UNKNOWN") + + def test_staged_invalid_does_not_change_revision_verdict(self): + manifest = self.root / "project.yaml" + manifest.write_text(manifest.read_text().replace('operational_state: unverified', + 'operational_state: impossible')) + self.git("add", "project.yaml") + self.invoke("--staged", expected=1) + self.invoke("--rev", self.rev) + + def test_reference_must_exist_in_selected_tree(self): + self.git("rm", "STATUS.md") + self.git("commit", "--quiet", "-m", "Remove synthetic reference") + revision = self.git("rev-parse", "HEAD").strip() + result = self.invoke("--rev", revision, expected=1) + self.assertEqual(result["findings"][0]["code"], "REFERENCE_MISSING") + self.invoke("--rev", self.rev) + + def test_script_executable_mode_required(self): + self.git("update-index", "--chmod=-x", "scripts/verify-project.sh") + result = self.invoke("--staged", expected=1) + self.assertEqual(result["findings"][0]["code"], "REFERENCE_MODE") + + def test_wrong_native_target_fails_conformance(self): + other = self.root / "scripts/other.sh" + other.write_text("#!/bin/sh\nexit 0\n") + other.chmod(0o755) + self.git("add", "scripts/other.sh") + manifest = self.root / "project.yaml" + manifest.write_text(manifest.read_text().replace('scripts/verify-project.sh', 'scripts/other.sh')) + result = self.invoke(expected=1) + self.assertEqual(result["declaration_checks"], "passed") + self.assertEqual(result["native_contract"], "mismatch") + + def test_bootstrap_tamper_is_refused_before_execution(self): + bootstrap = self.root / "vendor/project-manifest/release_verifier.py" + marker = self.root / "executed" + bootstrap.write_text("from pathlib import Path\nPath(" + repr(str(marker)) + ").touch()\n") + result = self.invoke(expected=1) + self.assertEqual(result["consumer_findings"], ["BOOTSTRAP_DIGEST"]) + self.assertFalse(marker.exists()) + + def test_payload_tamper_is_refused_before_execution(self): + payload = self.root / "vendor/project-manifest/release-1/src/project_manifest/acquire.py" + payload.write_text("raise AssertionError('must not execute')\n") + result = self.invoke(expected=1) + self.assertEqual(result["consumer_findings"], ["RELEASE_BINDING"]) + + def test_extra_vendor_file_is_refused(self): + (self.root / "vendor/project-manifest/release-1/extra").write_text("extra\n") + self.invoke(expected=1) + + def test_symlinked_bootstrap_is_unavailable(self): + bootstrap = self.root / "vendor/project-manifest/release_verifier.py" + saved = self.root / "bootstrap.py" + bootstrap.rename(saved) + bootstrap.symlink_to(saved) + self.invoke(expected=2) + + def test_inherited_git_override_does_not_redirect_observation(self): + before = hashlib.sha256((self.root / ".git/config").read_bytes()).hexdigest() + result = self.invoke(expected=2, environment={"GIT_DIR": str(self.root / ".git")}) + self.assertEqual(result["declaration_checks"], "not_checked") + self.assertEqual(before, hashlib.sha256((self.root / ".git/config").read_bytes()).hexdigest()) + + def test_missing_git_is_unavailable(self): + self.invoke(expected=2, environment={"PATH": str(self.root / "absent")}) + + def test_revision_selector_requires_full_oid(self): + result = self.invoke("--rev", "HEAD", expected=2) + self.assertEqual(result["consumer_findings"], ["SOURCE_SELECTION"]) + + def test_ci_revision_environment_and_conflict(self): + result = self.invoke(environment={"SHELLTUTOR_MANIFEST_REV": self.rev}) + self.assertEqual(result["commit_oid"], self.rev) + self.invoke("--staged", expected=2, environment={"SHELLTUTOR_MANIFEST_REV": self.rev}) + + def test_linked_worktree_needs_explicit_common_directory(self): + linked = self.root / "linked" + self.git("worktree", "add", "--quiet", "--detach", str(linked), self.rev) + env = {key: value for key, value in self.env.items() if not key.startswith("GIT_")} + command = [sys.executable, "-I", "-S", "-B", str(linked / "scripts/check-project-manifest.py")] + refused = subprocess.run(command, cwd=linked, env=env, capture_output=True, text=True, timeout=45) + self.assertEqual(refused.returncode, 2, refused.stdout + refused.stderr) + accepted = subprocess.run(command + ["--git-common-dir", str(self.root / ".git")], + cwd=linked, env=env, capture_output=True, text=True, timeout=45) + self.assertEqual(accepted.returncode, 0, accepted.stdout + accepted.stderr) + + def test_attributes_preserve_exact_release_and_manifest_bytes(self): + paths = [self.root / "project.yaml"] + sorted(p for p in (self.root / "vendor").rglob("*") if p.is_file()) + before = {str(path.relative_to(self.root)): path.read_bytes() for path in paths} + attributes = self.root / "outside.attributes" + attributes.write_text("* text eol=crlf filter=adoption ident working-tree-encoding=UTF-16\n") + for path in paths: + path.unlink() + self.git("-c", "core.attributesFile=" + str(attributes), "-c", "filter.adoption.smudge=exit 97", + "-c", "filter.adoption.required=true", "checkout-index", "--force", "--", *before) + for name, data in before.items(): + self.assertEqual((self.root / name).read_bytes(), data, name) + + def test_wrapper_refuses_arguments_before_make(self): + result = subprocess.run(["/bin/bash", str(ROOT / "scripts/verify-project.sh"), "unexpected"], + cwd=self.root, env=self.env, capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 2) + self.assertIn("usage:", result.stderr) + + +class PreparationTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="shelltutor-preparation-test-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + spec = importlib.util.spec_from_file_location("shelltutor_preparation_fixture", ROOT / "scripts/prepare-ci-git.py") + self.preparation = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.preparation) + + def archive(self, name, *, link=False): + archive = self.root / "fixture.tar" + with tarfile.open(archive, "w") as stream: + info = tarfile.TarInfo(name) + if link: + info.type = tarfile.LNKTYPE + info.linkname = "outside" + stream.addfile(info) + else: + data = b"synthetic source\n" + info.size = len(data) + stream.addfile(info, io.BytesIO(data)) + return archive + + def test_archive_traversal_refused_before_extraction(self): + destination = self.root / "destination" + with self.assertRaisesRegex(self.preparation.PreparationError, "ARCHIVE_PATH"): + self.preparation.extract(self.archive("../outside"), destination) + self.assertFalse(destination.exists()) + + def test_archive_hardlink_refused(self): + destination = self.root / "destination" + with self.assertRaisesRegex(self.preparation.PreparationError, "ARCHIVE_ENTRY"): + self.preparation.extract(self.archive("alias", link=True), destination) + self.assertFalse(destination.exists()) + + def test_existing_destination_is_not_overwritten(self): + destination = self.root / "destination" + destination.mkdir() + (destination / "owned").write_text("preserve\n") + with self.assertRaisesRegex(self.preparation.PreparationError, "EXTRACTION_TARGET_EXISTS"): + self.preparation.extract(self.archive("source"), destination) + self.assertEqual((destination / "owned").read_text(), "preserve\n") + + def test_download_checksum_mismatch_refused(self): + with mock.patch.object(self.preparation, "urlopen", return_value=io.BytesIO(b"wrong bytes")): + with self.assertRaisesRegex(self.preparation.PreparationError, "ARCHIVE_CHECKSUM"): + self.preparation.download(self.preparation.GIT, self.root / "archive") + + def test_native_host_cannot_start_setup(self): + with mock.patch.object(self.preparation, "sys", SimpleNamespace(argv=["prepare-ci-git.py"])), \ + mock.patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}), \ + mock.patch.object(self.preparation, "download") as download: + with self.assertRaisesRegex(self.preparation.PreparationError, "HOSTED_LINUX_ONLY"): + self.preparation.prepare() + download.assert_not_called() + + def test_wrong_prepared_python_refused_before_setup(self): + runtime = SimpleNamespace(argv=["prepare-ci-git.py"], version_info=(3, 13, 15), + flags=SimpleNamespace(isolated=1, no_site=1), dont_write_bytecode=True) + with mock.patch.object(self.preparation, "sys", runtime), \ + mock.patch.object(self.preparation.platform, "system", return_value="Linux"), \ + mock.patch.object(self.preparation.platform, "machine", return_value="x86_64"), \ + mock.patch.dict(os.environ, {"GITHUB_ACTIONS": "true", "RUNNER_OS": "Linux", "RUNNER_ARCH": "X64"}), \ + mock.patch.object(self.preparation, "download") as download: + with self.assertRaisesRegex(self.preparation.PreparationError, "PREPARED_PYTHON_REQUIRED"): + self.preparation.prepare() + download.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/vendor/project-manifest/release-1/LICENSE b/vendor/project-manifest/release-1/LICENSE new file mode 100644 index 0000000..31c80bd --- /dev/null +++ b/vendor/project-manifest/release-1/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jefahnierocks + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/project-manifest/release-1/docs/acquisition-candidate.md b/vendor/project-manifest/release-1/docs/acquisition-candidate.md new file mode 100644 index 0000000..c5f13e1 --- /dev/null +++ b/vendor/project-manifest/release-1/docs/acquisition-candidate.md @@ -0,0 +1,192 @@ +# C3 bounded offline acquisition candidate + +The unreleased adapter supplies repository evidence to the pure +[declaration checker](validation-candidate.md). It never executes project targets, +loads environments, evaluates Compose or mise, fetches objects, discovers +repositories, verifies a release, or establishes complete Specification 1 validity. + +## Source invocation and same-observation API + +Use trusted Python 3.11+ and Git 2.56+ with the local src directory: + +```sh +PYTHONPATH=src python -B -m project_manifest --repo /path/to/repository --rev HEAD --reference-date 2026-01-01 +PYTHONPATH=src python -B -m project_manifest --repo /path/to/repository --staged +PYTHONPATH=src python -B -m project_manifest --repo /path/to/repository --worktree +``` + +Exactly one mode is required. The root defaults to cwd; callers should select +it explicitly. The reference day is explicit or today's UTC day captured once. +Invalid arguments/dates produce value-blind not-checked diagnostics. This is a +source invocation, not an installed/vendor command. The bootstrap reports an +unsupported Python before importing modules with candidate annotation syntax. + +Python callers use +`observe_repository(root, mode="rev", rev="HEAD", reference_date="2026-01-01")`. +ObservationResult has exit_code, diagnostics and record/tree only on acceptance. +The tree is exactly the immutable TreeSnapshot consumed by check_declaration. +A pure consumer can use that record and same-observation tree without acquiring +another revision. Input-derived records/tree paths remain data; CLI output omits +both. A rejected/unavailable acquisition supplies neither. + +Exit 0 accepts candidate declaration checks; exit 1 rejects a known declaration +or reference; exit 2 means required evidence is unavailable, unsupported, changed +or outside bounds. Every result retains complete_v1_validity: not_checked. +Producer admission and runtime acceptance remain separate. + +## Literal observations and provenance + +| Mode | Selected evidence | Recorded provenance | +| --- | --- | --- | +| rev | Resolve REV once to a full commit; read its literal complete tree and manifest blob. | Commit/tree/blob IDs, manifest SHA-256 and observation digest. | +| staged | Captured ordinary stage-zero index with Git executable modes and derived directories. | Manifest blob ID/SHA-256 and index-observation digest; no commit/tree claim. | +| worktree | Only index-tracked paths, literal case and no symlink following. | Manifest byte SHA-256 and working-observation digest; no Git blob/commit/tree claim. | + +Untracked file contents never enter observation; global excludes do not affect +tracked paths. Working mode hashes tracked regular bytes, literal symlink targets +and observed kinds/modes, then repeats the observation to reject changes. +Deletions are known missing references. Only ordinary version-2 index records are +qualified here; v3/v4, split, skip-worktree, unmerged and intent-to-add indexes +return not checked. Revision mode does not read the unrelated index. +The index debug parser is version-qualified, never a stable Git ABI. + +Ordinary repositories support three bounded modes; bare storage supports rev. +Linked worktrees require explicit `--git-common-dir` (Python common_dir) +authority for common storage. Their gitdir pointer must select worktrees/NAME +within that scope with the expected common-dir backlink. Arbitrary external +storage authority is never inferred. + +## Containment before Git + +Python validates source layout/config, refs, selected index and object metadata +without following symlinks before any Git child. Local/worktree includes, +quoted or dotted core/extensions subsections, unsupported control/Unicode line +separators or syntax padding, worktree redirects, unsupported +reference storage, nonempty object alternates +and grafts are refused without opening outside targets. Config syntax supports +LF/CRLF and ASCII space/tab; ordinary Unicode comments and ignored value text +are retained without Unicode whitespace normalization. Every assignment value +is lexically checked, including ignored keys/subsections: one simple unquoted or +whole double-quoted scalar with optional outside comment. Unsupported escapes, +continuations, mixed/multiline/unclosed quotes are refused before Git. Quoted +ordinary value spaces are preserved; only storage booleans normalize case. +Only top-level objectFormat, worktreeConfig and partialClone extensions are +supported; every other extension is refused, even where native format 0 ignores +it. ObjectFormat sha1/sha256 requires repository format 1. WorktreeConfig true/ +false and a supplied simple partialClone string (including explicit empty) are +qualified for formats 0/1; a missing partialClone value is refused. Partial-clone +source config is never forwarded to the child and missing objects never fetch. +No new reference/storage implementation is added. Inherited Git +repository/config/storage overrides are refused, including external shallow-file +and graft-file environment paths before any source acquisition or Git child. + +Git receives only an owned temporary metadata view: fresh minimal bare config, +validated refs/index, compressed loose-object copies and pack paths bound to +already-open read-only file descriptors. It never evaluates source config. Replacing a source object pathname +with an outside symlink cannot redirect a read. Loose objects are copied by +sequential no-follow reads, with initial identity/hash captured and rechecked; +pack files stay descriptor-bound. Raw decoded Git content is not copied to the view. Rechecks reject source metadata, layout, +object identity and index drift, including initial linked common-dir identity. + +The child retains HOME. System/global config is excluded only for literal object +and tracked-index operations; no untracked discovery or ignore verdict occurs. +The minimal config omits hooks, filters, fsmonitor, aliases, credentials and +remotes. Flags separately suppress lazy fetching, replacement objects, optional +writes and submodule recursion; protocols/prompts are disabled. Git launches +without a shell. Raw child output/errors never enter diagnostics. + +Selected commit/manifest and all acquired tree objects have their literal Git +hashes checked. Referenced regular blob availability/type is checked; referenced +content is not interpreted or executed. Missing/corrupt objects and source changes +return not checked, without repair, refresh or retry. + +## Bounds and qualification + +| Resource | Candidate bound | +| --- | --- | +| Metadata child output including stderr | 8 MiB | +| Copied source metadata in aggregate | 32 MiB | +| Index file | 16 MiB | +| Object storage | 512 MiB physical bytes; 128 descriptors for pack/idx files | +| Compressed loose snapshot | 20,000 files; 16 MiB per file; 64 MiB aggregate | +| Parsed entries including derived directories | 20,000 | +| Path | 1,024 bytes; 16 segments | +| Directory names | 20,000 per directory; 40,000 per observation | +| Working regular bytes | 64 MiB per file; 256 MiB per pass | +| Git child | Five seconds per call; bounded pipes; owned process-group cancellation | +| Observation | Thirty-second aggregate deadline checked during acquisition, parsing and revalidation | + +Large databases, outside alternates, changed storage, unsupported formats/hosts +and exhausted bounds return not checked. The adapter never enlarges limits, +runs GC, installs tools or weakens permissions to obtain a verdict. Local +qualification is macOS arm64, Git 2.56 and POSIX `/dev/fd` descriptor binding. +Bounded loose copies cost additional RAM/temporary storage, avoiding descriptor +exhaustion for ordinary stores without mutating/repacking them. Generic platform +declarations do not expand acquisition qualification; other +hosts/future layouts need fresh evidence. The source-only Linux-x86_64 hosted +job is prepared for that evidence; no execution receipt follows from preparation. +It preserves all safety bounds and uses lock-authoritative Python/Gitleaks, +separate exact floor/Git qualification pins and an owned temporary tool prefix. +Git source trust is its official HTTPS-published checksum, not a tar-signature +verification claim. No runtime module or acquisition acceptance rule changes. + +The source gate runs exact source copies and guarded synthetic Git graphs in +ownership-marked `/private/tmp/project-manifest-c3-*` roots on macOS arm64 or +`/tmp/project-manifest-c3-*` roots on Linux x86_64, outside harvested roots. Graphs use fresh generic bytes/raw synthetic identities, never source +commits, real project manifests or producer inventory. Successful fixtures are +removed after outcome; unexpected failures retain owned roots and stop the gate. +Earlier C2 passes never qualify amended C3 source. + +Development contract policy is owner-frozen as of 2026-10-13. +Activation/native conformance, broader +runtime/cross-loader qualification, complete release binding, offline vendoring, +producer admission and live acceptance remain separate. + +## Git source contracts + +The adapter uses [Git command/environment controls](https://git-scm.com/docs/git), +[commit resolution](https://git-scm.com/docs/git-rev-parse), +[literal object reads](https://git-scm.com/docs/git-cat-file) and +[NUL-delimited index output](https://git-scm.com/docs/git-ls-files). +The index debug format can change; unsupported output is refused. + +## Released source observation protocol + +The new API in project_manifest.acquire is observe_released_repository(root, +mode=..., reference_date=..., rev=..., common_dir=...). It normalizes the explicit +reference day through the existing day checker and calls observe_repository once. +Candidate CLI/API behavior is unchanged. The accepted record and same immutable +TreeSnapshot are returned by identity, without another declaration check, +acquisition or caller-tree acceptance lane. Interrupt/cancellation propagates. + +The diagnostics mapping has exactly fourteen keys: scope, exit_code, +declaration_checks, complete_v1_validity, findings, mode, reference_date, +commit_oid, tree_oid, manifest_blob_oid, manifest_sha256, observation_digest, +git_version, tree_observation. Scope is project-manifest-git-observation-1. +Declaration status remains 0/1/2 with passed/failed/not_checked respectively. +Complete-v1 validity is always not_checked in direct source output; independently +reviewed consumer approval/binding and authenticated acquired evidence are needed +to derive valid/invalid. Native conformance/admission/runtime acceptance remain +separate, and later conformance exits cannot replace the declaration result. + +A passed result has no findings and retains the record/tree. Failed/unavailable +results have neither. One finding contains only the existing fixed code and +nonnegative bounded integer line/column; field names, raw acquisition reasons, +paths and records are omitted. The existing twenty code identifiers are closed; +NOT_CHECKED means unavailable, never a known-invalid declaration. Inconsistent +candidate or pure-tree results become unavailable rather than being promoted. +Every optional provenance key is present, using null for unavailable evidence. + +Known invalid results retain reliable mode-specific observation metadata and the +explicit request day, including missing/nonregular/oversized or syntax-invalid +manifest cases. Rev records full commit/tree and available manifest-blob identity; +staged has no commit/tree claim; worktree has no blob/commit/tree claim. Object IDs +have one consistent 40/64 width. Acquired accepted bytes bind manifest/observation +SHA-256 and measured Git version. Unavailable acquisition attests no partial +provenance. The three tree_observation values remain frozen-revision-tree, +frozen-stage-zero-index and tracked-working-bytes; only rev is committed evidence. + +This serialization profile changes no C1/C2 rule, storage bound or runtime +prerequisite. It adds no release approval, discovery, execution, runtime equality +allowlist or installed host reader. Changed source must receive its own executed +qualification; earlier candidate/hosted receipts do not qualify these new bytes. diff --git a/vendor/project-manifest/release-1/docs/enum-provenance.md b/vendor/project-manifest/release-1/docs/enum-provenance.md new file mode 100644 index 0000000..c8a63ea --- /dev/null +++ b/vendor/project-manifest/release-1/docs/enum-provenance.md @@ -0,0 +1,117 @@ +# Owner-adopted generic enum snapshot + +The owner adopted these five existing generic sets into the MIT specification +candidate, with fresh definitions. C2 validates membership. This settles the +candidate values and future specification ownership; it does not approve a +complete immutable release, binding, publication or project migration. + +Role and link policy are explicit specification policy sets. Posture, +operational state and visibility retain ontology-derived provenance in the +owner's private evidence. This public-safe source contains generic vocabulary +and new definitions only: no private paths, identifiers, membership data, +source comments or operational receipts. The producer proposes additions and +adopts complete releases first; changing approved values requires release review. + +## Role + +Role describes a project's declared relationship to other projects. Under the +selected uniform rule, canonical is null only for canonical role; every other +role declares a non-self canonical ID. Graph membership remains producer-owned. + +| Token | Fresh candidate definition | +| --- | --- | +| canonical | The primary project in its declared relationship group. | +| component | A project that contributes a constituent part to that group. | +| satellite | A related auxiliary project. | +| peer | A related project with an independent counterpart role. | +| predecessor | A project representing an earlier related implementation or line. | + +## Posture + +Posture names the primary kind of evidence represented by a project. It does +not certify current operations, disclosure permission or public linkability. + +| Token | Fresh candidate definition | +| --- | --- | +| deployed-system | A system represented through deployment evidence. | +| public-package | A package intended for public distribution. | +| public-repo | A repository represented as public source work. | +| private-tool | A tool maintained for restricted use. | +| course-taught | A course represented through delivered teaching work. | +| published-artifact | A completed artifact represented through publication. | +| client-deliverable | Work represented as a client delivery. | +| working-prototype | An implemented prototype still being developed or evaluated. | +| design-spec | Work represented by a design or specification. | +| source-held | Source retained without another asserted evidence posture. | +| archive-only | Material retained for archival reference. | + +## Operational state + +Operational state is a declared current state, separate from posture. A token +is testimony, not a runtime measurement or acceptance receipt. + +| Token | Fresh candidate definition | +| --- | --- | +| production-live | Declared to be operating in production. | +| staging-live | Declared to be operating in a staging environment. | +| active-build | Under active implementation. | +| maintained | Kept current through ongoing maintenance. | +| paused | Work or operation is temporarily suspended. | +| retired | No longer maintained for its previous active purpose. | +| unverified | The asserted operational condition has not been verified. | +| unknown | No operational condition is asserted as known. | + +## Visibility + +Visibility governs outward presentation of project material; it is separate +from hosting visibility and never permits disclosure of private identifiers. + +| Token | Fresh candidate definition | +| --- | --- | +| public-promoted | Public material selected for active presentation. | +| public-available | Public material available without active promotion. | +| shareable-private | Restricted material that may be shared through an approved path. | +| internal | Material intended for an internal audience. | +| archive | Material presented or retained as archival work. | + +## Link policy + +Link policy governs whether public linking is permitted by the declaration; +it does not itself establish that a destination is public or reachable. + +| Token | Fresh candidate definition | +| --- | --- | +| link-ok | Public linking is permitted subject to the applicable disclosure rules. | +| no-link | Public links are suppressed. | + +## Other vocabularies and remaining authority + +The development shape uses separate platform, runner, activation-owner, stack +and requirement vocabularies. The owner froze the implemented literal sets +and rules as contract policy on 2026-10-13: script/mise, host/checkout, +unpublished/loopback_only and container_engine/android_sdk. Actual consumer +behavior and complete immutable release approval remain separate. Platform +membership is the +six determinate combinations of OS macos/linux/windows and architecture +arm64/x86_64: macos-arm64, macos-x86_64, linux-arm64, linux-x86_64, +windows-arm64 and windows-x86_64. These generic axes impose no pair limits; +combined spelling and exclusion of unknown are owner-frozen contract policy. +Empty means no platform claim, never host observation or portability proof. +The declaration API checks the frozen activation/non-secret-loading set +none/direnv/mise/project. Native is not an alias; no per-tool dependency owner +is introduced. Contract policy acceptance grants no native activation proof. + +General tokens allow single hyphens or underscores. The concrete +[owner-frozen development policy](validation-candidate.md#proposed-development-policy-freeze) +retains implemented literal underscore-bearing values rather than a blanket +kebab-only rule. No alias or hidden normalization is permitted. Any future +incompatible spelling change is a new owner decision with compatibility/ +release review, not a style-only update. + + +The complete release must bind specification, fields, this adopted snapshot, +reader and corpus under one immutable identity. A shape version alone does not +reproduce a verdict. Fresh loader/runtime evidence, approved artifact/binding +format, the private grammar diagnostic and producer admission/identity evidence +remain required. Private provenance remains owner-held; source adoption is not +a public release or an authoritative inventory. diff --git a/vendor/project-manifest/release-1/docs/field-table.md b/vendor/project-manifest/release-1/docs/field-table.md new file mode 100644 index 0000000..7e2cf1d --- /dev/null +++ b/vendor/project-manifest/release-1/docs/field-table.md @@ -0,0 +1,86 @@ +# Candidate field and shape worksheet + +Every listed fixed key is required, including nullable keys. All parent records +are closed and non-null. The root contains exactly spec_version, project, role, +canonical, peers, status, authority, presentation and development. Only +development.stacks has dynamic token keys. Root depth is one; command and +stack leaf depth is four. + +The implementation column distinguishes C1 byte/shape checks and C2 pure +semantic/tree-record checks. The remaining-checks column identifies missing +implementation or approval. Syntax acceptance alone does not establish semantics. See the +[grammar and limits](specification-candidate.md) and +[enum authority worksheet](enum-provenance.md) and [C2 policy](validation-candidate.md). +C2 tree evidence is caller-supplied. The [C3 adapter](acquisition-candidate.md) +acquires bounded same-observation records; it grants no execution authority. + +| Path / required record | Implemented candidate checks | Intended meaning and remaining checks | +| --- | --- | --- | +| spec_version | Exact first content line, double-quoted 1 | Selects shape only; complete release binding and producer legacy dispatch remain separate. | +| project | Exactly id, display_name, owner_entity | Identity record; no registry or hosted-repository lookup. | +| project.id | Token, 1-64 bytes | Stable declared identifier; membership and uniqueness are producer-owned. | +| project.display_name | Quoted text, 1-100 bytes | Presentation label, not identity. | +| project.owner_entity | Token, 1-64 bytes | Declared entity; membership is producer-owned. | +| role | Token, 1-64 bytes | C2 checks the owner-adopted role set; no inferred role. | +| canonical | Token, 1-64 bytes, or null; always present | Selected uniform key. Must be null exactly for canonical role; otherwise an ID differing from own id. C2 checks these relations; alternate presence is not proposed. | +| peers | Token list; C1 preserves order/duplicates, C2 rejects duplicates | C2 excludes own id and duplicate peers; producer owns cross-project relationships. Duplicate refusal is owner-frozen contract policy. | +| status | Exactly posture, operational_state, local_phase, as_of | Status summary without ledger/receipt extension keys. | +| status.posture | Token, 1-64 bytes | C2 checks the owner-adopted posture set; release binding remains. | +| status.operational_state | Token, 1-64 bytes | C2 checks the required owner-adopted operational-state set. | +| status.local_phase | Quoted text, 1-400 bytes | Concise current phase; detailed status belongs in status of record. | +| status.as_of | Quoted YYYY-MM-DD lexical format, 10 bytes | Valid calendar date no later than explicit UTC reference day; no age-based expiry. C2 checks calendar validity against a required explicit day, never a clock. | +| authority | Exactly status_of_record | Source authority reference, no duplicate status ledger. | +| authority.status_of_record | Quoted lexical relative path, 1-255 bytes | Tracked regular file with exact tree case and no symlink/submodule component; C2 checks supplied tree records; C3 supports bounded acquisition. | +| presentation | Exactly visibility, public_alias, link_policy | Disclosure policy, separate from remote repository visibility. | +| presentation.visibility | Token, 1-64 bytes | C2 checks the owner-adopted visibility set. No network lookup. | +| presentation.public_alias | Quoted text, 1-100 bytes, or null | Null means no alternate label; quoted null remains a label. | +| presentation.link_policy | Token, 1-64 bytes | C2 checks the owner-adopted link-policy set. | +| development | Exactly platforms, commands, environment, stacks, host_requirements | Full selected shape; all keys remain even without a development server. | +| development.platforms | Token list; C1 preserves order/duplicates, C2 rejects duplicates | Six determinate macos/linux/windows plus arm64/x86_64 frozen tokens. Combined spelling and unknown exclusion are accepted contract policy, never pair restrictions, host observation or portability proof. Empty remains no claim; identify the pilot consumer. | +| development.commands | Exactly setup, check, test, build, dev, format_check, format_write | Native command roles, never executable authorization. | +| development.commands.setup | Closed runner/target record or null | Prepare local development; null means inapplicable. Exact role effects/conformance pending. | +| development.commands.check | Closed runner/target record; null forbidden | Secretless source acceptance entrypoint. C2 checks script references in supplied tree records; native task existence and secretless behavior remain conformance. | +| development.commands.test | Closed runner/target record or null | Native tests; applicability and effect contract pending. | +| development.commands.build | Closed runner/target record or null | Produce project outputs; effect contract pending. | +| development.commands.dev | Closed runner/target record or null | Development workflow; concurrency/runtime safety not implied. | +| development.commands.format_check | Closed runner/target record or null | Formatting verification; effect contract pending. | +| development.commands.format_write | Closed runner/target record or null | Formatting mutation; invocation still requires project authority. | +| development.commands.ROLE.runner | Token, 1-64 bytes | C2 checks owner-frozen script/mise contract semantics; native task and pilot acceptance remain separate. | +| development.commands.ROLE.target | C1 quoted text, 1-255 bytes; C2 script path 255 or mise task 64 | Script means tracked executable path; mise means native task, maximum 64 bytes; every other runner is unsupported. C2 checks frozen contract bounds/spelling/argv exclusion and script executable tree mode; native task existence and effects remain conformance. | +| development.environment | Exactly owner, template, secret_launcher | Owner is activation and non-secret configuration loading, not every tool/dependency environment. | +| development.environment.owner | Token, 1-64 bytes | The declaration API checks none/direnv/mise/project for activation and non-secret loading; native is not an alias. Runtime/dependency ownership remains independent; contract policy was accepted on 2026-10-13; behavioral conformance and complete release approval remain separate. | +| development.environment.template | Quoted lexical relative path, 1-255 bytes, or null | Null means absent; C2 checks supplied regular-file records; C3 supports bounded acquisition. | +| development.environment.secret_launcher | Quoted lexical relative path, 1-255 bytes, or null | Null means no role consumes secrets. C2 requires a portable regular executable reference. Role/secret-use conformance remains pending; C3 supports bounded acquisition and never delivers a value. | +| development.stacks | Dynamic token-keyed closed records; empty map allowed | Nonempty stacks require container-engine declaration; C2 checks the owner-frozen container_engine implication; actual engine capability remains separate. | +| development.stacks.NAME | Exactly compose, instance_scope, port_policy | The literal port_policy key is frozen contract policy. No arbitrary stack extensions. | +| development.stacks.NAME.compose | Quoted lexical relative path, 1-255 bytes | Tracked Compose file, no interpolation or engine probe. C2 checks supplied regular-file records; C3 acquisition is bounded; Compose conformance remains pending. | +| development.stacks.NAME.instance_scope | Token, 1-64 bytes | C2 checks owner-frozen host/checkout scope. Checkout scope alone does not prove concurrency. | +| development.stacks.NAME.port_policy | Token, 1-64 bytes | C2 checks owner-frozen unpublished/loopback_only literals; actual port exposure remains consumer/runtime acceptance. No wider exposure value is invented. | +| development.host_requirements | Token list; C1 preserves order/duplicates, C2 rejects duplicates | The owner-frozen container_engine/android_sdk vocabulary declares requirements, not kernel Capability records or allocated resources. C2 checks the frozen stack implication and duplicate refusal; actual consumer/host readiness remains separate. | + +## Absence, types and paths + +Omission is always different from null. Null is available only for canonical, +public_alias, template, secret_launcher and the six command roles other than +check. An empty map is useful only for stacks; any other required empty record +fails missing-key checks. Empty peer, platform and requirement lists are +syntactically allowed. C2's empty-platform default and duplicate policy are explicit; broader applicability remains separate. + +C1 applies lexical path rules. C2 checks complete caller-supplied tree records +and candidate task/portable-script syntax. Neither authenticates tree acquisition +or grants execution through acceptance. + +## Remaining declaration authority and separate acceptance + +The owner accepted the [development contract policy](validation-candidate.md#proposed-development-policy-freeze) +on 2026-10-13, freezing current literal values and reference/task/list/stack/ +activation semantics. Complete release and actual acceptance remain separate. The selected uniform canonical and full +development shape, adopted five-domain snapshot, calendar replay and pure tree +checks are not reopened. The [corpus map](validation-candidate.md#declaration-corpus-coverage) +distinguishes inspected positive, negative and limit fixtures. + +Native task existence, role effects, secretless check, activation behavior and +Compose safety require project/consumer acceptance without execution by this +validator. Complete release/artifact/runtime/platform qualification, private +grammar and producer identity/admission evidence are separate owner lanes; +private source is never copied into this origin. diff --git a/vendor/project-manifest/release-1/docs/offline-binding-candidate.md b/vendor/project-manifest/release-1/docs/offline-binding-candidate.md new file mode 100644 index 0000000..f912e72 --- /dev/null +++ b/vendor/project-manifest/release-1/docs/offline-binding-candidate.md @@ -0,0 +1,218 @@ +# Candidate offline byte binding + +This unreleased C4 source candidate binds a closed set of bytes. It does not +create, export, install, vendor, sign or approve a release. Complete Specification +1 validity, native conformance, producer admission and runtime acceptance remain +separate. No release artifact or approved consumer lock exists here. + +## Trusted consumer bootstrap + +A consumer separately reviews and pins `bootstrap/release_verifier.py`, together +with its trusted interpreter and standard library. That bootstrap is outside +the unverified payload and is never imported from it. The consumer also obtains +the expected digest through an independently trusted lock or owner-approved +record. A digest supplied by the payload does not establish trust. The table +contains neither its own digest nor the verifier's digest. + +The trusted bootstrap exposes `verify_release(root, expected_digest=...)`. +`root` must be an explicit absolute directory with no symlink component. Before +importing any payload module, the consumer requires exit 0 against its external +digest. The standalone inspection interface may run with Python's isolated +mode, from the trusted bootstrap location: + +```sh +python -I -B release_verifier.py --root /absolute/closed-bundle --expected-digest EXPECTED_SHA256 +``` + +This is byte inspection, not an install or release command. Exit 0 means the +candidate bytes match; exit 1 means malformed, changed or mismatched evidence; +exit 2 means the external trust input, root or runtime is unavailable. Findings +are fixed codes and omit payload values and paths. The result retains the +verified public descriptor only on success. Python 3.11 and POSIX directory +descriptors with no-follow opens are prerequisites; that check is not a platform +qualification receipt. + +Verification does not lock the filesystem after return. The trusted consumer +must hold the verified snapshot immutable through import and use, or reacquire +and verify it immediately before use. Importing from a concurrently writable +directory after verification is not covered. The verifier neither executes +payload code nor enforces a consumer's isolation boundary. + +## Closed table and byte identity + +The root contains exactly `release.json` and the twenty sorted paths in the +trusted bootstrap's `PAYLOAD_FILES`, plus only their derived directories. The +set includes the MIT license, all six normative/usage candidate documents, the +six runtime modules, five declaration/acquisition test and fixture modules, +and the corpus and loader-comparison harnesses. The trusted bootstrap and its +own tamper tests are reviewed separately; neither is acquired as payload code. +Origin-only governance, setup pins/lock, source gate and working reports are +not payload. Extra files/directories, missing paths, alternate lexical spelling, +symlinks, hardlinks and special files fail. No root project manifest is part of +this set. + +The descriptor has exactly these fields: + +| Field | Candidate rule | +| --- | --- | +| format | `candidate-binding-1` | +| origin | `jefahnierocks/project-manifest` | +| spec_version | String `1`; grammar selection alone grants no verdict | +| release_id | `candidate-1`; explicitly unreleased | +| source_revision | Full lowercase 40- or 64-digit source object identity | +| python_minimum | `3.11`; subject to release qualification | +| git_minimum | `2.56.0`; bounded acquisition requirement | +| files | Complete sorted list of exact `path`, integer `bytes`, lowercase SHA-256 | + +Canonical encoding is ASCII JSON with sorted keys, separators comma/colon, +`ensure_ascii=True`, no non-finite numbers, and one final LF. Duplicate keys +and any noncanonical encoding fail. Each row binds a path, exact byte length +and content digest. Paths are fixed by the trusted bootstrap, not interpolated +from arbitrary table input. Boolean sizes, duplicate/unsorted/missing/extra rows +and undeclared metadata fail. + +The externally expected digest is SHA-256 of the literal ASCII domain +`project-manifest-release-table-v1`, one NUL byte, then the canonical table +bytes. The table thereby binds origin, candidate identity, specification/runtime +claims, source revision and every selected content hash together. Content +hashes establish byte identity, not truthful provenance or approval. POSIX +permissions are observed for safe regular-file reads, not a released executable +mode contract. + +Inspection bounds are 65,536 table bytes, 1,048,576 bytes per payload file and +16,777,216 declared payload bytes total. No-follow descriptor-relative reads +check file identity and size before/after; namespace and root identity are +rechecked. Changes cause failure. There is no network, Git child, target command, +environment activation or payload import in verification. + +## Corpus and fresh loader evidence + +The stdlib harness `python -B tests/run_corpus.py --scope pure` exercises byte and +semantic controls without synthetic Git graphs. CLI tests belong to the full +acquisition lane because some create synthetic Git graphs. `--scope all` also runs +guarded acquisition and, in the trusted origin source, bootstrap tamper tests. +Those fixtures use fixed owned macOS-arm64 or Linux-x86_64 temporary roots. +Other full-acquisition hosts return exit 2 before fixture creation. Linux +full-acquisition qualification requires an actual exact-source execution receipt +from the prepared hosted job. Pure test portability and source-only host support +are not full acquisition qualification and do not qualify other hosts. These are +source test interfaces, not an installed command. + +`python -B tests/compare_loaders.py` compares fresh inline synthetic vectors +with already-installed PyYAML SafeLoader/CSafeLoader and Ruby Psych safe loading. +It installs nothing and adds no validator dependency. Every accepted vector +must yield the exact stdlib-reader record; known broader YAML acceptance is +recorded while the strict reader still refuses those bytes. Missing engines +are reported, and other loaders remain not checked. Fresh receipts bind the +exact source snapshot and installed engine versions outside publication source. +Neither an earlier prototype comparison nor this bounded engine sample is +complete release qualification. + +The closed normative/runtime/corpus inventory, bootstrap trust mechanism, +approved vocabulary, full platform/runtime corpus, private grammar diagnostic +and consumer trust/binding/admission design require separate owner review before +a complete immutable release is approved. Actual consumer binding and adoption +follow that approved release under their separate gates. C4 adds no exporter, +installer, vendoring command, CI change or runtime operation. + +## Released source descriptor profile + +The bootstrap now accepts a second explicitly selected engineering profile through +verify_release(root, expected_digest=..., expected_format="project-manifest-release-1"). +The default and standalone candidate invocation retain candidate-binding-1. +An unknown expected_format returns BINDING_PROTOCOL / 2 before filesystem access. +A mixed candidate/released table is TABLE_IDENTITY / 1, with no fallback. + +The released profile has nine closed fields: format, origin, spec_version, +release_id, source_revision, python_minimum, git_minimum, observation_protocol, +files. Format is project-manifest-release-1; release_id is string 1 and +observation_protocol is project-manifest-git-observation-1. Other fixed headers +retain the existing origin, string specification 1, Python 3.11 and Git 2.56.0 +prerequisites. Source-revision/row rules, the twenty payload paths, table hash +domain, canonical encoding, bounds and no-follow/tamper checks are unchanged. +No runtime tuple field or version-equality policy is added. + +This interface verifies bytes, not release approval. Its diagnostics use +project-manifest-release-byte-binding-1 and still report release_approval and +complete_v1_validity as not_checked. The bootstrap remains independently reviewed +and pinned outside the payload. A consumer selects its repo-owned reviewed +vendoring binding at its trusted entrypoint, captures verified runtime bytes +before import, and invokes the selected observation protocol once. An argv digest, +table identity, caller Boolean or arbitrary approval file cannot supply that trust. +The six runtime paths remain fixed; module names are not supplied by the table. + +No released artifact, export, consumer binding, installation or publication is +created by this source profile. Actual support/qualification receipts, final +immutable release/trust and normal consumer acceptance remain separate. + +## Origin-only candidate assembler source + +The origin script scripts/assemble-release.py prepares an unapproved local +candidate; it is not distributed as payload or loaded by consumers. Its API is +assemble_candidate(source_root, expected_revision=..., output_parent=..., +common_dir=...). The CLI derives its own source root and takes --source-revision, +--output-parent and optional --git-common-dir. Assembly execution still needs +its task authority; source preparation is not release approval. + +The selected full committed OID must equal captured HEAD. Existing C3 FrozenStore +and GitView supply contained literal Git-object reads, tree/index evidence and +source-storage rechecks. Every tracked leaf is a regular file; literal index +object/mode identities and descriptor-relative working bytes must match the +commit. Plain status/diff, source filters/hooks/fsmonitor and export substitution +are not used. The twenty payload bytes come from their verified committed blob +IDs, never from later worktree/index content. The existing source gate alone +does not prove that revision/byte relationship. + +Committed ignore files are projected into owned capture storage. Contained +Git check-ignore classifies bounded untracked names against those captured rules, +with its cwd explicitly anchored to the projection and blanket literal-pathspec +mode disabled only for this path-input command. Source configuration is not +imported and ignored file contents are not opened. Nonignored +extra source, changed revision/index/working evidence, unavailable storage or +unexpected mutation stops candidate finalization. + +Output parent is an existing no-symlink build/dist directory under this origin, +with a newly exclusive private container. A marker outside the closed staged +bundle identifies ownership. Files are written descriptor-relative with exclusive +no-follow creation. The only shared encoder is the bootstrap's pure +canonical_table_bytes; inventory, headers, domain and bounds come from that +read-only verifier. The assembler verifies the completed stage with the real +released-profile verifier initially and again after final source checks, before +an atomic no-replace local rename to bundle. Output parent, container and retained +staging directories use assembler-local device/inode/type/mode/ownership identities +that permit the assembler's own entry creation; C3's full source identities are +unchanged. Exact container membership and the owner token remain required. +Unavailable atomic no-replace support is coded unavailable, not a weaker fallback. +Finalization is a local candidate operation, never publication or approval. + +Temporary capture cleanup and source descriptor closes finish before final output +checks. The unnecessary parent descriptor closes before final closed byte +verification. Every transferred +owned descriptor is closed independently at most once, including after an earlier +close error. Ordinary precommit cleanup failure prevents finalization. Successful +atomic no-replace rename is the commit point: an ordinary final descriptor-close +error preserves exit 0 with CANDIDATE_FINALIZED_CLEANUP_WARNING and the same byte +receipt, without exposing the raw error. The candidate already exists at that point. + +The last closed byte verification detects intervening staged-byte mutation during +source checks and cleanup. It does not freeze files against another process +with write access +after verification returns. Mutation between final verification and rename, or +later changes to the candidate, remains outside this assembly receipt; final +immutable artifact freeze and consumer trust require their separate acceptance. + +An ordinary precommit failure creates no finalized candidate. Fatal interruption +and concurrent mutation remain outside the receipt, including the interval after +verification or a commit completed before interruption. Partial owned output +remains private for diagnosis; no existing output or foreign marker is overwritten or recursively +removed. A successful fixed-code receipt records actual commit/tree identities, +table digest and relative bundle name, with release_approval/full-v1 not_checked. +No archive/tag/upload/install/consumer-binding or root-manifest action is added. + +Origin assembly tests remain in tests/test_binding.py and reuse the existing +owned synthetic Git fixtures, whose plumbing strips inherited Git context. +The gate includes the origin-only script in its 35-file source/corpus-copy set; +PAYLOAD_FILES stays twenty and the six runtime paths, pins and workflow are +unchanged. Actual final artifact freeze requires the private diagnostic and the +owner's exact artifact, bootstrap trust and release acceptance. Consumer binding +and adoption follow the approved release under their separate acceptance gates. diff --git a/vendor/project-manifest/release-1/docs/specification-candidate.md b/vendor/project-manifest/release-1/docs/specification-candidate.md new file mode 100644 index 0000000..1ecafee --- /dev/null +++ b/vendor/project-manifest/release-1/docs/specification-candidate.md @@ -0,0 +1,186 @@ +# Specification candidate: C1 + +This is an unreleased design and implementation candidate. The implemented +API accepts a byte document for syntax and closed field shape only. Acceptance +is never a complete Specification 1 verdict, a release, or permission to act +on a declaration. [C2](validation-candidate.md) adds implemented semantic and +pure tree-record checks through a separate API; this byte API retains its scope. + +The [field table](field-table.md) inventories the intended contract, including +semantic rules not implemented here. The [enum worksheet](enum-provenance.md) +records missing authority inputs. YAML 1.2 is informative: this bounded line +grammar defines candidate acceptance. Fresh cross-loader comparison is still +required before release; no earlier prototype result qualifies these bytes. + +## Byte and line grammar + +Inputs are exact Python bytes. A document contains only printable ASCII +(0x20 through 0x7e) and LF (0x0a), ends with LF, and contains no trailing space +on any line. BOM, CR, TAB, DEL, all other controls, Unicode and invisible +characters are rejected before parsing. Lengths therefore count bytes. + +The file limit is 65,536 bytes, including every LF, comment and blank line. +A line limit of 2,048 bytes counts its content and indentation, excluding LF. +An empty file is invalid. Empty lines and full-line comments may occur anywhere, +including before the version. A comment begins with # after optional spaces. +Comment indentation has no structural meaning; even odd or deep indentation +is allowed within the line limit. Whitespace-only lines fail the trailing-space +rule. These precise choices are candidate defaults requiring freeze review. + +The first content line has zero indentation and is exactly +`spec_version: "1"`. There is no additional key ordering requirement. +Other versions or spellings get `VERSION_LINE`, except the three source-confirmed +exact legacy lines: `spec_version: 0.1`, `spec_version: "0.1"` and +`spec_version: '0.1'`. These produce `LEGACY_VERSION` and no accepted record. +This class permits later producer dispatch; it never accepts legacy as version 1 +or expands its whitespace/comment grammar. + +Content uses block mappings. Root keys have zero indentation; each child +level adds exactly two spaces. Four key-path levels are allowed, including +command-role and dynamic stack-name levels. Indentation cannot skip a level. +A key followed immediately by a colon and LF opens a nonempty child mapping. +A scalar entry is a key, colon, exactly one space, scalar and LF. There is +no space before the colon. A scalar cannot have indented children. + +Keys and unquoted scalar tokens match: + +`[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*` + +They have at most 64 bytes and exclude `y`, `n`, `yes`, `no`, +`on`, `off`, `true`, `false` and `null`. Separators cannot be +leading, trailing or adjacent. Mixed single hyphens and underscores are +allowed in general tokens as a candidate default. Fixed keys are the literal +snake_case names in the table; only stack names are dynamic general tokens. +Enum spelling and membership have separate, unresolved release rules. + +A scalar is one of: + +- A token. +- Nonempty double-quoted printable ASCII text with no interior quotation mark + or backslash and no leading or trailing space. There are no escapes. + Literal # and colon inside quotes are text, not comment syntax. +- The exact unquoted literal `null`, permitted only at listed nullable fields. + Quoted `"null"` is text and is never converted into null. +- `[]`, or a nonempty single-line token list with exactly comma-space between + items, such as `[example-one, example_two]`. There are no edge spaces. +- `{}`, an empty mapping, accepted only where the shape permits it. + +This byte API preserves list order and duplicate items. The separately +approved declaration policy rejects peer/platform/requirement duplicates and +preserves order; the owner froze that policy on 2026-10-13. Empty collections must use their explicit spelling; bare empty +mapping openers and alternate spaced flow empties are invalid. + +Duplicate keys, unknown keys and missing required keys are rejected. +No extension namespace exists. Nonempty flow maps, block sequences, +anchors, aliases, tags, merges, document markers, single-quoted strings, +inline comments and block or multiline scalars are forbidden. Input is not +handed to a general YAML loader. + +## Intrinsic field checks + +The reader preserves token, text, null, list and mapping kinds until after +shape validation. Parent records are never nullable. Only the six optional +command records, canonical ID, public alias, environment template and secret +launcher can be null. The check role must be a record. + +Text bounds are 1 through 100 bytes for display names and aliases; 1 through +400 for the phase; and 1 through 255 for path fields. All tokens are bounded +at 64. Command targets receive a provisional 1 through 255 text bound only: +the owner-frozen runner-specific task maximum of 64, task spelling, script- +path interpretation and argv prohibition belong to the separate declaration +API and are not implemented by this byte-only API. + +Date text is exactly the lexical pattern `[0-9]{4}-[0-9]{2}-[0-9]{2}`. +Calendar validity, an explicit UTC reference date and future-date rejection +are still missing. A lexically accepted date can be impossible. + +Path fields use slash-separated, nonempty ASCII segments; absolute and +drive-prefixed paths, dot, dot-dot and exact lowercase .git segments fail. +Segments cannot have edge spaces. This is case-sensitive lexical checking: +case variants such as .GIT are not silently canonicalized. No tree lookup, +existence, exact-case resolution, tracked/regular-file test, symlink/submodule +inspection or executability check occurs in this byte API. The declaration +API separately enforces the owner-frozen case-insensitive .git reservation +and exact tree-reference rules; that policy was accepted on 2026-10-13. + +## API and findings + +Import `check_candidate` from `project_manifest` with the local `src` +directory on Python's import path. The function performs no I/O, Git command, +clock read, configuration evaluation, command execution or logging. + +It returns a CandidateResult: + +- `exit_code == 0` and `syntax_shape_accepted == True` mean only the checks + described here passed. The normalized record is available as programmatic + data, with null as None and ordered lists retained. +- `exit_code == 1` means candidate rejection and no record. +- `exit_code == 2` means not checked; currently only a non-bytes input uses + this result. Source acquisition, runtime compatibility and Git modes are + not part of this API. + +The `diagnostics()` method always declares `candidate-syntax-shape` scope +and `complete_v1_validity: not_checked`. It omits the record and lists +remaining checks. Findings include only a code, predefined field class, +one-based line/column or zero for a document-wide location. A dynamic stack +name is replaced by a wildcard field class. Offending values, dynamic names +and paths are never echoed. Accepted records contain input data and must +not be mistaken for safe diagnostic output. + +Candidate codes are `BYTE_PROFILE`, `GRAMMAR`, `VERSION_LINE`, +`KEY_REQUIRED`, `KEY_DUPLICATE`, `KEY_UNKNOWN`, `FIELD_TYPE`, +`FIELD_BOUND`, `REFERENCE_PATH`, `DATE_FORMAT`, `LEGACY_VERSION` and `NOT_CHECKED`. +These are provisional names, not a published compatibility interface. +The first failure is reported. Document-wide final-LF, file-size and byte +checks run before line parsing. For each line in source order, length and +trailing-space checks precede its content-indentation check. The first content +line's exact version is checked after this pass; mapping grammar and shape +follow. Each closed record checks unknown keys, missing keys and then its +fields in the table's fixed order. Reordering content can change which +failure appears first. + +## Freeze and later implementation + +The selected intended contract uses canonical uniformly, with null exactly +for canonical role, and the full development shape. Those selected decisions +are not reopened by the reader's scope limits. The owner froze the reviewed +development contract policy on 2026-10-13; complete release work remains: + +- Bind the adopted five-domain snapshot and [frozen development policy](validation-candidate.md#proposed-development-policy-freeze) + into the complete immutable release; source policy approval is not release approval. +- Integrate the source-confirmed legacy rejection with the producer dispatch contract. +- Preserve implemented calendar/reference-date and settled canonical/self/peer + relations; qualification of the complete normative/runtime envelope remains separate. +- Qualify native role/task/activation and Compose safety in project/consumer + acceptance. Frozen declaration rules for targets, literals and references + never execute project code or prove those effects. +- Name the first pilot consumer for platforms, stacks and host requirements. + Platform claims do not prove portability; unsupported host operations are + neither inferred nor invoked. +- Extend the [C3 bounded modes](acquisition-candidate.md) only with fresh safety + and platform evidence; separately qualify release binding and offline vendoring. +- Obtain producer runtime requirements, approve the validator minimum and + run fresh pinned cross-loader comparison. The bootstrap Python pin is + execution evidence on one host platform, not the release minimum. +- Complete the owner-authorized private grammar diagnostic and producer + admission/identity evidence in their own lanes. + +A complete immutable release must bind specification, field rules, approved +enum snapshot, reader, corpus and all artifact hashes together. Selection of +a shape version alone cannot reproduce a verdict. New values or changed +verdict rules require a new complete release, producer-first support and +owner approval. C4 implements an unreleased [closed byte-binding candidate](offline-binding-candidate.md) +and separately trusted consumer bootstrap; no vendor command or release artifact +is implemented here. External expected trust and pre-import verification are +mandatory; self-reported payload digests do not establish trust. + +Declaration validity eventually includes grammar, fields and tree references. +Declaration conformance separately checks native task/role/environment claims. +Producer admission checks joins and accepted authority/evidence. Runtime +acceptance checks actual systems. None grants another layer's approval. + +No discoverable repository-root v1 manifest may be introduced under harvested +roots or this origin before landed lock-only producer admission. C1/C2 keep +vectors inline. C3's owner-authorized synthetic graphs stay in guarded system +temporary roots outside harvested roots. No producer discovery, enrollment, +migration, publication or runtime operation is performed. diff --git a/vendor/project-manifest/release-1/docs/validation-candidate.md b/vendor/project-manifest/release-1/docs/validation-candidate.md new file mode 100644 index 0000000..b83e130 --- /dev/null +++ b/vendor/project-manifest/release-1/docs/validation-candidate.md @@ -0,0 +1,317 @@ +# C2 candidate declaration validation + +This unreleased extension adds executable semantic and exact-tree checks to +the [byte and shape reader](specification-candidate.md). It does not acquire +Git objects, verify a release or establish complete Specification 1 validity. + +## Pure interface and evidence + +Import `TreeEntry`, `TreeSnapshot` and `check_declaration` from +`project_manifest` with the local src directory on Python's import path: + +```python +tree = TreeSnapshot(( + TreeEntry("docs", "directory"), + TreeEntry("docs/status.example", "file"), + TreeEntry("tools", "directory"), + TreeEntry("tools/check.example", "file", executable=True), +)) +result = check_declaration(document_bytes, tree=tree, reference_date="2026-01-01") +``` + +Tree records and document vectors are in memory; no fixture repository or +manifest file is created. The snapshot is caller-supplied and unverified. +It must declare completeness and contain a tuple of immutable TreeEntry +records. Every directory component is explicit; the root is implicit. +Records may be in any order. Duplicate paths, missing/non-directory parents, +unknown kinds, malformed paths or flags, and incomplete snapshots return +not checked. Kinds are file, directory, symlink and submodule. Only files +can carry a Boolean executable flag. No symlink target or submodule content +is represented or followed. + +Unrelated tree names may be Unicode, but document references remain ASCII. +Tree paths cannot be empty, absolute, drive-prefixed, contain control bytes +or have empty/dot/dot-dot segments. Snapshot structure is validated without +filesystem access. Its completeness assertion is caller testimony, not proof +that records match a repository, revision or index. + +The reference day is required: either an exact stdlib date or a valid +YYYY-MM-DD string. Datetime, missing values and malformed/impossible dates +return not checked. The API never reads a clock. Successful and semantic/tree +failure diagnostics record the normalized supplied reference day. + +## Implemented semantics + +The stated as_of must be a real Gregorian calendar date no later than the +reference day. Old dates never expire solely through age. + +Canonical is uniformly required. It is null exactly when role is canonical; +otherwise it names an ID different from the project's own ID. Peers exclude +the own ID. C2 checks all five owner-adopted manifest enum sets described in the +[snapshot](enum-provenance.md). Cross-project ID/entity membership and +uniqueness remain producer responsibilities. + +Peer, platform and requirement lists reject duplicates as an explicit C2 +candidate default. Order is preserved. Empty platforms means no platform +claim; acceptance never supplies a portability receipt. The declaration API +checks candidate membership in macos-arm64, macos-x86_64, linux-arm64, linux-x86_64, +windows-arm64 and windows-x86_64. Unknown-axis tokens are excluded by +candidate policy; the generic axes themselves provide no pair restriction. +Activation-owner membership is implemented for none/direnv/mise/project; +the owner froze this contract policy on 2026-10-13; actual activation behavior +and complete release approval remain separate. + +The producer runtime floor is Python 3.11+, so the declaration API uses that +candidate floor and returns not checked below it when import is possible. +The contract gate uses the project's pinned Python 3.13.16; supported-floor +qualification runs the same pure corpus separately on installed Python 3.11. +The version-guard test alone simulates branches and is not a live floor receipt. +The source CLI bootstrap enforces the floor before importing candidate +annotations on unsupported runtimes; branch simulation is not a live receipt. + +## Candidate development policy + +The owner froze these exact closed sets as contract policy on 2026-10-13. +They are not an approved complete immutable release: + +| Field | Candidate policy | +| --- | --- | +| Activation owner | none, direnv, mise or project | +| Command runner | script or mise | +| Stack scope | host or checkout | +| Stack port_policy | unpublished or loopback_only | +| Host requirement | container_engine or android_sdk | + +Activation owner selects project activation and non-secret configuration loading: + +- none: no project activation/loading; a documentation-only template may exist. +- direnv: `.envrc` coordinates activation, including use-mise when appropriate; + mise still owns runtime selection. +- mise: direct mise-native activation/non-secret configuration loading. +- project: tracked command-scoped native loading; there is no per-tool owner enum. + +The exact set is closed; native is not an alias. Owner, template and secret_launcher +are independent declarations. No template/launcher is implied by an owner, and +their presence does not force a different owner. A null launcher declares that +no role uses secrets. Loader/task existence, activation effects and secretless +check behavior are separate conformance; no environment is loaded here. + +Nonempty stacks require container_engine. Each non-null script role references +a supplied-tree regular executable file; all seven command roles use the same +rule. Optional environment templates reference regular files. Non-null secret +launchers reference regular executable files. Stack Compose declarations +reference regular files without parsing/interpolation. Null launcher means no +role may use secrets; that behavioral assertion remains conformance work. + +Script and launcher paths use a candidate portable alphabet: +ASCII letters/digits, dot, underscore, hyphen and slash only. Segments must also +satisfy the path rules; spaces and shell/argv characters are rejected. The path +limit is 255 bytes. Paths are literal references, never executable authorization. + +Mise targets are native task names, not file paths or argv. Their candidate +spelling is one or more lowercase token-shaped segments separated by single +colons, at most 64 bytes total. Single hyphen/underscore separators are allowed +within a segment; leading/trailing/adjacent separators fail. Examples such as +check and format:check describe task syntax, not approved project tasks. +Quoted task names need not exclude YAML reserved words. Task existence and role +effects require separate native-file conformance; mise is never evaluated. + +## Proposed development-policy freeze + +The owner accepted the exact reviewed table below on **2026-10-13** as +contract policy. The historical heading is retained for stable section links. +This decision does not approve a release, publication, migration or runtime +acceptance. The frozen literal sets and bounds are: + +| Decision | Accepted contract rule | +| --- | --- | +| Platform tokens | macos-arm64, macos-x86_64, linux-arm64, linux-x86_64, windows-arm64, windows-x86_64; empty means no platform claim | +| Runner tokens | script or mise; every non-null command role uses the same runner-specific rules | +| Activation owner tokens | none, direnv, mise or project, with the independent template/launcher semantics above | +| Stack keys and tokens | compose, instance_scope and literal port_policy; scope host/checkout; policy unpublished/loopback_only | +| Requirement tokens and implication | container_engine/android_sdk; any nonempty stacks require container_engine | +| List policy | Peer, platform and requirement duplicates are invalid; retain declared order; no deduplication or inferred values | +| Script/launcher targets | Literal relative portable paths, maximum 255 bytes, exact regular executable tree reference, no .git component in any letter case or argv | +| Mise targets | Quoted task names, maximum 64 bytes, lowercase token segments separated by single colons; no path/argv interpretation or task evaluation | +| Token spelling | Retain current literal underscore-bearing values, including container_engine, android_sdk and loopback_only; no blanket kebab-only restriction | + +General bare tokens continue to allow single hyphen or underscore separators. +Closed sets require exact literal membership; aliases, case folding and hidden +normalization are excluded. A future replacement of existing underscores with +hyphens requires a new owner decision with compatibility and release review. The five generic manifest domains, uniform canonical +relation and full development shape are settled and are not reopened here. +These development rules are owner-frozen contract policy. Native task/effect/ +activation and Compose safety acceptance are not implied; complete release +and pilot/runtime acceptance remain separate. + + +## Exact-tree references + +All declared paths are relative, nonempty slash-separated ASCII segments. +Dot/dot-dot, absolute/drive-prefixed paths and .git in any letter case fail. +C2's case-insensitive reservation is an explicit candidate default; actual +tree lookup is always byte-exact and case-sensitive. + +Each component must be present in the complete supplied tree. Intermediate +components must be directories; final components must be regular files. +Symlink, submodule or directory at a final file path is invalid. Executable +references additionally require the executable flag. A wrong-case or absent +path in a known complete snapshot is invalid. Unavailable, incomplete or +internally inconsistent evidence is not checked, never an empty healthy tree. + +The pure API reads no referenced content and performs no Git, filesystem, +network, process or runtime operation. Its caller records remain unverified. +The separate [C3 adapter](acquisition-candidate.md) acquires bounded evidence +and exposes the exact TreeSnapshot used by C2 beside accepted Python records. +A staged or working observation is never mislabeled as a committed verdict. + +## Declaration corpus coverage + +This is the single finite rule-to-fixture index. IDs below are local documentation +labels, not published diagnostic or observation-protocol identifiers. Normative +clauses remain in the [grammar](specification-candidate.md), +[field inventory](field-table.md), [enum snapshot](enum-provenance.md) and the +policy sections above. A shared guard can cite a common boundary test plus its +positional loop; method names alone are not evidence of every loop case. + +Fixtures are fresh inline synthetic bytes and supplied tree records. C1 checks +bytes and shape; C2 adds declaration semantics and tree evidence. Neither validates native task effects, +producer membership, release trust or runtime acceptance. Boundary/unavailable +classes are explicit below; inapplicable classes are not silently counted as passes. + +Test prefixes: **C** = `tests/test_candidate.py:CandidateTests`, **D** = +`tests/test_validation.py:DeclarationTests`, **A** = +`tests/test_acquisition.py:AcquisitionTests`, **B** = +`tests/test_binding.py:ReleaseBindingTests`, **L** = +`tests/test_candidate.py:LoaderComparisonTests`, and **R** = +`tests/test_acquisition.py:ReleasedObservationTests`. Implementation symbols are in +`src/project_manifest/candidate.py` (C1), `validation.py` (C2), `acquire.py` (C3), +the separately trusted `bootstrap/release_verifier.py` (C4), or the optional +preparation harness `tests/compare_loaders.py` (L). + +| Local ID / exact clause | Implementation | Positive, refusal and boundary/unavailable fixture cases | +| --- | --- | --- | +| C1-01: LF printable ASCII; file 65,536 bytes, line 2,048 content bytes; comments/blanks count | `_lines` | C.test_bytes: empty/missing final LF, BOM, NUL/tab/CR/DEL, non-ASCII, invisible and combining/homoglyph prefixes, CRLF/trailing spaces refused. C.test_file_and_line_boundaries: exact file 65,536 accepted / 65,537 refused, line 2,048 accepted / 2,049 refused. C.test_comments_blanks_and_unordered_keys: pre-version/end/interior and odd/deep comment indentation accepted. Non-bytes is unavailable, not grammar invalid: C.test_wrong_input_type_not_checked (str/bytearray/None/int/memoryview). | +| C1-02: first content is exact quoted version 1; three exact legacy lines rejected distinctly | `_lines`, `LEGACY_LINES` | C.test_version_line: quoted 2, bare 1, 0.10 forms, extra spacing/indent/comment, missing/duplicate version; the exact bare/double/single-quoted 0.1 forms produce LEGACY_VERSION. D.test_legacy_forms_remain_invalid_and_dispatchable repeats all three with/without intro comment and rejects 0.10 as VERSION_LINE. Producer dispatch is outside this index. | +| C1-03: block maps, exact two-space levels, depth four, key/scalar separation; no broader YAML forms | `_mapping`, `_scalar` | C.test_spacing_indentation_and_depth: odd/skipped indentation, missing/double separator/space-before-colon, empty opener, fifth-level child; six list-spacing failures plus accepted comma-space list. C.test_prohibited_forms: anchors/aliases/tags, nonempty flow map, block/multiline scalars, single quotes, inline comments, document/merge/sequence/explicit-key markers. C.test_comments_blanks_and_unordered_keys accepts root reorder. Required map empties use C1-04 below. | +| C1-04: fixed keys all required, closed records; nullable only at ten positions; dynamic stack-name map | `_shape`, `SHAPE`, `_OptionalRecord`, `_DynamicRecords` | C.test_every_fixed_field_is_required_even_when_nullable removes all 51 independent fixed positions; version removal is C1-02; sole authority child uses explicit empty map. C.test_nullability_of_every_fixed_record_and_leaf covers all 51, ten nullable positions and present stack-record null refusal. Duplicate loop covers eleven mapping contexts; unknown/missing loops cover fixed mapping classes. Dynamic stack names have no fixed-key unknown case: other_stack-name accepted, token bounds apply. C.test_nonnullable_records_and_leaf_types covers mapping/list/text/token/optional-record mismatches. | +| C1-05: lowercase token/key alphabet, single hyphen/underscore separators, 1–64 bytes, reserved words excluded | `_token`, `TOKEN`, `RESERVED` | C.test_token_boundaries_and_reserved_spellings: a / 64 bytes / a-b_c2 accepted, 65 refused; reserved y/n/yes/no/on/off/true/false and case/numeric/separator probes refused; null has wrong token-field type, [null] is grammar invalid; dynamic name 64/65, yes/a--b cases. Common `_token` guards keys, scalar tokens and list items. Empty token is not a valid representation. | +| C1-06: quoted nonempty ASCII text, no escapes/edge spaces; bounds 100/400/255 by field; null distinct from quoted text | `_scalar`, `_shape`, `_Leaf` | C.test_text_boundaries: display/alias 100, phase 400, target 255 each at 1/max/max+1, empty and edge-space/backslash/quote refusals. C.test_quoted_punctuation_and_null_text: literal #/colon/brackets/braces retained, alias quoted null remains text, quoted null is invalid for canonical token-or-null. C.test_full_development_shape exercises populated optional records/text. | +| C1-07: literal relative ASCII paths, no empty/dot/dot-dot/drive/lowercase .git segment; 1–255 | `_path`, `_shape` | C.test_path_boundaries_and_lexical_rules loops authority/template/launcher/Compose: 1/255 accepted, 256/empty and twelve unsafe spellings refused. Uppercase .GIT is deliberately accepted by C1 lexical scope and separately refused by C2. Command target is C1 quoted text until runner-specific C2 checks. No filesystem unavailability class belongs to C1. | +| C1-08: quoted YYYY-MM-DD lexical form; byte reader preserves list order/duplicates and defers semantics | `_shape`, `_record` | C.test_date_format_and_semantic_deferrals: format/type/length failures, but impossible lexical dates, own canonical/peer, duplicate lists and 255-byte mise target are accepted by C1. C.test_full_development_shape asserts ordered two-peer record. Calendar/membership/duplicate policy belongs to C2; loader representations L-01 are not semantic products. | +| C2-01: exact supported runtime and explicit real reference day; Gregorian as_of ≤ supplied day, no age expiry | `check_declaration`, `_reference_day`, `_semantics` | D.test_runtime_floor_guard_is_not_live_minimum_evidence simulates 3.10/3.11; actual runtime receipts remain separate. D.test_valid_normalized_record_and_explicit_replay compares exact date and string replay. D.test_calendar_dates_and_future_boundary: eight year/month/day/nonleap/future refusals, four min-year/2000/2024-leap/equality positives and reference 9999. D.test_reference_date_evidence_not_checked: None/bytes/malformed/impossible/year-zero/Z/datetime/int yields 2, not invalid declaration. | +| C2-02: five adopted domains; canonical null iff canonical role; canonical/peer exclude own ID | `_semantics`, `MANIFEST_ENUMS` | D.test_all_five_owner_adopted_enum_sets_and_unknown_rejection loops 5 role / 11 posture / 8 state / 5 visibility / 2 link values; each domain unknown plus wrong-domain role refused. D.test_selected_uniform_canonical_and_own_id_rules covers null/referenced canonical, own-ID and own-peer refusals plus unrelated peer. Cross-project membership/uniqueness is producer-owned, not an unavailable lookup invented here. | +| C2-03: peer/platform/requirement duplicates invalid, preserve declared order; six platform literals, empty no claim | `_unique`, `_semantics` | D.test_list_uniqueness_and_empty_platform_claim has all three duplicate classes and empty/singleton positives. D.test_candidate_platform_domain_is_closed_and_empty_remains_no_claim has all six literals and four unknown-axis refusals. D.test_distinct_list_order_is_preserved_with_stack_engine_independent_of_order has three distinct two-item list classes in both orders, with stack engine implication in both requirement orders. No normalization or portability receipt is inferred. | +| C2-04: every non-null role runner is script/mise; literal executable script path 255 or native task spelling 64 | `_semantics`, `TASK`, `_safe_path`, `_references` | D.test_closed_candidate_runner_vocabulary, test_task_spelling_and_bounds (1/64/65, reserved quoted true, namespace/separator/path/argv failures), test_script_target_literal_path_and_bounds (255/256 plus argv/.GIT failures). test_each_command_role_requires_executable_script loops six optional roles positive/missing and required check missing/mode; test_optional_script_roles_require_their_own_executable_mode uses separate optional file per role. test_mise_target_rules_in_every_role_do_not_check_native_existence loops all seven with 64/65/path/unsupported runner while denying I/O. Native task existence/effects are outside declaration rules. | +| C2-05: activation owner closed; owner/template/launcher independent; optional references and launcher executable | `_semantics`, `_references`, `_check_reference` | D.test_activation_owner_domain_and_rejected_alias: four owners, native/sample/python/unknown refused. test_activation_template_and_secret_launcher_are_independent: 4 owners × 2 template states × 2 launcher states = 16 positives. test_environment_optional_references_and_launcher_mode covers regular presence/absence, missing references, launcher mode and unsafe executable path. Null launcher’s behavioral secret claim needs consumer conformance. | +| C2-06: nonempty stacks imply container_engine; scopes host/checkout, port policies unpublished/loopback_only; Compose regular file | `_semantics`, `_references`, `_check_reference` | D.test_stack_requirement_scope_policy_and_compose: both scopes/policies, unknown values, Compose missing, missing engine implication; android_sdk positive/unknown requirement refusal. C1-04 supplies stack closed-key/null/empty-map cases. Native Compose parsing/ports/concurrency is not declaration acceptance. | +| C2-07: complete coherent immutable caller tree; literal case/kind/parent/executable references, no link following | `_tree_index`, `_check_reference`, `_safe_path` | D.test_exact_case_lookup_and_directory_nodes: three case variants/final directory refused, reversed records/unrelated Unicode accepted. test_symlink_and_submodule_components_are_never_followed: both kinds at final/reference parent/executable target. test_git_reservation_and_literal_nonexecutable_paths covers .git casefold reservation and literal nonexecutable space path. test_unavailable_or_malformed_tree_is_not_checked names incomplete/mutable/nonentry/type/path/parent/duplicate/kind/flag cases yielding 2; known-empty complete tree gives REFERENCE_MISSING / 1. | +| C2-08: first phase ordering, coded value-blind diagnostics and no native I/O/clock; candidate not released verdict | `check_declaration`, `DeclarationResult.diagnostics`, `Finding.diagnostic` | D.test_syntax_then_semantics_then_evidence_order; diagnostic marker cases cover values/paths/dynamic keys and silence; test_no_filesystem_process_network_or_clock_operations denies native I/O; test_policy_approval_is_not_release_native_or_runtime_acceptance keeps remaining gates and complete_v1_validity not_checked. C.test_diagnostics_are_value_blind_and_silent supplies byte-parser findings. | + +### Acquisition and binding evidence partitions + +These partitions reference existing safety corpora; they do not add declaration +semantics. The detailed [acquisition envelope](acquisition-candidate.md) and +[candidate binding](offline-binding-candidate.md) remain authoritative for their +own modes, limits and external trust. A mapped case is not complete release approval. + +| Local ID / clause | Implementation | Inspected fixture cases and evidence class | +| --- | --- | --- | +| C3-01: explicit rev/staged/worktree; mode-specific provenance, no source execution | `observe_repository`, `ObservationResult`, CLI `main` | A.test_valid_rev_staged_and_worktree_have_actual_mode_provenance loops three modes and object-ID presence/absence. test_source_selection_distinguishes_object_index_and_working_bytes, test_replay_is_stable_and_changed_working_bytes_change_digest; tests/test_cli.py:test_source_module_cli_exits_and_never_prints_record checks coded source output with no record. Staged/worktree are observations, not committed verdicts. | +| C3-02: exact reference case/kind/mode, measured working metadata | `_references`, `_tree`, `_index`, `_working`, `observe_repository` | A.test_missing_wrong_case_nonexecutable_symlink_and_gitlink_references is five failure classes × three modes. test_working_mode_is_measured_instead_of_taken_from_index and case-only rename distinguish real working state. Pure tree cases remain C2-07. | +| C3-03: qualified local storage, config namespaces/versions, no implicit fetch/hooks/includes | `_config`, `FrozenStore`, `GitView`, `bounded_process` | A.test_all_storage_keys_refuse_subsections_in_common_and_worktree_config: two locations × two styles × seven keys. Unknown extensions tested in versions 0/1; objectFormat sha1/sha256 × versions 0/1; worktreeConfig true/false × versions; partialClone empty/nonempty/missing scalar × versions. Missing object remains not_checked with no remote-helper marker. Existing includes/alternates/promisor/hook/filter/replacement/layout corpora remain linked by their named tests; no new native actor operation follows. | +| C3-04: bounded acquisition and stable observation; unavailable evidence is coded 2 | `Budget`, `bounded_process`, `FrozenStore`, `_working` | A.test_public_bounds_fail_closed lowers each named cap and checks not_checked; this does not claim literal default maximum/equality coverage for every cap. test_deep_repository_paths_are_not_checked covers depth 17 × three modes. Existing child deadline/output and config/index/ref/object/working-byte/mode mutation tests retain first-failure and owned-child cancellation. Diagnostics marker tests omit source/child values. R.test_three_modes_and_both_oid_widths_use_the_existing_acquisition_once; test_distinct_revision_index_and_working_bytes_keep_their_own_identity; test_unresolved_revision_is_unavailable_without_reacquisition_or_raw_ref; test_same_accepted_record_and_tree_objects_are_returned_without_revalidation; test_known_invalid_manifest_cases_keep_acquired_provenance_and_explicit_day; test_unavailable_storage_and_bad_inputs_are_closed_and_value_blind; test_mixed_pure_and_malformed_candidate_observations_do_not_enter_the_protocol; test_observer_failure_is_not_retried_or_echoed_and_cancellation_propagates cover the [released source observation](acquisition-candidate.md#released-source-observation-protocol), one acquisition, closed 0/1/2 and no direct-source promotion. | +| C4-01: separately trusted candidate table/digest, closed payload/protocol, bytes verified before import | `verify_release` in independent bootstrap | B.test_exact_canonical_binding_requires_independent_domain_separated_expectation plus external mismatch; missing descriptor/every listed payload and every normative doc; changed/truncated/extended; nine byte-count/hash type/value controls; closed protocol/revision and four noncanonical-JSON cases. Candidate success never approves its own release. B.test_released_profile_binds_closed_nine_fields_without_approving_itself; test_candidate_and_released_profiles_never_fall_back_to_each_other; test_unknown_expected_profile_is_unavailable_before_any_read; test_released_headers_keys_and_every_payload_row_remain_closed; test_released_profile_keeps_external_expectation_tamper_and_no_import_boundary cover the [released source descriptor](offline-binding-candidate.md#released-source-descriptor-profile), exact profile selection and unchanged trust/tamper boundary. | +| C4-02: namespace/type/budget/tamper/no-import use boundary | `_open_root`, `_namespace`, `_read`, `_descriptor`, `verify_release` | B.test_byte_budgets_refuse_resealed_oversized_files_and_aggregate_payload accepts exact 1 MiB and refuses next byte/aggregate excess; table budget refuses before parse; file-container/missing path/bytes/hash loops. Existing symlink/hardlink/FIFO/extra-file/root/object-race controls preserve immutable bytes. test_payload_modules_are_verified_as_bytes_without_import_side_effects poisons all Python payloads while denying imports. Consumer immutable capture/import qualification remains separate. | + +### Optional selected-loader representation and version coverage + +The preparation harness compares **C1 normalized records**, not every semantic +Cartesian product. It does not replace the strict reader or add a validator, +runtime dependency or ordinary project-CI loader requirement. + +| Local ID / representation or preparation contract | Selected vector / stdlib control | +| --- | --- | +| L-01: token/text/null/empty shapes and populated records | base, stack, full_shape, four activation owners; quoted_reserved; quoted_null_and_numeric preserves text null/001 versus base literal null; quoted_punctuation preserves literal #/colon/brackets/braces | +| L-02: nonempty list shapes and order/duplicates retained by C1 | singleton_list, ordered_lists, duplicate_list; these are not C2 duplicate acceptance | +| L-03: comment/key ordering and repeated dynamic maps | comment, interior_comments (odd/deep mapping-interior indentation), reordered_keys (root/nested), two_mixed_stack_keys (two underscore/hyphen names) | +| L-04: accepted lexical edges | minimum_scalars, token_limits (64-byte value and dynamic key), text_and_path_limits (100/400/255), file_limit 65,536, line_limit 2,048. C1-01/05/06/07 supply refusal neighbors; no larger input is selected as accepted | +| L-05: closed selected set, exact expected versions, no discovery/fallback/extras; every selected engine/every accepted vector | L.test_selected_requires_every_engine_and_every_accepted_vector_without_discovery; test_unknown_duplicate_or_unqualified_profile_is_refused_before_engine_activity; test_missing_version_and_initialization_failures_never_reduce_selected_set; test_python_context_mismatch_stops_before_initialization; test_explicit_invalid_selected_cli_input_never_falls_back_to_reconnaissance | +| L-06: failure classes, observed linked-library metadata and honest reconnaissance | L.test_accepted_record_disagreement_and_strict_control_failure_are_exit_one; test_post_initialization_loss_is_unavailable_in_both_vector_loops_without_fallback; test_parser_refusal_of_accepted_input_is_mismatch_and_strict_refusal_is_informational; test_ruby_parser_refusal_is_distinct_from_process_loss_or_malformed_output; test_linked_libyaml_version_is_observed_from_extension_not_boolean; test_reconnaissance_remains_incomplete_and_does_not_adopt_a_release_profile; test_selected_representation_classes_have_explicit_strict_record_controls | + +`python -B tests/compare_loaders.py` retains optional installed-engine +reconnaissance. Its available-engine success remains `selected_comparison` and +`release_qualification` **not_checked**; missing engines are reported. + +`python -B tests/compare_loaders.py --selected-profiles FILE` selects an +operator-supplied JSON preparation input. No profile/version/legacy-Psych +requirement is built into source. Its closed top-level keys are `python` (exact +expected interpreter version) and `profiles` (one to eight records). Each record +has a unique token `id`, a closed `engine` of PyYAML.SafeLoader, PyYAML.CSafeLoader +or Ruby.Psych, and exact `versions`. PyYAML versions require `pyyaml` and actual +linked `libyaml` triple, or null for an unlinked pure SafeLoader. Ruby versions +require `ruby`, `psych`, `libyaml` triple and an explicit absolute `executable`. +Multiple explicitly selected Ruby profiles are permitted; none is silently +discovered or substituted. Unknown/extra/duplicate keys and range/latest +versions are refused. The input is not a registry or an approved release profile. + +Selected missing/version/initialization/context/input failures and post-initialization +process/OS/timeout/unavailable failures in either vector loop are coded exit 2 +with not_checked. No smaller selected set or retry supplies a substitute result; +prior comparison differences remain recorded. Parser refusal of an accepted vector, +accepted-record disagreement or strict-control failure is exit 1. The Ruby helper +uses a fixed parser-refusal status; other nonzero/signal termination or malformed +output is unavailable, without raw process errors. Exit 0 requires all selected +version requirements and all accepted-vector +agreements. Broader YAML acceptance of strict-refused bytes is informational, +not a requirement that general loaders reject the strict subset's exclusions. +Actual interpreter/host, source/vector/selection hashes and measured +Python/PyYAML/Ruby/Psych/linked-LibYAML versions bind the preparation receipt. +The PyYAML linked version uses its extension's `get_version()` API, not the +presence Boolean (see [official extension source](https://github.com/yaml/pyyaml/blob/6.0.3/yaml/_yaml.pyx)). + +Ordinary source tests inject synthetic observations/callables and deny optional +package imports/process execution. They do not constitute an actual selected +loader run. Required tuples/host/current/floor contexts must be freshly measured +and separately selected for later qualification; historical receipts are not +adopted pins. Even selected-comparison success keeps `release_qualification` +**not_checked**. Release format/protocol/binding, private grammar diagnostic, +producer admission and project/consumer/runtime acceptance remain later gates. + + +## Results and remaining gates + +DeclarationResult provides exit_code, candidate_declaration_accepted, findings +and a normalized record only on success. Exit 0 covers C2 candidate declaration +checks; exit 1 rejects the document; exit 2 means required evidence is unreliable. +The syntax phase uses the C1 reader, including the three source-confirmed legacy +rejections, and retains its safe findings. +Then reference-day evidence is checked, followed by enum/semantic rules, snapshot +structure and references in fixed order. Semantic/tree findings have zero +line/column; predefined fields and wildcard stack classes never echo values. + +`diagnostics()` excludes the record, labels caller-supplied-unverified tree +evidence, owner-adopted manifest domains and owner-frozen contract policy with +its 2026-10-13 approval date, and always returns +`complete_v1_validity: not_checked`. Input-derived records remain data, +not safe diagnostic output. + +New provisional codes are DATE_RULE, ROLE_RELATION, LIST_DUPLICATE, +FIELD_RELATION, ENUM_UNSUPPORTED, TARGET_RULE, REFERENCE_MISSING and +REFERENCE_MODE; FIELD_BOUND, REFERENCE_PATH and NOT_CHECKED retain their +candidate meanings. These are not published compatibility identifiers. + +The development-policy freeze is settled by the 2026-10-13 owner decision. +Any subsequently selected missing validity rule or policy change requires its +own owner-approved source/corpus delta. Native task existence, role effects, secretless check, activation behavior +and Compose runtime safety belong to project/consumer acceptance, not execution +inside the validator. Producer legacy dispatch and cross-project identity/join +invariance remain producer-owned. + +Complete-release provenance, approved artifact/consumer lock and bootstrap trust, +full runtime/platform/loader qualification, the private diagnostic, producer +admission and pilot/runtime acceptance remain separate. Existing source receipts +qualify only their exact bytes and execution envelopes. Bounded acquisition and +candidate byte binding are implemented source mechanisms, not complete release +or offline-vendor acceptance. No release/publication/adoption or discoverable v1 +root manifest follows from declaration acceptance. diff --git a/vendor/project-manifest/release-1/release.json b/vendor/project-manifest/release-1/release.json new file mode 100644 index 0000000..612134f --- /dev/null +++ b/vendor/project-manifest/release-1/release.json @@ -0,0 +1 @@ +{"files":[{"bytes":1070,"path":"LICENSE","sha256":"2b949f8b898a56a5fd6e890f8c5d2b662f7a79c23d7bba8f5a8073ac91e3b66e"},{"bytes":12050,"path":"docs/acquisition-candidate.md","sha256":"b78730a64cac24bf07625c79c64f1a1fe946d655682a4490b20a7eeeea581d12"},{"bytes":5982,"path":"docs/enum-provenance.md","sha256":"a3fb315154a2644dac4ee149e1cc9e5705b5369931a8a9131bc63f4bdc8bfa36"},{"bytes":9807,"path":"docs/field-table.md","sha256":"74f393d3e7e26d38cbc1e5f1d022c0065161141bea98a598d33f5d983ba7e9b1"},{"bytes":13321,"path":"docs/offline-binding-candidate.md","sha256":"031eabb484d8a700c898a35b8fccdc22d5fc132ab2a5a275677bd45573ba0a0f"},{"bytes":10864,"path":"docs/specification-candidate.md","sha256":"a70e96d2e4d3862543e5b9d7f77596428d6ca69650e6fec3d0f55e6454205e40"},{"bytes":33223,"path":"docs/validation-candidate.md","sha256":"934cc5a98f1382bc3a73e6f7a28dcb8f32431f92aed70ed8f1d72f19ecf162b6"},{"bytes":697,"path":"src/project_manifest/__init__.py","sha256":"8641afa9b4c8450759b236efa1ba32676b4cddc5b12e3724fd87e292e6027545"},{"bytes":514,"path":"src/project_manifest/__main__.py","sha256":"933ff7f16b58e04f882a6788af89528c0b72e1a9a22c994bf1cc823c26933fff"},{"bytes":53018,"path":"src/project_manifest/acquire.py","sha256":"76698a655aa1faa2704ceea918735bdfd7b01e6747e3abdf5c03c739257777ee"},{"bytes":10459,"path":"src/project_manifest/candidate.py","sha256":"90a6bb439dcc472781bfe0ca311f40d3c8c693524ff1e91fbf8c67bcd388d8c8"},{"bytes":2080,"path":"src/project_manifest/cli.py","sha256":"ba1bd3f9224d00dd23c914bf788e77269eb95fb27c8b03c641899a77a0341cd4"},{"bytes":11214,"path":"src/project_manifest/validation.py","sha256":"8a988611813cc389a2896713fcba153edb1cf3fecc87bcda54349028b0156f64"},{"bytes":12499,"path":"tests/acquisition_fixtures.py","sha256":"b39116f16a88245a71637fce3508869fdfd441eb117b55b31ffe526b5d580ba1"},{"bytes":15972,"path":"tests/compare_loaders.py","sha256":"6b698a0d05eaa71aeb659375d8580262e9c5f8218d7abec91be55fa1e3278a28"},{"bytes":1678,"path":"tests/run_corpus.py","sha256":"dae0ea5fc0d8b300654a96d5f3d078518048b2963df705eb4e0bc151c818e053"},{"bytes":76414,"path":"tests/test_acquisition.py","sha256":"6429f2d491e5227b169bcebde915bae274f7a01d9dc9965fa937d1fb609baf14"},{"bytes":40791,"path":"tests/test_candidate.py","sha256":"b5bc562ce5cc6c3a0daa2b7a2168c2332c2fdae55ff9a4b2c4de06be284406f5"},{"bytes":4905,"path":"tests/test_cli.py","sha256":"f3b0a8ed5ed30e06789702dfa03260b996b010762586e8da45b0e40897851d61"},{"bytes":26388,"path":"tests/test_validation.py","sha256":"3a2cbb8340b7eb7c7fc6c27cb1ee25732486e9f1198ad397f9af271bad0d8b9a"}],"format":"project-manifest-release-1","git_minimum":"2.56.0","observation_protocol":"project-manifest-git-observation-1","origin":"jefahnierocks/project-manifest","python_minimum":"3.11","release_id":"1","source_revision":"56e3920089726e585eaa0fd7f4dc94e6938a1e0c","spec_version":"1"} diff --git a/vendor/project-manifest/release-1/src/project_manifest/__init__.py b/vendor/project-manifest/release-1/src/project_manifest/__init__.py new file mode 100644 index 0000000..c1e8f43 --- /dev/null +++ b/vendor/project-manifest/release-1/src/project_manifest/__init__.py @@ -0,0 +1,16 @@ +"""Unreleased candidate APIs; never complete v1 validation.""" +import sys + +# Permit the CLI bootstrap to report an unsupported runtime without importing +# modules whose annotation syntax needs the declared Python floor. +__all__ = [] +if sys.version_info >= (3, 11): + from .candidate import CandidateResult, Finding, check_candidate + from .validation import DeclarationResult, TreeEntry, TreeSnapshot, check_declaration + from .acquire import ObservationResult, observe_repository + + __all__ = [ + "CandidateResult", "Finding", "check_candidate", "DeclarationResult", + "TreeEntry", "TreeSnapshot", "check_declaration", "ObservationResult", + "observe_repository", + ] diff --git a/vendor/project-manifest/release-1/src/project_manifest/__main__.py b/vendor/project-manifest/release-1/src/project_manifest/__main__.py new file mode 100644 index 0000000..818c25d --- /dev/null +++ b/vendor/project-manifest/release-1/src/project_manifest/__main__.py @@ -0,0 +1,14 @@ +"""Runtime bootstrap is parseable before importing candidate annotations.""" +import json +import sys + +if sys.version_info < (3, 11): + print(json.dumps({"scope": "candidate-git-observation", + "candidate_declaration_accepted": False, "complete_v1_validity": "not_checked", + "exit_code": 2, "findings": [{"code": "NOT_CHECKED", "field": "runtime", + "line": 0, "column": 0}]}, sort_keys=True)) + raise SystemExit(2) + +from .cli import main + +raise SystemExit(main()) diff --git a/vendor/project-manifest/release-1/src/project_manifest/acquire.py b/vendor/project-manifest/release-1/src/project_manifest/acquire.py new file mode 100644 index 0000000..8baad2b --- /dev/null +++ b/vendor/project-manifest/release-1/src/project_manifest/acquire.py @@ -0,0 +1,1110 @@ +"""Bounded candidate observations; Git reads a private frozen metadata view.""" + +from dataclasses import dataclass +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import selectors +import shutil +import signal +import stat +import subprocess +import tempfile +import time + +from .candidate import Finding, MAX_FILE_BYTES +from .validation import ROLES, TreeEntry, TreeSnapshot, _Failure, _reference_day, check_declaration + +MAX_METADATA = 8 * 1024 * 1024 +MAX_INDEX = 16 * 1024 * 1024 +MAX_METADATA_TOTAL = 32 * 1024 * 1024 +MAX_DIRECTORY_NAMES = 40_000 +MAX_OBJECT_STORAGE = 512 * 1024 * 1024 +MAX_WORKING_BYTES = 256 * 1024 * 1024 +MAX_WORKING_FILE = 64 * 1024 * 1024 +MAX_ENTRIES = 20_000 +MAX_PATH_BYTES = 1_024 +MAX_PATH_DEPTH = 16 +MAX_OBJECT_FDS = 128 +MAX_LOOSE_OBJECTS = 20_000 +MAX_LOOSE_FILE = 16 * 1024 * 1024 +MAX_LOOSE_BYTES = 64 * 1024 * 1024 +CALL_SECONDS = 5.0 +OBSERVATION_SECONDS = 30.0 +SUPPORTED_EXTENSIONS = frozenset({"objectformat", "worktreeconfig", "partialclone"}) +OID = re.compile(rb"(?:[0-9a-f]{40}|[0-9a-f]{64})") +MODES = {b"100644": ("file", False), b"100755": ("file", True), + b"120000": ("symlink", False), b"160000": ("submodule", False), + b"040000": ("directory", False)} +BAD_ENV = ( + "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_INDEX_FILE", "GIT_CONFIG", + "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_COUNT", + "GIT_CONFIG_PARAMETERS", "GIT_NAMESPACE", "GIT_REPLACE_REF_BASE", "GIT_EXEC_PATH", + "GIT_SHALLOW_FILE", "GIT_GRAFT_FILE", +) + + +class AcquisitionFailure(Exception): + def __init__(self, reason): + self.reason = reason + + +@dataclass(frozen=True) +class ObservationResult: + exit_code: int + diagnostics: dict + record: dict | None = None + tree: TreeSnapshot | None = None + + +class Budget: + def __init__(self, seconds=OBSERVATION_SECONDS): + self.end = time.monotonic() + seconds + self.names = 0 + + def check(self): + if time.monotonic() >= self.end: + raise AcquisitionFailure("deadline") + + def remaining(self): + self.check() + return self.end - time.monotonic() + + +def bounded_process(argv, *, env, cwd, pass_fds=(), data=b"", + limit=MAX_METADATA, seconds=CALL_SECONDS): + """No shell; bounded bidirectional pipes and only this child-group cleanup.""" + if os.name != "posix": + raise AcquisitionFailure("unsupported_platform") + deadline = time.monotonic() + seconds + process = subprocess.Popen(argv, cwd=cwd, env=env, pass_fds=pass_fds, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True) + streams = selectors.DefaultSelector() + stdout, stderr = bytearray(), bytearray() + position = 0 + completed = False + try: + for stream in (process.stdin, process.stdout, process.stderr): + os.set_blocking(stream.fileno(), False) + if data: + streams.register(process.stdin, selectors.EVENT_WRITE, "input") + else: + process.stdin.close() + streams.register(process.stdout, selectors.EVENT_READ, "output") + streams.register(process.stderr, selectors.EVENT_READ, "error") + while streams.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AcquisitionFailure("child_timeout") + for key, _ in streams.select(min(remaining, 0.1)): + if key.data == "input": + try: + written = os.write(key.fd, data[position:position + 4096]) + except BlockingIOError: + continue + except BrokenPipeError: + written = len(data) - position + position += written + if position == len(data): + streams.unregister(key.fileobj) + key.fileobj.close() + else: + try: + chunk = os.read(key.fd, 65536) + except BlockingIOError: + continue + if not chunk: + streams.unregister(key.fileobj) + key.fileobj.close() + continue + target = stdout if key.data == "output" else stderr + target.extend(chunk) + if len(stdout) + len(stderr) > limit: + raise AcquisitionFailure("child_output_bound") + try: + process.wait(timeout=max(0.001, deadline - time.monotonic())) + except subprocess.TimeoutExpired: + raise AcquisitionFailure("child_timeout") from None + completed = True + return process.returncode, bytes(stdout), bytes(stderr) + finally: + if not completed: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait() + streams.close() + for stream in (process.stdin, process.stdout, process.stderr): + if not stream.closed: + stream.close() + + +def _parts(path): + encoded = path.encode("utf-8", "surrogateescape") + parts = path.split("/") + if (not path or len(encoded) > MAX_PATH_BYTES or len(parts) > MAX_PATH_DEPTH + or any(not part or part in {".", ".."} for part in parts) + or any(ord(char) < 32 or ord(char) == 127 for char in path)): + raise AcquisitionFailure("path_bound_or_format") + return parts + + +def _identity(info): + return (info.st_dev, info.st_ino, info.st_mode, info.st_size, + info.st_mtime_ns, info.st_ctime_ns) + + +def _open_at(root_fd, parts, directory=False): + current = os.dup(root_fd) + try: + for offset, part in enumerate(parts): + info = os.stat(part, dir_fd=current, follow_symlinks=False) + expected_directory = offset < len(parts) - 1 or directory + if not (stat.S_ISDIR(info.st_mode) if expected_directory else stat.S_ISREG(info.st_mode)): + raise AcquisitionFailure("unsupported_file_kind") + flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK + if offset < len(parts) - 1 or directory: + flags |= os.O_DIRECTORY + child = os.open(part, flags, dir_fd=current) + if _identity(os.fstat(child)) != _identity(info): + os.close(child) + raise AcquisitionFailure("observation_changed") + os.close(current) + current = child + result = current + current = None + return result + finally: + if current is not None: + os.close(current) + + +def _open_directory(path): + absolute = os.path.abspath(os.fspath(path)) + fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY) + try: + return _open_at(fd, Path(absolute).parts[1:], directory=True), absolute + finally: + os.close(fd) + + +def _names(fd, budget): + """Bound directory metadata; contents of untracked files are never opened.""" + info = os.fstat(fd) + names = [] + with os.scandir(fd) as entries: + for entry in entries: + budget.check() + budget.names += 1 + if budget.names > MAX_DIRECTORY_NAMES or len(names) >= MAX_ENTRIES: + raise AcquisitionFailure("directory_name_bound") + names.append(entry.name) + if _identity(os.fstat(fd)) != _identity(info): + raise AcquisitionFailure("observation_changed") + return sorted(names) + + +def _read_fd(fd, maximum, budget): + info = os.fstat(fd) + if not stat.S_ISREG(info.st_mode) or info.st_size > maximum: + raise AcquisitionFailure("file_kind_or_bound") + os.lseek(fd, 0, os.SEEK_SET) + data = bytearray() + while True: + budget.check() + block = os.read(fd, min(65536, maximum + 1 - len(data))) + if not block: + break + data.extend(block) + if len(data) > maximum: + raise AcquisitionFailure("file_bound") + if _identity(os.fstat(fd)) != _identity(info): + raise AcquisitionFailure("observation_changed") + return bytes(data) + + +def _config_scalar(raw): + """One supported config scalar; comments are outside double quotes only.""" + if raw is None: + return None + match = re.fullmatch(r'[ \t]*(?:"([^"\\]*)"|([^#;"\\]*?))[ \t]*(?:[#;].*)?', raw) + if not match: + raise AcquisitionFailure("unsupported_config_scalar") + if match[1] is not None: + return match[1] + return match[2].strip(" \t") + + +def _config(data): + """Never evaluate includes; recognize only storage/layout-relevant values.""" + text = data.decode("utf-8") + if any((ord(char) < 32 and char not in "\t\n\r") + or char in "\x7f\u0085\u2028\u2029" for char in text): + raise AcquisitionFailure("unsupported_config_line_characters") + section = "" + values = {} + for raw in text.split("\n"): + if raw.endswith("\r"): + raw = raw[:-1] + if "\r" in raw: + raise AcquisitionFailure("unsupported_config_line_characters") + line = raw.strip(" \t") + if not line or line.startswith(("#", ";")): + continue + if line.startswith("["): + match = re.fullmatch(r'\[[ \t]*([A-Za-z0-9.-]+)(?:[ \t]+"([^"\\]*)")?[ \t]*\](?:[ \t]*[#;].*)?', line) + if not match: + raise AcquisitionFailure("unsupported_config") + section = match[1].lower() + base_section = section.split(".", 1)[0] + subsection = "." in section or match[2] is not None + if base_section in {"include", "includeif"}: + raise AcquisitionFailure("outside_include") + if base_section in {"core", "extensions"} and subsection: + raise AcquisitionFailure("unsupported_storage_subsection") + if subsection: + # Other subsections are ignored, never promoted to top-level keys. + section = "__ignored_subsection__" + continue + match = re.match(r"([A-Za-z][A-Za-z0-9-]*)[ \t]*(?:=[ \t]*(.*))?$", line) + if not match or not section: + raise AcquisitionFailure("unsupported_config") + # Validate framing even when the key/namespace has no storage meaning. + # Ignoring an escaped or unclosed value could invent later sections. + value = _config_scalar(match[2]) + key = match[1].lower() + name = section + "." + key + if section == "extensions" and key not in SUPPORTED_EXTENSIONS: + raise AcquisitionFailure("unsupported_repository_extension") + if name in {"core.worktree", "extensions.refstorage", "extensions.relativeworktrees"}: + raise AcquisitionFailure("unsupported_layout_config") + if name in {"core.bare", "core.repositoryformatversion", + "extensions.objectformat", "extensions.worktreeconfig", + "extensions.partialclone"}: + if name in {"core.bare", "extensions.worktreeconfig"}: + value = value.lower() if value is not None else None + if value is None or value in {"true", "yes", "on", "1"}: + value = "true" + elif value in {"", "false", "no", "off", "0"}: + value = "false" + else: + raise AcquisitionFailure("unsupported_config_boolean") + elif value is None: + raise AcquisitionFailure("unsupported_config_scalar") + values[name] = value + if values.get("core.repositoryformatversion", "0") not in {"0", "1"}: + raise AcquisitionFailure("unsupported_repository_format") + if values.get("extensions.objectformat", "sha1") not in {"sha1", "sha256"}: + raise AcquisitionFailure("unsupported_object_format") + if ("extensions.objectformat" in values + and values.get("core.repositoryformatversion", "0") != "1"): + raise AcquisitionFailure("extension_repository_version") + return values + + +def _ref_data(data): + if OID.fullmatch(data.strip()): + return + value = data.decode("utf-8").strip() + if not value.startswith("ref: "): + raise AcquisitionFailure("unsupported_ref") + _ref_name(value[5:]) + + +def _ref_name(value): + parts = _parts(value) + if (not value.startswith("refs/") or any(part.startswith(".") or part.endswith(".lock") + for part in parts) or any(char in value for char in " ~^:?*[\\") + or ".." in value or "@{" in value): + raise AcquisitionFailure("unsupported_ref") + + +class FrozenStore: + """Git sees frozen compressed loose bytes and descriptor-bound packs.""" + + def __init__(self, root, common_dir, budget, include_index=True): + self.fds = [] + try: + self._initialize(root, common_dir, budget, include_index) + except BaseException: + self.close() + raise + + def _initialize(self, root, common_dir, budget, include_index): + self.budget = budget + self.fds = [] + self.objects = [] + self.loose = [] + self.bindings = [] + self.metadata = {} + self.metadata_bytes = 0 + self.root_fd, self.root_path = _open_directory(root) + self.fds.append(self.root_fd) + self.root_identity = _identity(os.fstat(self.root_fd)) + self.common_path = None + self.common_identity = None + try: + info = os.stat(".git", dir_fd=self.root_fd, follow_symlinks=False) + except FileNotFoundError: + self.common_fd = self.root_fd + self.git_fd = self.root_fd + self.bare = True + else: + if stat.S_ISDIR(info.st_mode): + self.common_fd = _open_at(self.root_fd, [".git"], directory=True) + self.fds.append(self.common_fd) + self.git_fd = self.common_fd + self.bare = False + self.bindings.append((self.root_fd, [".git"], _identity(info), None)) + elif stat.S_ISREG(info.st_mode): + pointer = self.read(self.root_fd, [".git"], 4096).decode("utf-8").strip() + if not pointer.startswith("gitdir: ") or common_dir is None: + raise AcquisitionFailure("linked_storage_scope_required") + self.common_fd, common_path = _open_directory(common_dir) + self.fds.append(self.common_fd) + self.common_path = common_path + linked = os.path.normpath(os.path.join(self.root_path, pointer[8:])) + relative = os.path.relpath(linked, common_path) + parts = _parts(relative) + if len(parts) != 2 or parts[0] != "worktrees": + raise AcquisitionFailure("unsupported_linked_layout") + self.git_fd = _open_at(self.common_fd, parts, directory=True) + self.fds.append(self.git_fd) + self.bindings.append((self.common_fd, parts, _identity(os.fstat(self.git_fd)), None)) + common = self.read(self.git_fd, ["commondir"], 4096).strip() + backlink = self.read(self.git_fd, ["gitdir"], 4096).decode().strip() + if common != b"../.." or os.path.normpath(backlink) != os.path.join(self.root_path, ".git"): + raise AcquisitionFailure("unsupported_linked_layout") + self.bare = False + else: + raise AcquisitionFailure("unsupported_layout") + self.common_identity = _identity(os.fstat(self.common_fd)) + config = self.read(self.common_fd, ["config"], MAX_METADATA) + values = _config(config) + worktree = values.get("extensions.worktreeconfig", "false") in {"true", "yes", "1", "on"} + if worktree: + local = self.read(self.git_fd, ["config.worktree"], MAX_METADATA, optional=True) + values.update(_config(local) if local else {}) + configured_bare = values.get("core.bare", "false") in {"true", "yes", "1", "on"} + if configured_bare != self.bare: + raise AcquisitionFailure("unsupported_layout") + self.algorithm = values.get("extensions.objectformat", "sha1") + self.head = self.read(self.git_fd, ["HEAD"], 4096) + _ref_data(self.head) + self.metadata["HEAD"] = self.head + packed = self.read(self.common_fd, ["packed-refs"], MAX_METADATA, optional=True) + for line in packed.splitlines(): + if not line or line.startswith(b"#"): + continue + if line.startswith(b"^"): + if not OID.fullmatch(line[1:]): + raise AcquisitionFailure("unsupported_ref") + else: + oid, sep, name = line.partition(b" ") + if not sep or not OID.fullmatch(oid): + raise AcquisitionFailure("unsupported_ref") + _ref_name(name.decode("utf-8")) + if packed: + self.metadata["packed-refs"] = packed + shallow = self.read(self.common_fd, ["shallow"], MAX_METADATA, optional=True) + if shallow: + if any(not OID.fullmatch(line) for line in shallow.splitlines()): + raise AcquisitionFailure("unsupported_shallow") + self.metadata["shallow"] = shallow + grafts = self.read(self.common_fd, ["info", "grafts"], MAX_METADATA, optional=True) + if grafts.strip(): + raise AcquisitionFailure("unsupported_grafts") + self._refs() + for name in _names(self.git_fd, self.budget): + self.budget.check() + if include_index and (name == "index" or re.fullmatch(r"sharedindex\.[0-9a-f]{40,64}", name)): + if name != "index": + raise AcquisitionFailure("unsupported_split_index") + index_data = self.read(self.git_fd, [name], MAX_INDEX) + if index_data[:4] != b"DIRC" or index_data[4:8] != b"\0\0\0\2": + raise AcquisitionFailure("unsupported_index_version") + self.metadata[name] = index_data + self._objects() + self.budget.check() + + def read(self, root_fd, parts, bound, optional=False): + self.budget.check() + try: + fd = _open_at(root_fd, parts) + except FileNotFoundError: + if optional: + self.bindings.append((root_fd, parts, None, None)) + return b"" + raise + try: + info = os.fstat(fd) + data = _read_fd(fd, bound, self.budget) + self.metadata_bytes += len(data) + if self.metadata_bytes > MAX_METADATA_TOTAL: + raise AcquisitionFailure("metadata_bound") + self.bindings.append((root_fd, parts, _identity(info), hashlib.sha256(data).digest())) + return data + finally: + os.close(fd) + + def _refs(self): + try: + fd = _open_at(self.common_fd, ["refs"], directory=True) + except FileNotFoundError: + self.bindings.append((self.common_fd, ["refs"], None, None)) + return + os.close(fd) + names = [] + def walk(parts): + self.budget.check() + fd = _open_at(self.common_fd, parts, directory=True) + self.bindings.append((self.common_fd, parts, _identity(os.fstat(fd)), None)) + try: + for name in _names(fd, self.budget): + self.budget.check() + path = parts + [name] + _parts("/".join(path)) + info = os.stat(name, dir_fd=fd, follow_symlinks=False) + if stat.S_ISDIR(info.st_mode): + walk(path) + elif stat.S_ISREG(info.st_mode): + ref = "/".join(path) + _ref_name(ref) + data = self.read(self.common_fd, path, 4096) + _ref_data(data) + names.append(ref) + if len(names) > 1024: + raise AcquisitionFailure("ref_bound") + self.metadata[ref] = data + else: + raise AcquisitionFailure("unsupported_ref") + finally: + os.close(fd) + walk(["refs"]) + + def _objects(self): + for name in ("alternates", "http-alternates"): + data = self.read(self.common_fd, ["objects", "info", name], 4096, optional=True) + if data.strip(): + raise AcquisitionFailure("object_alternates") + root = _open_at(self.common_fd, ["objects"], directory=True) + self.bindings.append((self.common_fd, ["objects"], _identity(os.fstat(root)), None)) + total = loose_total = 0 + try: + for directory in _names(root, self.budget): + self.budget.check() + if directory != "pack" and not re.fullmatch(r"[0-9a-f]{2}", directory): + continue + fd = _open_at(root, [directory], directory=True) + self.bindings.append((self.common_fd, ["objects", directory], _identity(os.fstat(fd)), None)) + try: + for name in _names(fd, self.budget): + self.budget.check() + if directory == "pack": + if not re.fullmatch(r"pack-[0-9a-f]{40,64}\.(?:pack|idx)", name): + continue + elif not re.fullmatch(r"[0-9a-f]{" + ("38" if self.algorithm == "sha1" else "62") + "}", name): + continue + path = ["objects", directory, name] + object_fd = _open_at(self.common_fd, path) + info = os.fstat(object_fd) + if not stat.S_ISREG(info.st_mode): + os.close(object_fd) + raise AcquisitionFailure("object_layout") + total += info.st_size + if directory == "pack": + self.fds.append(object_fd) + self.objects.append(("/".join(path), object_fd)) + self.bindings.append((self.common_fd, path, _identity(info), None)) + else: + try: + data = _read_fd(object_fd, MAX_LOOSE_FILE, self.budget) + finally: + os.close(object_fd) + loose_total += len(data) + if len(self.loose) >= MAX_LOOSE_OBJECTS or loose_total > MAX_LOOSE_BYTES: + raise AcquisitionFailure("loose_object_bound") + self.loose.append(("/".join(path), data)) + self.bindings.append((self.common_fd, path, _identity(info), hashlib.sha256(data).digest())) + if len(self.objects) > MAX_OBJECT_FDS or total > MAX_OBJECT_STORAGE: + raise AcquisitionFailure("object_storage_bound") + finally: + os.close(fd) + finally: + os.close(root) + + def materialize(self, target): + target.mkdir() + (target / "objects").mkdir() + (target / "refs").mkdir() + config = "[core]\nrepositoryformatversion = " + ("1" if self.algorithm == "sha256" else "0") + "\nbare = true\n" + if self.algorithm == "sha256": + config += "[extensions]\nobjectformat = sha256\n" + (target / "config").write_text(config) + for name, data in self.metadata.items(): + self.budget.check() + path = target / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + for name, data in self.loose: + self.budget.check() + path = target / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + for name, fd in self.objects: + self.budget.check() + path = target / name + path.parent.mkdir(parents=True, exist_ok=True) + path.symlink_to("/dev/fd/" + str(fd)) + + def recheck(self): + current, _ = _open_directory(self.root_path) + try: + if _identity(os.fstat(current)) != self.root_identity: + raise AcquisitionFailure("observation_changed") + finally: + os.close(current) + if self.common_path is not None: + current, _ = _open_directory(self.common_path) + try: + if _identity(os.fstat(current)) != self.common_identity: + raise AcquisitionFailure("observation_changed") + finally: + os.close(current) + for root_fd, parts, expected, digest in self.bindings: + self.budget.check() + try: + fd = _open_at(root_fd, parts, directory=expected is not None and stat.S_ISDIR(expected[2])) + except FileNotFoundError: + if expected is None: + continue + raise AcquisitionFailure("observation_changed") from None + try: + if expected is None or _identity(os.fstat(fd)) != expected: + raise AcquisitionFailure("observation_changed") + if digest is not None: + data = _read_fd(fd, max(MAX_INDEX, MAX_METADATA), self.budget) + if hashlib.sha256(data).digest() != digest: + raise AcquisitionFailure("observation_changed") + finally: + os.close(fd) + + def close(self): + for fd in reversed(self.fds): + os.close(fd) + + +class GitView: + def __init__(self, store, mirror, budget): + self.store, self.mirror, self.budget = store, mirror, budget + self.executable = shutil.which("git") + if self.executable is None: + raise AcquisitionFailure("git_unavailable") + self.env = os.environ.copy() + if any(self.env.get(name) for name in BAD_ENV): + raise AcquisitionFailure("inherited_git_context") + for name in list(self.env): + if name.startswith("GIT_TRACE"): + self.env[name] = "0" + self.env.update(GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM="1", + GIT_OPTIONAL_LOCKS="0", GIT_NO_LAZY_FETCH="1", GIT_NO_REPLACE_OBJECTS="1", + GIT_LITERAL_PATHSPECS="1", GIT_ALLOW_PROTOCOL="", GIT_TERMINAL_PROMPT="0", + GCM_INTERACTIVE="never", GIT_TRACE="0", GIT_TRACE2="0", + GIT_TRACE2_EVENT="0", GIT_TRACE2_PERF="0", GIT_TRACE_REDACT="1") + version = self.run(["--version"]) + match = re.fullmatch(rb"git version (\d+)\.(\d+)\.(\d+)(?:[^\r\n]*)\n", version) + if not match or tuple(int(item) for item in match.groups()) < (2, 56, 0): + raise AcquisitionFailure("unsupported_git") + self.version = ".".join(part.decode() for part in match.groups()) + + def run(self, args, data=b"", limit=MAX_METADATA, optional=False): + self.budget.check() + for _, fd in self.store.objects: + os.lseek(fd, 0, os.SEEK_SET) + base = [self.executable, "--no-pager", "--no-optional-locks", + "--no-lazy-fetch", "--no-replace-objects", "--literal-pathspecs", + "-c", "core.fsmonitor=false", "-c", "submodule.recurse=false", + "--git-dir=" + str(self.mirror)] + code, output, _ = bounded_process(base + args, env=self.env, cwd=self.mirror, + pass_fds=tuple(fd for _, fd in self.store.objects), data=data, + limit=limit, seconds=min(CALL_SECONDS, self.budget.remaining())) + self.budget.check() + if code: + if optional: + return None + raise AcquisitionFailure("git_read") + return output + + def resolve(self, rev): + data = self.run(["rev-parse", "--verify", "--end-of-options", rev + "^{commit}"]) + value = data.strip() + if not OID.fullmatch(value): + raise AcquisitionFailure("object_identifier") + return value + + def info(self, oids): + if not oids: + return {} + output = self.run(["cat-file", "--batch-check"], data=b"\n".join(oids) + b"\n") + lines = output.splitlines() + if len(lines) != len(oids): + raise AcquisitionFailure("object_metadata") + result = {} + for wanted, line in zip(oids, lines): + self.budget.check() + fields = line.split(b" ") + if len(fields) != 3 or fields[0] != wanted or fields[1] not in {b"blob", b"tree", b"commit", b"tag"} or not fields[2].isdigit(): + raise AcquisitionFailure("object_unavailable") + result[wanted] = (fields[1], int(fields[2])) + return result + + def raw(self, kind, oid, bound): + info = self.info([oid])[oid] + if info[0] != kind or info[1] > bound: + raise AcquisitionFailure("object_type_or_bound") + data = self.run(["cat-file", kind.decode(), oid.decode()], limit=bound + 4096) + digest = hashlib.new(self.store.algorithm, kind + b" " + str(len(data)).encode() + b"\0" + data).hexdigest().encode() + if len(data) != info[1] or digest != oid: + raise AcquisitionFailure("object_integrity") + return data + + + def verify_trees(self, oids): + wanted = sorted(set(oids)) + if not wanted: + return + metadata = self.info(wanted) + if (any(kind != b"tree" for kind, _ in metadata.values()) + or sum(size for _, size in metadata.values()) > MAX_METADATA): + raise AcquisitionFailure("tree_object_bound") + output = self.run(["cat-file", "--batch"], data=b"\n".join(wanted) + b"\n", + limit=MAX_METADATA + len(wanted) * 128) + position = 0 + for oid in wanted: + self.budget.check() + end = output.find(b"\n", position) + header = output[position:end].split(b" ") + if end < position or header != [oid, b"tree", str(metadata[oid][1]).encode()]: + raise AcquisitionFailure("tree_object_metadata") + position = end + 1 + size = metadata[oid][1] + data = output[position:position + size] + position += size + digest = hashlib.new(self.store.algorithm, + b"tree " + str(size).encode() + b"\0" + data).hexdigest().encode() + if digest != oid or output[position:position + 1] != b"\n": + raise AcquisitionFailure("object_integrity") + position += 1 + if position != len(output): + raise AcquisitionFailure("tree_object_metadata") + + +def _tree(data, budget): + records, objects = [], {} + for item in data.split(b"\0"): + budget.check() + if not item: + continue + header, sep, raw_path = item.partition(b"\t") + fields = header.split(b" ") + if not sep or len(fields) != 3 or fields[0] not in MODES or not OID.fullmatch(fields[2]): + raise AcquisitionFailure("tree_metadata") + path = raw_path.decode("utf-8", "surrogateescape") + _parts(path) + kind, executable = MODES[fields[0]] + expected_type = b"tree" if kind == "directory" else b"commit" if kind == "submodule" else b"blob" + if fields[1] != expected_type: + raise AcquisitionFailure("tree_metadata") + records.append(TreeEntry(path, kind, executable)) + objects[path] = fields[2] + if len(records) > MAX_ENTRIES: + raise AcquisitionFailure("entry_bound") + return records, objects + + +def _index(data, budget): + records, objects = [], {} + position = 0 + # Debug records are deliberately version-qualified, not treated as stable Git ABI. + debug = re.compile(rb" ctime: \d+:\d+\n mtime: \d+:\d+\n dev: \d+\s+ino: \d+\n uid: \d+\s+gid: \d+\n size: \d+\s+flags: ([0-9a-f]+)\n") + while position < len(data): + budget.check() + end = data.find(b"\0", position) + if end < 0: + raise AcquisitionFailure("unsupported_index") + raw = data[position:end] + position = end + 1 + header, tab, path_bytes = raw.partition(b"\t") + fields = header.split(b" ") + match = debug.match(data, position) + if not tab or len(fields) != 3 or not match or fields[0] not in MODES or not OID.fullmatch(fields[1]): + raise AcquisitionFailure("unsupported_index") + if fields[2] != b"0" or int(match[1], 16) & 0x60000000: + raise AcquisitionFailure("unmerged_or_intent_index") + position = match.end() + path = path_bytes.decode("utf-8", "surrogateescape") + _parts(path) + kind, executable = MODES[fields[0]] + if kind == "directory" or path in objects: + raise AcquisitionFailure("unsupported_index") + records.append(TreeEntry(path, kind, executable)) + objects[path] = fields[1] + if len(records) > MAX_ENTRIES: + raise AcquisitionFailure("entry_bound") + parents = {} + leaves = {entry.path for entry in records} + for entry in records: + budget.check() + parts = _parts(entry.path) + for depth in range(1, len(parts)): + path = "/".join(parts[:depth]) + if path in leaves: + raise AcquisitionFailure("index_path_conflict") + parents[path] = TreeEntry(path, "directory") + if len(parents) + len(records) > MAX_ENTRIES: + raise AcquisitionFailure("entry_bound") + return list(parents.values()) + records, objects + + +def _working(store, records, budget): + observed = {} + manifest = None + fingerprint = hashlib.sha256(b"candidate-working-bytes\0") + total = 0 + directories = {} + for entry in sorted(records, key=lambda item: item.path): + budget.check() + parts = _parts(entry.path) + current = os.dup(store.root_fd) + try: + for depth, part in enumerate(parts, 1): + budget.check() + prefix = "/".join(parts[:depth]) + directory_info = os.fstat(current) + directory_key = _identity(directory_info) + if directory_key not in directories: + directories[directory_key] = frozenset(_names(current, budget)) + if part not in directories[directory_key]: + fingerprint.update(b"missing\0" + prefix.encode("utf-8", "surrogateescape") + b"\0") + break + info = os.stat(part, dir_fd=current, follow_symlinks=False) + if stat.S_ISLNK(info.st_mode): + observed[prefix] = TreeEntry(prefix, "symlink") + target = os.readlink(part, dir_fd=current) + if _identity(os.stat(part, dir_fd=current, follow_symlinks=False)) != _identity(info): + raise AcquisitionFailure("observation_changed") + fingerprint.update(json.dumps(["symlink", prefix, target], ensure_ascii=True).encode() + b"\0") + break + if depth < len(parts): + if not stat.S_ISDIR(info.st_mode): + observed[prefix] = TreeEntry(prefix, "file", bool(info.st_mode & 0o111)) + break + observed[prefix] = TreeEntry(prefix, "directory") + child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=current) + if _identity(os.fstat(child)) != _identity(info): + os.close(child) + raise AcquisitionFailure("observation_changed") + os.close(current) + current = child + elif entry.kind == "submodule": + observed[prefix] = TreeEntry(prefix, "submodule") + fingerprint.update(b"submodule\0" + prefix.encode()) + elif entry.kind == "directory" and stat.S_ISREG(info.st_mode): + # A file obstructing a derived directory is not an index leaf. + # Record only its kind/mode; never consume untracked bytes. + observed[prefix] = TreeEntry(prefix, "file", bool(info.st_mode & 0o111)) + elif stat.S_ISREG(info.st_mode): + fd = os.open(part, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=current) + try: + if _identity(os.fstat(fd)) != _identity(info): + raise AcquisitionFailure("observation_changed") + data = _read_fd(fd, MAX_WORKING_FILE, budget) + if _identity(os.stat(part, dir_fd=current, follow_symlinks=False)) != _identity(os.fstat(fd)): + raise AcquisitionFailure("observation_changed") + finally: + os.close(fd) + total += len(data) + if total > MAX_WORKING_BYTES: + raise AcquisitionFailure("working_byte_bound") + executable = bool(info.st_mode & 0o111) + observed[prefix] = TreeEntry(prefix, "file", executable) + framed = json.dumps([prefix, executable, len(data)], ensure_ascii=True).encode() + fingerprint.update(framed + b"\0" + hashlib.sha256(data).digest()) + if prefix == "project.yaml": + manifest = data + elif stat.S_ISDIR(info.st_mode): + observed[prefix] = TreeEntry(prefix, "directory") + else: + raise AcquisitionFailure("working_file_kind") + finally: + os.close(current) + for entry in sorted(observed.values(), key=lambda item: item.path): + budget.check() + fingerprint.update(json.dumps([entry.path, entry.kind, entry.executable], + ensure_ascii=True).encode() + b"\0") + budget.check() + return list(observed.values()), manifest, fingerprint.hexdigest() + + +def _references(record): + yield record["authority"]["status_of_record"] + environment = record["development"]["environment"] + for key in ("template", "secret_launcher"): + if environment[key] is not None: + yield environment[key] + for role in ROLES: + value = record["development"]["commands"][role] + if value is not None and value["runner"] == "script": + yield value["target"] + for value in record["development"]["stacks"].values(): + yield value["compose"] + + +def _finding(code, field, reference_date=None): + return {"scope": "candidate-git-observation", "complete_v1_validity": "not_checked", + "candidate_declaration_accepted": False, "reference_date": reference_date, + "exit_code": 2 if code == "NOT_CHECKED" else 1, + "findings": [Finding(code, field, 0, 0).diagnostic()]} + + +def observe_repository(root, *, mode, rev=None, reference_date=None, common_dir=None): + """Read one explicitly scoped root; no repository mutation or source execution.""" + store = None + budget = Budget() + try: + if (type(mode) is not str or mode not in {"rev", "staged", "worktree"} + or (mode == "rev" and (type(rev) is not str or not rev + or len(rev.encode("utf-8")) > MAX_PATH_BYTES + or any(ord(char) < 32 or ord(char) == 127 for char in rev))) + or (mode != "rev" and rev is not None)): + raise AcquisitionFailure("source_selection") + if any(os.environ.get(name) for name in BAD_ENV): + raise AcquisitionFailure("inherited_git_context") + if reference_date is None: + reference_date = datetime.now(timezone.utc).date().isoformat() + try: + reference_date = _reference_day(reference_date).isoformat() + except _Failure: + return ObservationResult(2, _finding("NOT_CHECKED", "reference_date")) + store = FrozenStore(root, common_dir, budget, include_index=mode != "rev") + if mode != "rev" and store.bare: + raise AcquisitionFailure("bare_observation") + with tempfile.TemporaryDirectory(prefix="project-manifest-observation-") as temp: + mirror = Path(temp) / "git" + store.materialize(mirror) + git = GitView(store, mirror, budget) + commit = tree_oid = manifest_oid = None + if mode == "rev": + commit = git.resolve(rev) + commit_data = git.raw(b"commit", commit, MAX_METADATA) + first = commit_data.split(b"\n", 1)[0] + if not first.startswith(b"tree ") or not OID.fullmatch(first[5:]): + raise AcquisitionFailure("commit_metadata") + tree_oid = first[5:] + git.raw(b"tree", tree_oid, MAX_METADATA) + metadata = git.run(["ls-tree", "-r", "-t", "-z", "--full-tree", tree_oid.decode()]) + records, objects = _tree(metadata, budget) + git.verify_trees([tree_oid] + [objects[e.path] for e in records if e.kind == "directory"]) + else: + metadata = git.run(["ls-files", "--stage", "--debug", "-z"]) + records, objects = _index(metadata, budget) + fingerprint = hashlib.sha256(mode.encode() + b"\0" + metadata).hexdigest() + if mode == "worktree": + records, data, fingerprint = _working(store, records, budget) + else: + entry = next((entry for entry in records if entry.path == "project.yaml"), None) + if entry is None: + data = None + elif entry.kind != "file": + result = ObservationResult(1, _finding("REFERENCE_PATH", "manifest", reference_date)) + result.diagnostics.update(mode=mode, commit_oid=commit.decode() if commit else None, + tree_oid=tree_oid.decode() if tree_oid else None, + manifest_blob_oid=manifest_oid.decode() if manifest_oid else None, + manifest_sha256=None, observation_digest=fingerprint, git_version=git.version) + store.recheck() + budget.check() + return result + else: + manifest_oid = objects["project.yaml"] + info = git.info([manifest_oid])[manifest_oid] + if info[0] != b"blob": + raise AcquisitionFailure("manifest_object") + if info[1] > MAX_FILE_BYTES: + result = ObservationResult(1, _finding("FIELD_BOUND", "document", reference_date)) + result.diagnostics.update(mode=mode, commit_oid=commit.decode() if commit else None, + tree_oid=tree_oid.decode() if tree_oid else None, + manifest_blob_oid=manifest_oid.decode() if manifest_oid else None, + manifest_sha256=None, observation_digest=fingerprint, git_version=git.version) + store.recheck() + budget.check() + return result + data = git.raw(b"blob", manifest_oid, MAX_FILE_BYTES) + if data is None: + known = next((entry for entry in records if entry.path == "project.yaml"), None) + code = "REFERENCE_PATH" if known is not None else "KEY_REQUIRED" + result = ObservationResult(1, _finding(code, "manifest", reference_date)) + else: + budget.check() + snapshot = TreeSnapshot(tuple(records)) + declared = check_declaration(data, tree=snapshot, + reference_date=reference_date) + budget.check() + if declared.exit_code == 0 and mode != "worktree": + ids = sorted({objects[path] for path in _references(declared.record)}) + availability = git.info(ids) + if any(kind != b"blob" for kind, _ in availability.values()): + raise AcquisitionFailure("reference_object") + report = declared.diagnostics() + report["checks_remaining"] = [item for item in report["checks_remaining"] + if item != "git_observation_acquisition"] + ["acquisition_portability_qualification"] + report["tree_observation"] = { + "rev": "frozen-revision-tree", "staged": "frozen-stage-zero-index", + "worktree": "tracked-working-bytes"}[mode] + report.update(scope="candidate-git-observation", mode=mode, + commit_oid=commit.decode() if commit else None, + tree_oid=tree_oid.decode() if tree_oid else None, + manifest_blob_oid=manifest_oid.decode() if manifest_oid else None, + manifest_sha256=hashlib.sha256(data).hexdigest(), + observation_digest=fingerprint, git_version=git.version, + acquired_tree="frozen-git-objects" if mode != "worktree" else "tracked-working-bytes") + result = ObservationResult(declared.exit_code, report, declared.record, + snapshot if declared.exit_code == 0 else None) + result.diagnostics.update(mode=mode, commit_oid=commit.decode() if commit else None, + tree_oid=tree_oid.decode() if tree_oid else None, + manifest_blob_oid=manifest_oid.decode() if manifest_oid else None, + manifest_sha256=hashlib.sha256(data).hexdigest() if data is not None else None, + observation_digest=fingerprint, git_version=git.version) + if mode == "worktree": + _, repeated_data, repeated_fingerprint = _working(store, _index(metadata, budget)[0], budget) + if repeated_data != data or repeated_fingerprint != fingerprint: + raise AcquisitionFailure("observation_changed") + store.recheck() + budget.check() + return result + except (AcquisitionFailure, OSError, UnicodeError, ValueError, TypeError) as error: + reason = error.reason if isinstance(error, AcquisitionFailure) else "source_io_or_format" + report = _finding("NOT_CHECKED", "acquisition") + report.update(mode=mode if type(mode) is str and mode in {"rev", "staged", "worktree"} else None, + acquisition_reason=reason) + return ObservationResult(2, report) + finally: + if store is not None: + store.close() + + +# Transport identity only: direct source cannot authenticate release approval. +RELEASED_OBSERVATION_SCOPE = "project-manifest-git-observation-1" +RELEASED_FINDING_CODES = frozenset({ + "BYTE_PROFILE", "DATE_FORMAT", "FIELD_BOUND", "FIELD_TYPE", "GRAMMAR", + "KEY_DUPLICATE", "KEY_REQUIRED", "KEY_UNKNOWN", "LEGACY_VERSION", + "NOT_CHECKED", "REFERENCE_PATH", "VERSION_LINE", "DATE_RULE", + "ENUM_UNSUPPORTED", "FIELD_RELATION", "LIST_DUPLICATE", "REFERENCE_MISSING", + "REFERENCE_MODE", "ROLE_RELATION", "TARGET_RULE", +}) +_RELEASED_TREES = { + "rev": "frozen-revision-tree", "staged": "frozen-stage-zero-index", + "worktree": "tracked-working-bytes", +} + + +def _released_unavailable(mode, day): + return { + "scope": RELEASED_OBSERVATION_SCOPE, "exit_code": 2, + "declaration_checks": "not_checked", "complete_v1_validity": "not_checked", + "findings": [{"code": "NOT_CHECKED", "line": 0, "column": 0}], + "mode": mode if type(mode) is str and mode in _RELEASED_TREES else None, + "reference_date": day, "commit_oid": None, "tree_oid": None, + "manifest_blob_oid": None, "manifest_sha256": None, + "observation_digest": None, "git_version": None, "tree_observation": None, + } + + +def observe_released_repository(root, *, mode, reference_date, rev=None, common_dir=None): + """Serialize one C3 observation; approval/binding remain consumer prerequisites. + + A day is explicit, with the existing date normalization. No caller-tree API, + second acquisition, declaration algorithm or complete-v1 approval is added. + Candidate APIs and diagnostics are unchanged. Interrupt/cancellation propagates. + """ + report = _released_unavailable(mode, None) + try: + day = _reference_day(reference_date).isoformat() + except _Failure: + return ObservationResult(2, report) + report = _released_unavailable(mode, day) + try: + observed = observe_repository(root, mode=mode, rev=rev, + reference_date=day, common_dir=common_dir) + status, diagnostics = observed.exit_code, observed.diagnostics + if (type(observed) is not ObservationResult or type(status) is not int + or status not in (0, 1, 2) or type(diagnostics) is not dict + or diagnostics.get("scope") != "candidate-git-observation" + or type(diagnostics.get("exit_code")) is not int + or diagnostics["exit_code"] != status + or diagnostics.get("candidate_declaration_accepted") is not (status == 0) + or diagnostics.get("complete_v1_validity") != "not_checked" + or diagnostics.get("mode") != report["mode"] + or (diagnostics.get("reference_date") != day + and (status == 0 or diagnostics.get("reference_date") is not None))): + raise ValueError + raw_findings = diagnostics.get("findings") + if type(raw_findings) is not list or len(raw_findings) != int(status != 0): + raise ValueError + findings = [] + for item in raw_findings: + if (type(item) is not dict or type(item.get("code")) is not str + or item["code"] not in RELEASED_FINDING_CODES + or (item["code"] == "NOT_CHECKED") is not (status == 2) + or any(type(item.get(key)) is not int or not 0 <= item[key] <= 65_536 + for key in ("line", "column"))): + raise ValueError + findings.append({key: item[key] for key in ("code", "line", "column")}) + if status == 0: + if (type(observed.record) is not dict or type(observed.tree) is not TreeSnapshot + or observed.tree.complete is not True or type(observed.tree.entries) is not tuple): + raise ValueError + elif observed.record is not None or observed.tree is not None: + raise ValueError + provenance = {key: diagnostics.get(key) for key in + ("commit_oid", "tree_oid", "manifest_blob_oid", "manifest_sha256", + "observation_digest", "git_version")} + if status != 2: + if (not re.fullmatch(r"[0-9a-f]{64}", provenance["observation_digest"] or "") + or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", provenance["git_version"] or "") + or (status == 0 and provenance["manifest_sha256"] is None)): + raise ValueError + if provenance["manifest_sha256"] is not None and not re.fullmatch( + r"[0-9a-f]{64}", provenance["manifest_sha256"]): + raise ValueError + required = {"commit_oid", "tree_oid"} if mode == "rev" else set() + if status == 0 and mode != "worktree": + required.add("manifest_blob_oid") + present = [] + for key in ("commit_oid", "tree_oid", "manifest_blob_oid"): + value = provenance[key] + if value is None: + if key in required: + raise ValueError + continue + if (mode == "worktree" or (mode == "staged" and key != "manifest_blob_oid") + or type(value) is not str + or re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", value) is None): + raise ValueError + present.append(len(value)) + if len(set(present)) > 1: + raise ValueError + else: + # Unavailable acquisition cannot attest partially computed provenance. + provenance = {key: None for key in provenance} + report.update(exit_code=status, declaration_checks=("passed", "failed", "not_checked")[status], + findings=findings, tree_observation=_RELEASED_TREES.get(mode) if status != 2 else None, + **provenance) + return ObservationResult(status, report, observed.record, observed.tree) + except Exception: + return ObservationResult(2, _released_unavailable(mode, day)) diff --git a/vendor/project-manifest/release-1/src/project_manifest/candidate.py b/vendor/project-manifest/release-1/src/project_manifest/candidate.py new file mode 100644 index 0000000..f3b1a84 --- /dev/null +++ b/vendor/project-manifest/release-1/src/project_manifest/candidate.py @@ -0,0 +1,296 @@ +"""Pure C1 syntax/shape reader. Success is never a complete v1 verdict.""" + +from dataclasses import dataclass +import re + +MAX_FILE_BYTES = 65_536 +MAX_LINE_BYTES = 2_048 # Content, including indentation; excludes LF. +MAX_TOKEN_BYTES = 64 +TOKEN = re.compile(r"[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*", re.ASCII) +RESERVED = frozenset({"y", "n", "yes", "no", "on", "off", "true", "false", "null"}) +LEGACY_LINES = frozenset({ + 'spec_version: "0.1"', "spec_version: '0.1'", "spec_version: 0.1", +}) +REMAINING = ( + "enum_membership", "calendar_and_reference_date", "role_peer_relations", + "producer_legacy_dispatch", "list_uniqueness", "cross_loader_agreement", + "runner_target_semantics", "tree_references", "declaration_conformance", + "release_binding", "producer_admission", "runtime_acceptance", +) + + +@dataclass(frozen=True) +class Finding: + code: str + field: str + line: int + column: int + + def diagnostic(self): + return {"code": self.code, "field": self.field, + "line": self.line, "column": self.column} + + +@dataclass(frozen=True) +class CandidateResult: + exit_code: int + record: dict | None + findings: tuple[Finding, ...] + + @property + def syntax_shape_accepted(self): + return self.exit_code == 0 + + def diagnostics(self): + return { + "scope": "candidate-syntax-shape", + "syntax_shape_accepted": self.syntax_shape_accepted, + "complete_v1_validity": "not_checked", + "exit_code": self.exit_code, + "findings": [finding.diagnostic() for finding in self.findings], + "checks_remaining": list(REMAINING), + } + + +@dataclass(frozen=True) +class _Node: + kind: str + value: object + line: int + column: int + + +@dataclass(frozen=True) +class _Leaf: + kind: str + maximum: int = 0 + nullable: bool = False + path: bool = False + date_format: bool = False + + +@dataclass(frozen=True) +class _OptionalRecord: + fields: dict + + +@dataclass(frozen=True) +class _DynamicRecords: + fields: dict + + +class _Rejected(Exception): + def __init__(self, code, field, line=0, column=0): + self.finding = Finding(code, field, line, column) + + +def _fail(code, field, node=None): + raise _Rejected(code, field, node.line if node else 0, + node.column if node else 0) + + +def _token(value, line, column): + if len(value) > MAX_TOKEN_BYTES: + raise _Rejected("FIELD_BOUND", "token", line, column) + if not TOKEN.fullmatch(value) or value in RESERVED: + raise _Rejected("GRAMMAR", "token", line, column) + return value + + +def _scalar(value, line, column): + if value == "null": + return _Node("null", None, line, column) + if value == "{}": + return _Node("mapping", {}, line, column) + if value == "[]": + return _Node("list", [], line, column) + if value.startswith('"'): + if not re.fullmatch(r'"[^"\\]+"', value): + raise _Rejected("GRAMMAR", "scalar", line, column) + text = value[1:-1] + if text.startswith(" ") or text.endswith(" "): + raise _Rejected("GRAMMAR", "scalar", line, column) + return _Node("text", text, line, column) + if value.startswith("["): + if not value.endswith("]") or len(value) < 3: + raise _Rejected("GRAMMAR", "list", line, column) + parts = value[1:-1].split(", ") + return _Node("list", [_token(part, line, column) for part in parts], + line, column) + return _Node("token", _token(value, line, column), line, column) + + +def _lines(data): + if not data or not data.endswith(b"\n"): + raise _Rejected("BYTE_PROFILE", "document") + if len(data) > MAX_FILE_BYTES: + raise _Rejected("FIELD_BOUND", "document") + for offset, byte in enumerate(data): + if byte != 10 and not 32 <= byte <= 126: + line = data.count(b"\n", 0, offset) + 1 + column = offset - data.rfind(b"\n", 0, offset) + raise _Rejected("BYTE_PROFILE", "document", line, column) + content = [] + for number, raw in enumerate(data[:-1].split(b"\n"), 1): + if len(raw) > MAX_LINE_BYTES: + raise _Rejected("FIELD_BOUND", "line", number, 1) + if raw.endswith(b" "): + raise _Rejected("BYTE_PROFILE", "line", number, len(raw)) + text = raw.decode("ascii") + body = text.lstrip(" ") + if not body or body.startswith("#"): + continue + indent = len(text) - len(body) + if indent % 2 or indent > 6: + raise _Rejected("GRAMMAR", "indentation", number, 1) + content.append((number, indent, body)) + if not content: + raise _Rejected("KEY_REQUIRED", "root") + first = content[0] + if first[1] == 0 and first[2] in LEGACY_LINES: + raise _Rejected("LEGACY_VERSION", "spec_version", first[0], 1) + if first[1] != 0 or first[2] != 'spec_version: "1"': + raise _Rejected("VERSION_LINE", "spec_version", first[0], 1) + return content + + +def _mapping(lines, position, indent, depth): + if depth > 4: + raise _Rejected("GRAMMAR", "depth", lines[position][0], 1) + children = {} + opening_line = lines[position][0] + while position < len(lines): + line, current, body = lines[position] + if current < indent: + break + if current != indent or ":" not in body: + raise _Rejected("GRAMMAR", "mapping", line, current + 1) + key, rest = body.split(":", 1) + _token(key, line, current + 1) + if key in children: + raise _Rejected("KEY_DUPLICATE", "mapping", line, current + 1) + position += 1 + if rest == "": + if position == len(lines) or lines[position][1] != indent + 2: + raise _Rejected("GRAMMAR", "mapping", line, current + 1) + node, position = _mapping(lines, position, indent + 2, depth + 1) + else: + if not rest.startswith(" ") or len(rest) == 1 or rest[1] == " ": + raise _Rejected("GRAMMAR", "separator", line, current + 1) + node = _scalar(rest[1:], line, current + len(key) + 3) + if position < len(lines) and lines[position][1] > indent: + raise _Rejected("GRAMMAR", "mapping", lines[position][0], 1) + children[key] = node + return _Node("mapping", children, opening_line, indent + 1), position + + +TEXT100 = _Leaf("text", 100) +TOKEN64 = _Leaf("token", 64) +PATH255 = _Leaf("text", 255, path=True) +OPTIONAL_PATH255 = _Leaf("text", 255, nullable=True, path=True) +TOKEN_LIST = _Leaf("list") +COMMAND = {"runner": TOKEN64, "target": _Leaf("text", 255)} +STACK = {"compose": PATH255, "instance_scope": TOKEN64, "port_policy": TOKEN64} +ROLES = ("setup", "check", "test", "build", "dev", "format_check", "format_write") +SHAPE = { + "spec_version": _Leaf("text", 1), + "project": {"id": TOKEN64, "display_name": TEXT100, "owner_entity": TOKEN64}, + "role": TOKEN64, + "canonical": _Leaf("token", 64, nullable=True), + "peers": TOKEN_LIST, + "status": { + "posture": TOKEN64, "operational_state": TOKEN64, + "local_phase": _Leaf("text", 400), + "as_of": _Leaf("text", 10, date_format=True), + }, + "authority": {"status_of_record": PATH255}, + "presentation": { + "visibility": TOKEN64, "public_alias": _Leaf("text", 100, nullable=True), + "link_policy": TOKEN64, + }, + "development": { + "platforms": TOKEN_LIST, + "commands": { + role: COMMAND if role == "check" else _OptionalRecord(COMMAND) + for role in ROLES + }, + "environment": { + "owner": TOKEN64, "template": OPTIONAL_PATH255, + "secret_launcher": OPTIONAL_PATH255, + }, + "stacks": _DynamicRecords(STACK), + "host_requirements": TOKEN_LIST, + }, +} + + +def _path(value): + # Candidate lexical default; no filesystem or tree access. + parts = value.split("/") + return ( + not value.startswith("/") and not re.match(r"^[A-Za-z]:", value) + and all(part and part not in {".", "..", ".git"} + and part == part.strip(" ") for part in parts) + ) + + +def _shape(node, rule, field): + if isinstance(rule, _OptionalRecord): + if node.kind == "null": + return + return _shape(node, rule.fields, field) + if isinstance(rule, _DynamicRecords): + if node.kind != "mapping": + _fail("FIELD_TYPE", field, node) + for child in node.value.values(): + _shape(child, rule.fields, field + ".*") + return + if isinstance(rule, dict): + if node.kind != "mapping": + _fail("FIELD_TYPE", field, node) + unknown = next((key for key in node.value if key not in rule), None) + if unknown is not None: + _fail("KEY_UNKNOWN", field, node.value[unknown]) + if any(key not in node.value for key in rule): + _fail("KEY_REQUIRED", field, node) + for key, child_rule in rule.items(): + child_field = key if field == "root" else field + "." + key + _shape(node.value[key], child_rule, child_field) + return + if node.kind == "null" and rule.nullable: + return + if node.kind != rule.kind: + _fail("FIELD_TYPE", field, node) + if rule.maximum and not 1 <= len(node.value) <= rule.maximum: + _fail("FIELD_BOUND", field, node) + if rule.path and not _path(node.value): + _fail("REFERENCE_PATH", field, node) + if rule.date_format and not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", node.value): + _fail("DATE_FORMAT", field, node) + + +def _record(node): + if node.kind == "mapping": + return {key: _record(child) for key, child in node.value.items()} + if node.kind == "list": + return list(node.value) + return node.value + + +def check_candidate(data): + """Check only C1 bytes/grammar/shape and intrinsic lexical field limits. + + No enum membership, clock, Git/tree resolver, conformance or admission. + Diagnostics never contain input values or dynamic key names. + """ + if type(data) is not bytes: + return CandidateResult(2, None, (Finding("NOT_CHECKED", "input", 0, 0),)) + try: + lines = _lines(data) + node, consumed = _mapping(lines, 0, 0, 1) + if consumed != len(lines): + _fail("GRAMMAR", "mapping") + _shape(node, SHAPE, "root") + return CandidateResult(0, _record(node), ()) + except _Rejected as error: + return CandidateResult(1, None, (error.finding,)) diff --git a/vendor/project-manifest/release-1/src/project_manifest/cli.py b/vendor/project-manifest/release-1/src/project_manifest/cli.py new file mode 100644 index 0000000..7932db4 --- /dev/null +++ b/vendor/project-manifest/release-1/src/project_manifest/cli.py @@ -0,0 +1,49 @@ +"""Offline source CLI; diagnostics contain no manifest-derived record.""" +import argparse +import json +import os +import sys + +from .acquire import observe_repository + + +class UsageError(Exception): + pass + + +class Parser(argparse.ArgumentParser): + def error(self, message): + raise UsageError() + + +def _unavailable(field): + return {"scope": "candidate-git-observation", + "candidate_declaration_accepted": False, + "complete_v1_validity": "not_checked", "exit_code": 2, + "findings": [{"code": "NOT_CHECKED", "field": field, + "line": 0, "column": 0}]} + + +def main(argv=None): + parser = Parser(prog="project-manifest", allow_abbrev=False, + description="Offline candidate declaration checks; complete v1 validity is not checked.") + parser.add_argument("--repo", default=".", help="One explicitly selected repository root.") + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--rev", metavar="REV", help="Resolve one commit once; do not fetch.") + source.add_argument("--staged", action="store_true", help="Read the captured stage-zero index.") + source.add_argument("--worktree", action="store_true", help="Read only index-tracked working paths.") + parser.add_argument("--git-common-dir", help="Explicit storage scope required for a linked worktree.") + parser.add_argument("--reference-date", metavar="YYYY-MM-DD", + help="Explicit reference day; default is today's UTC day captured once.") + try: + args = parser.parse_args(argv) + except UsageError: + print(json.dumps(_unavailable("arguments"), sort_keys=True)) + return 2 + mode = "rev" if args.rev is not None else "staged" if args.staged else "worktree" + result = observe_repository(args.repo, mode=mode, rev=args.rev, + reference_date=args.reference_date, common_dir=args.git_common_dir) + report = dict(result.diagnostics) + report["exit_code"] = result.exit_code + print(json.dumps(report, sort_keys=True, ensure_ascii=True)) + return result.exit_code diff --git a/vendor/project-manifest/release-1/src/project_manifest/validation.py b/vendor/project-manifest/release-1/src/project_manifest/validation.py new file mode 100644 index 0000000..5c35232 --- /dev/null +++ b/vendor/project-manifest/release-1/src/project_manifest/validation.py @@ -0,0 +1,266 @@ +"""Unreleased pure declaration checks over explicit dates and tree records.""" + +from dataclasses import dataclass +from datetime import date +import re +import sys +from types import MappingProxyType + +from .candidate import Finding, ROLES, _path, check_candidate + +# Owner-adopted generic domains; complete release approval is separate. +MINIMUM_PYTHON = (3, 11) +MANIFEST_ENUMS = MappingProxyType({ + "role": frozenset({"canonical", "component", "satellite", "peer", "predecessor"}), + "status.posture": frozenset({ + "deployed-system", "public-package", "public-repo", "private-tool", + "course-taught", "published-artifact", "client-deliverable", + "working-prototype", "design-spec", "source-held", "archive-only", + }), + "status.operational_state": frozenset({ + "production-live", "staging-live", "active-build", "maintained", + "paused", "retired", "unverified", "unknown", + }), + "presentation.visibility": frozenset({ + "public-promoted", "public-available", "shareable-private", "internal", "archive", + }), + "presentation.link_policy": frozenset({"link-ok", "no-link"}), +}) +# Owner-frozen contract policy; complete immutable release remains separate. +DEVELOPMENT_POLICY_APPROVAL_DATE = "2026-10-13" +PLATFORMS = frozenset({ + "macos-arm64", "macos-x86_64", "linux-arm64", "linux-x86_64", + "windows-arm64", "windows-x86_64", +}) +ACTIVATION_OWNERS = frozenset({"none", "direnv", "mise", "project"}) +RUNNERS = frozenset({"script", "mise"}) +SCOPES = frozenset({"host", "checkout"}) +PORT_POLICIES = frozenset({"unpublished", "loopback_only"}) +HOST_REQUIREMENTS = frozenset({"container_engine", "android_sdk"}) +TASK_PART = r"[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*" +TASK = re.compile(TASK_PART + r"(?::" + TASK_PART + r")*", re.ASCII) +EXECUTABLE_PATH = re.compile(r"[A-Za-z0-9._/-]+", re.ASCII) +REMAINING = ( + "release_runtime_qualification", + "producer_legacy_dispatch", "cross_loader_agreement", "git_observation_acquisition", + "release_binding", "native_task_and_declaration_conformance", + "producer_admission", "runtime_acceptance", +) + + +@dataclass(frozen=True) +class TreeEntry: + """One literal tree path; kind is file/directory/symlink/submodule.""" + + path: str + kind: str + executable: bool = False + + +@dataclass(frozen=True) +class TreeSnapshot: + """Caller-supplied complete tree records, not verified Git acquisition.""" + + entries: tuple[TreeEntry, ...] + complete: bool = True + + +@dataclass(frozen=True) +class DeclarationResult: + exit_code: int + record: dict | None + findings: tuple[Finding, ...] + reference_date: str | None = None + + @property + def candidate_declaration_accepted(self): + return self.exit_code == 0 + + def diagnostics(self): + return { + "scope": "candidate-declaration-tree-records", + "candidate_declaration_accepted": self.candidate_declaration_accepted, + "complete_v1_validity": "not_checked", + "tree_observation": "caller-supplied-unverified", + "vocabulary_state": { + "manifest_domains": "owner-adopted-generic-snapshot", + "development_policy": "owner-frozen-contract-policy", + "development_policy_approval_date": DEVELOPMENT_POLICY_APPROVAL_DATE, + }, + "exit_code": self.exit_code, + "reference_date": self.reference_date, + "findings": [item.diagnostic() for item in self.findings], + "checks_remaining": list(REMAINING), + } + + +class _Failure(Exception): + def __init__(self, code, field, exit_code=1): + self.finding = Finding(code, field, 0, 0) + self.exit_code = exit_code + + +def _reject(code, field): + raise _Failure(code, field) + + +def _reference_day(value): + if type(value) is date: + return value + if type(value) is str and re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value): + try: + return date.fromisoformat(value) + except ValueError: + pass + raise _Failure("NOT_CHECKED", "reference_date", 2) + + +def _safe_path(value, executable=False): + # Reserve .git case-insensitively; tree lookup itself is exact-case. + safe = _path(value) and all(part.casefold() != ".git" for part in value.split("/")) + return safe and (not executable or bool(EXECUTABLE_PATH.fullmatch(value))) + + +def _unique(items, field): + if len(items) != len(set(items)): + _reject("LIST_DUPLICATE", field) + + +def _semantics(record, reference_day): + for field, value in ( + ("role", record["role"]), + ("status.posture", record["status"]["posture"]), + ("status.operational_state", record["status"]["operational_state"]), + ("presentation.visibility", record["presentation"]["visibility"]), + ("presentation.link_policy", record["presentation"]["link_policy"]), + ): + if value not in MANIFEST_ENUMS[field]: + _reject("ENUM_UNSUPPORTED", field) + try: + stated_day = date.fromisoformat(record["status"]["as_of"]) + except ValueError: + _reject("DATE_RULE", "status.as_of") + if stated_day > reference_day: + _reject("DATE_RULE", "status.as_of") + + own_id = record["project"]["id"] + canonical = record["canonical"] + if (record["role"] == "canonical") != (canonical is None) or canonical == own_id: + _reject("ROLE_RELATION", "canonical") + if own_id in record["peers"]: + _reject("ROLE_RELATION", "peers") + _unique(record["peers"], "peers") + development = record["development"] + if development["environment"]["owner"] not in ACTIVATION_OWNERS: + _reject("ENUM_UNSUPPORTED", "development.environment.owner") + _unique(development["platforms"], "development.platforms") + if any(item not in PLATFORMS for item in development["platforms"]): + _reject("ENUM_UNSUPPORTED", "development.platforms") + _unique(development["host_requirements"], "development.host_requirements") + if any(item not in HOST_REQUIREMENTS for item in development["host_requirements"]): + _reject("ENUM_UNSUPPORTED", "development.host_requirements") + if development["stacks"] and "container_engine" not in development["host_requirements"]: + _reject("FIELD_RELATION", "development.host_requirements") + for role in ROLES: + command = development["commands"][role] + if command is None: + continue + field = "development.commands." + role + if command["runner"] not in RUNNERS: + _reject("ENUM_UNSUPPORTED", field + ".runner") + target = command["target"] + if command["runner"] == "mise": + if len(target) > 64: + _reject("FIELD_BOUND", field + ".target") + if not TASK.fullmatch(target): + _reject("TARGET_RULE", field + ".target") + elif not _safe_path(target, executable=True): + _reject("TARGET_RULE", field + ".target") + for stack in development["stacks"].values(): + if stack["instance_scope"] not in SCOPES: + _reject("ENUM_UNSUPPORTED", "development.stacks.*.instance_scope") + if stack["port_policy"] not in PORT_POLICIES: + _reject("ENUM_UNSUPPORTED", "development.stacks.*.port_policy") + + +def _tree_index(snapshot): + if type(snapshot) is not TreeSnapshot or snapshot.complete is not True: + raise _Failure("NOT_CHECKED", "tree", 2) + if type(snapshot.entries) is not tuple: + raise _Failure("NOT_CHECKED", "tree", 2) + entries = {} + for entry in snapshot.entries: + if type(entry) is not TreeEntry or type(entry.path) is not str: + raise _Failure("NOT_CHECKED", "tree", 2) + parts = entry.path.split("/") + if (not entry.path or entry.path.startswith("/") or re.match(r"^[A-Za-z]:", entry.path) + or any(not part or part in {".", ".."} for part in parts) + or any(ord(char) < 32 or ord(char) == 127 for char in entry.path) + or entry.path in entries or type(entry.kind) is not str or entry.kind not in + {"file", "directory", "symlink", "submodule"} + or type(entry.executable) is not bool + or (entry.kind != "file" and entry.executable)): + raise _Failure("NOT_CHECKED", "tree", 2) + entries[entry.path] = entry + # A complete tree explicitly includes directory nodes; no inferred parents. + for path in entries: + parts = path.split("/") + for depth in range(1, len(parts)): + parent = entries.get("/".join(parts[:depth])) + if parent is None or parent.kind != "directory": + raise _Failure("NOT_CHECKED", "tree", 2) + return entries + + +def _references(record): + yield "authority.status_of_record", record["authority"]["status_of_record"], False + environment = record["development"]["environment"] + for key in ("template", "secret_launcher"): + if environment[key] is not None: + yield "development.environment." + key, environment[key], key == "secret_launcher" + for role in ROLES: + command = record["development"]["commands"][role] + if command is not None and command["runner"] == "script": + yield "development.commands." + role + ".target", command["target"], True + for stack in record["development"]["stacks"].values(): + yield "development.stacks.*.compose", stack["compose"], False + + +def _check_reference(entries, field, value, executable): + if not _safe_path(value, executable=executable): + _reject("REFERENCE_PATH", field) + parts = value.split("/") + for depth in range(1, len(parts) + 1): + entry = entries.get("/".join(parts[:depth])) + if entry is None: + _reject("REFERENCE_MISSING", field) + required_kind = "file" if depth == len(parts) else "directory" + if entry.kind != required_kind: + _reject("REFERENCE_PATH", field) + if depth == len(parts) and executable and not entry.executable: + _reject("REFERENCE_MODE", field) + + +def check_declaration(data, *, tree, reference_date): + """Check candidate semantics and literal references, with no I/O or clock. + + Exit 0 covers this candidate policy only, never complete v1 validity. + Exit 1 rejects the declaration; exit 2 rejects unavailable/incoherent + caller-supplied evidence. No finding echoes a path, value or dynamic name. + """ + if sys.version_info < MINIMUM_PYTHON: + return DeclarationResult(2, None, (Finding("NOT_CHECKED", "runtime", 0, 0),)) + parsed = check_candidate(data) + if parsed.exit_code: + return DeclarationResult(parsed.exit_code, None, parsed.findings) + reference_day = None + try: + reference_day = _reference_day(reference_date) + _semantics(parsed.record, reference_day) + entries = _tree_index(tree) + for field, value, executable in _references(parsed.record): + _check_reference(entries, field, value, executable) + return DeclarationResult(0, parsed.record, (), reference_day.isoformat()) + except _Failure as error: + return DeclarationResult(error.exit_code, None, (error.finding,), + reference_day.isoformat() if reference_day else None) diff --git a/vendor/project-manifest/release-1/tests/acquisition_fixtures.py b/vendor/project-manifest/release-1/tests/acquisition_fixtures.py new file mode 100644 index 0000000..c7870b9 --- /dev/null +++ b/vendor/project-manifest/release-1/tests/acquisition_fixtures.py @@ -0,0 +1,286 @@ +"""Owned synthetic Git graphs, created only under the approved temporary root.""" + +from dataclasses import dataclass +import os +import platform +from pathlib import Path +import secrets +import shlex +import shutil +import stat +import subprocess +import sys +import tempfile + + +TEST_HOST_ROOTS = {("Darwin", "arm64"): Path("/private/tmp"), + ("Linux", "x86_64"): Path("/tmp")} + + +def fixture_parent(system=None, machine=None): + host = (platform.system() if system is None else system, + platform.machine() if machine is None else machine) + if host not in TEST_HOST_ROOTS: + raise AssertionError("Unsupported synthetic acquisition test host") + return TEST_HOST_ROOTS[host] + + +@dataclass(frozen=True) +class FileSpec: + mode: str + data: bytes | None = None + oid: str | None = None + + +def _safe_parts(path): + parts = path.split("/") + if not parts or any(not part or part in {".", ".."} for part in parts): + raise AssertionError("Unsafe synthetic fixture path") + if path.startswith("/") or any(char in path for char in "\x00\n\t"): + raise AssertionError("Unsafe synthetic fixture path") + return parts + + +class FixtureOwner: + """Cleanup is deferred until unittest has recorded failures, including subtests.""" + + def __init__(self, testcase): + self.testcase = testcase + self.roots = [] + result = testcase._outcome.result + self.before = self._failure_count(result) + testcase.addCleanup(self.finish) + + @staticmethod + def _failure_count(result): + return sum(len(getattr(result, name, ())) for name in + ("failures", "errors", "unexpectedSuccesses")) + + def create_root(self): + parent = fixture_parent() + root = Path(tempfile.mkdtemp(prefix="project-manifest-c3-", dir=parent)) + if (root.is_symlink() or root.resolve().parent != parent.resolve() + or root.stat().st_uid != os.getuid()): + raise AssertionError("Synthetic fixture escaped its approved temporary root") + token = secrets.token_hex(24) + marker = root / "fixture-owner" + marker.write_text(token, encoding="ascii") + info = root.stat() + self.roots.append((root, token, (info.st_dev, info.st_ino))) + return root + + def finish(self): + result = self.testcase._outcome.result + failed = self._failure_count(result) != self.before + failed = failed or not self.testcase._outcome.success + for root, token, identity in self.roots: + if failed: + print("Retained synthetic fixture for failed test: " + str(root), file=sys.stderr) + continue + info = root.lstat() + if not stat.S_ISDIR(info.st_mode) or (info.st_dev, info.st_ino) != identity: + raise AssertionError("Synthetic fixture ownership changed; root retained") + marker = root / "fixture-owner" + fd = os.open(marker, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + try: + marker_info = os.fstat(fd) + if not stat.S_ISREG(marker_info.st_mode) or marker_info.st_size != len(token): + raise AssertionError("Synthetic fixture marker changed; root retained") + if os.read(fd, len(token) + 1).decode("ascii") != token: + raise AssertionError("Synthetic fixture ownership changed; root retained") + finally: + os.close(fd) + shutil.rmtree(root) + + +class GitFixture: + """Plumbing only: no real author, commit invocation, hooks, transport or signing.""" + + def __init__(self, owner, *, bare=False, object_format="sha1"): + if object_format not in {"sha1", "sha256"}: + raise AssertionError("Unsupported synthetic fixture object format") + self.root = owner.create_root() + self.path = self.root / "repository" + self.path.mkdir() + self.bare = bare + self.object_format = object_format + self.owned_loose_objects = set() + self.git_dir = self.path if bare else self.path / ".git" + self.env = {name: value for name, value in os.environ.items() + if not name.startswith("GIT_")} + self.env.update(GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM="1", + GIT_TERMINAL_PROMPT="0", GCM_INTERACTIVE="never") + self.executable = shutil.which("git") + if self.executable is None: + raise AssertionError("Required Git executable is unavailable") + template = self.root / "empty-template" + template.mkdir() + args = ["init", "--quiet", "--template=" + str(template), + "--initial-branch=synthetic", "--object-format=" + object_format] + if bare: + args.append("--bare") + self._run(args + [str(self.path)], initialized=False) + self.head = None + self.tree = None + self.files = {} + self.blobs = {} + + def _run(self, args, *, data=None, initialized=True): + prefix = [self.executable] + if initialized: + prefix += ["--git-dir=" + str(self.git_dir)] + if not self.bare: + prefix += ["--work-tree=" + str(self.path)] + result = subprocess.run(prefix + list(args), input=data, capture_output=True, + env=self.env, cwd=self.root, timeout=10, check=False) + if result.returncode: + raise AssertionError("Synthetic fixture Git plumbing failed: " + args[0]) + return result.stdout + + def blob(self, data, kind="blob"): + oid = self._run(["hash-object", "-w", "-t", kind, "--stdin"], data=data).strip().decode("ascii") + self.owned_loose_objects.add(oid) + return oid + + def commit_files(self, files, *, update_head=True): + graph = {} + blobs = {} + for path, spec in files.items(): + parts = _safe_parts(path) + if spec.mode not in {"100644", "100755", "120000", "160000"}: + raise AssertionError("Unsupported synthetic fixture mode") + if (spec.data is None) == (spec.oid is None): + raise AssertionError("Synthetic fixture needs exactly one payload or object ID") + oid = spec.oid if spec.oid is not None else self.blob(spec.data) + blobs[path] = oid + node = graph + for part in parts[:-1]: + node = node.setdefault(part, {}) + if not isinstance(node, dict): + raise AssertionError("Conflicting synthetic fixture paths") + if parts[-1] in node: + raise AssertionError("Duplicate synthetic fixture path") + node[parts[-1]] = (spec.mode, oid) + + def write_tree(node): + records = [] + for name, value in sorted(node.items()): + if isinstance(value, dict): + mode, kind, oid = "040000", "tree", write_tree(value) + else: + mode, oid = value + kind = "commit" if mode == "160000" else "blob" + records.append((mode + " " + kind + " " + oid + "\t" + name).encode() + b"\0") + oid = self._run(["mktree", "-z"], data=b"".join(records)).strip().decode("ascii") + self.owned_loose_objects.add(oid) + return oid + + tree = write_tree(graph) + address = b"fixture" + b"@" + b"example.invalid" + identity = b"Synthetic Fixture <" + address + b"> 0 +0000\n" + commit = b"tree " + tree.encode() + b"\nauthor " + identity + commit += b"committer " + identity + b"\nSynthetic graph\n" + oid = self.blob(commit, "commit") + if update_head: + self._run(["update-ref", "refs/heads/synthetic", oid]) + self.head, self.tree, self.files, self.blobs = oid, tree, dict(files), blobs + return oid + + def seed(self, files): + self.commit_files(files) + if not self.bare: + self.set_index(files) + self.materialize(files, self.path) + return self + + def set_index(self, files): + if self.bare: + raise AssertionError("Bare fixture cannot have an observation index") + lines = [] + for path, spec in sorted(files.items()): + _safe_parts(path) + oid = spec.oid if spec.oid is not None else self.blob(spec.data) + lines.append((spec.mode + " " + oid + "\t" + path).encode() + b"\0") + self._run(["update-index", "-z", "--index-info"], data=b"".join(lines)) + + @staticmethod + def materialize(files, target): + for path, spec in files.items(): + _safe_parts(path) + leaf = target.joinpath(*path.split("/")) + leaf.parent.mkdir(parents=True, exist_ok=True) + if spec.mode == "160000": + leaf.mkdir() + elif spec.mode == "120000": + leaf.symlink_to(spec.data.decode("utf-8")) + else: + if spec.data is None: + raise AssertionError("Synthetic working file requires bytes") + leaf.write_bytes(spec.data) + leaf.chmod(0o755 if spec.mode == "100755" else 0o644) + + def append_config(self, text, *, path=None): + target = self.git_dir / "config" if path is None else path + with target.open("a", encoding="utf-8") as stream: + stream.write(text) + + def linked(self): + if self.bare: + raise AssertionError("Linked fixture requires nonbare common storage") + linked = self.root / "linked" + linked.mkdir() + admin = self.git_dir / "worktrees" / "synthetic" + admin.mkdir(parents=True) + (linked / ".git").write_text("gitdir: " + str(admin) + "\n") + (admin / "commondir").write_text("../..\n") + (admin / "gitdir").write_text(str(linked / ".git") + "\n") + (admin / "HEAD").write_text(self.head + "\n") + (admin / "index").write_bytes((self.git_dir / "index").read_bytes()) + self.materialize(self.files, linked) + return linked, admin + + def unmerged_index(self, path): + oid = self.blobs[path] + zero = "0" * len(oid) + rows = ["0 " + zero + "\t" + path] + rows += ["100644 " + oid + " " + str(stage) + "\t" + path for stage in (1, 2, 3)] + self._run(["update-index", "-z", "--index-info"], + data=b"\0".join(row.encode() for row in rows) + b"\0") + + def intent_index(self, path): + leaf = self.path.joinpath(*_safe_parts(path)) + leaf.parent.mkdir(parents=True, exist_ok=True) + leaf.write_bytes(b"Synthetic pending entry\n") + self._run(["add", "--intent-to-add", "--", path]) + + def pack_only(self): + """Pack every owned object, then remove only this fixture's loose files.""" + pack_dir = self.git_dir / "objects/pack" + pack_dir.mkdir(exist_ok=True) + expected = 40 if self.object_format == "sha1" else 64 + if any(len(oid) != expected or any(char not in "0123456789abcdef" for char in oid) + for oid in self.owned_loose_objects): + raise AssertionError("Synthetic owned object identifier was malformed") + objects = b"\n".join(oid.encode("ascii") for oid in sorted(self.owned_loose_objects)) + b"\n" + pack_oid = self._run(["pack-objects", str(pack_dir / "pack")], + data=objects).strip().decode("ascii") + if len(pack_oid) != expected or any(char not in "0123456789abcdef" for char in pack_oid): + raise AssertionError("Synthetic pack identifier was malformed") + pack = pack_dir / ("pack-" + pack_oid + ".pack") + index = pack_dir / ("pack-" + pack_oid + ".idx") + if not pack.is_file() or not index.is_file(): + raise AssertionError("Synthetic pack plumbing omitted pack or index") + for oid in self.owned_loose_objects: + path = self.git_dir / "objects" / oid[:2] / oid[2:] + if not stat.S_ISREG(path.lstat().st_mode): + raise AssertionError("Synthetic loose object ownership changed") + path.unlink() + return pack, index + + def sentinel(self): + marker = self.root / "executed-sentinel" + executable = self.root / "sentinel-command" + executable.write_text("#!/bin/sh\nprintf executed >> " + shlex.quote(str(marker)) + + "\nprintf synthetic-child-marker\nexit 91\n") + executable.chmod(0o755) + return marker, executable diff --git a/vendor/project-manifest/release-1/tests/compare_loaders.py b/vendor/project-manifest/release-1/tests/compare_loaders.py new file mode 100644 index 0000000..99c480c --- /dev/null +++ b/vendor/project-manifest/release-1/tests/compare_loaders.py @@ -0,0 +1,311 @@ +"""Optional loader preparation comparison; never a validator dependency/release.""" + +import argparse +import hashlib +import importlib +import json +import os +from pathlib import Path +import platform +import re +import subprocess +import sys + +ROOT = Path(__file__).absolute().parent.parent +sys.dont_write_bytecode = True +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "tests")) +from project_manifest import check_candidate +from test_candidate import BASE, STACK, changed, full_declaration_shape + +ENGINES = frozenset({"PyYAML.SafeLoader", "PyYAML.CSafeLoader", "Ruby.Psych"}) +VERSION = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+[A-Za-z0-9.+-]*") +PROFILE_ID = re.compile(r"[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*") +MAX_PROFILE_BYTES = 65_536 + +RUBY = """ +require 'psych' +require 'json' +if ARGV == ['version'] + puts JSON.generate({ruby: RUBY_VERSION, psych: Psych::VERSION, libyaml: Psych.libyaml_version}) +else + text = STDIN.read + begin + value = if Psych::VERSION.split('.')[0].to_i < 4 + Psych.safe_load(text, [], [], false) + else + Psych.safe_load(text, permitted_classes: [], permitted_symbols: [], aliases: false) + end + rescue Psych::Exception + exit 65 + end + puts JSON.generate(value) +end +""" + + +class Unavailable(Exception): + """Fixed preparation code, never exception text or an executable path.""" + + +def vectors(): + accepted = {"base": BASE, "stack": changed(b" stacks: {}\n", STACK), + "comment": b"# Synthetic comparison\n\n" + BASE, + "quoted_reserved": changed(b' display_name: "Synthetic example"\n', b' display_name: "yes"\n')} + for owner in ("none", "direnv", "mise", "project"): + accepted["activation_" + owner] = changed(b" owner: sample-activation\n", b" owner: " + owner.encode() + b"\n") + accepted["full_shape"] = full_declaration_shape() + accepted["singleton_list"] = changed(b"peers: []\n", b"peers: [a]\n") + accepted["ordered_lists"] = BASE.replace(b"peers: []\n", b"peers: [sample_two, sample-one]\n").replace( + b" platforms: []\n", b" platforms: [linux-x86_64, macos-arm64]\n").replace( + b" host_requirements: []\n", b" host_requirements: [android_sdk, container_engine]\n") + accepted["duplicate_list"] = changed(b"peers: []\n", b"peers: [sample-peer, sample-peer]\n") + accepted["quoted_null_and_numeric"] = BASE.replace(b" public_alias: null\n", b' public_alias: "null"\n').replace( + b' display_name: "Synthetic example"\n', b' display_name: "001"\n') + accepted["quoted_punctuation"] = changed(b' display_name: "Synthetic example"\n', b' display_name: "# literal: [] {}"\n') + accepted["reordered_keys"] = BASE.replace(b"role: sample-role\ncanonical: null\n", b"canonical: null\nrole: sample-role\n").replace( + b' id: sample-project\n display_name: "Synthetic example"\n owner_entity: sample-owner\n', + b' owner_entity: sample-owner\n display_name: "Synthetic example"\n id: sample-project\n') + accepted["interior_comments"] = BASE.replace(b" id: sample-project\n", b" # Synthetic odd interior comment\n id: sample-project\n").replace( + b" owner: sample-activation\n", b" # Synthetic deep interior comment\n owner: sample-activation\n") + second = b''' second-stack_two: + compose: "stacks/second.example" + instance_scope: checkout + port_policy: loopback_only +''' + accepted["two_mixed_stack_keys"] = changed(b" stacks: {}\n", STACK.replace(b"sample-stack:", b"first_stack-one:") + second) + accepted["token_limits"] = changed(b" stacks: {}\n", STACK.replace(b"sample-stack:", b"b" * 64 + b":")).replace( + b" id: sample-project\n", b" id: " + b"a" * 64 + b"\n") + data = full_declaration_shape() + for old, key, length in ( + (b' display_name: "Synthetic example"\n', b" display_name", 100), + (b' public_alias: "Synthetic alias"\n', b" public_alias", 100), + (b' local_phase: "Synthetic phase"\n', b" local_phase", 400), + (b' status_of_record: "docs/status.example"\n', b" status_of_record", 255), + (b' target: "check-example"\n', b" target", 255), + (b' template: "config/example.env"\n', b" template", 255), + (b' secret_launcher: "scripts/example-launcher"\n', b" secret_launcher", 255), + (b' compose: "stacks/compose.example"\n', b" compose", 255), + ): + data = changed(old, key + b': "' + b"x" * length + b'"\n', data) + accepted["text_and_path_limits"] = data + accepted["minimum_scalars"] = BASE.replace(b" id: sample-project\n", b" id: a\n").replace( + b' display_name: "Synthetic example"\n', b' display_name: "x"\n').replace( + b' local_phase: "Synthetic phase"\n', b' local_phase: "x"\n').replace( + b' status_of_record: "docs/status.example"\n', b' status_of_record: "x"\n') + accepted["file_limit"] = BASE + b"\n" * (65_536 - len(BASE)) + accepted["line_limit"] = BASE + b"#" + b"x" * 2047 + b"\n" + refused = { + "duplicate_key": BASE + b'authority:\n status_of_record: "docs/status.example"\n', + "reserved_token": changed(b"role: sample-role\n", b"role: yes\n"), + "single_quote": changed(b' display_name: "Synthetic example"\n', b" display_name: 'Synthetic example'\n"), + "flow_map": changed(b"project:\n", b"project: {}\n"), + "unicode": changed(b' display_name: "Synthetic example"\n', ' display_name: "Synthetic caf\u00e9"\n'.encode()), + "legacy_version": changed(b'spec_version: "1"\n', b"spec_version: 0.1\n"), + "crlf": BASE.replace(b"\n", b"\r\n"), + } + return accepted, refused + + +def exact_version(value): + return type(value) is str and VERSION.fullmatch(value) is not None + + +def versions_valid(engine, value): + keys = {"ruby", "psych", "libyaml"} if engine == "Ruby.Psych" else {"pyyaml", "libyaml"} + if type(value) is not dict or set(value) != keys or any(not exact_version(value[k]) for k in keys - {"libyaml"}): + return False + linked = value["libyaml"] + return ((linked is None and engine == "PyYAML.SafeLoader") or + type(linked) is list and len(linked) == 3 and all(type(n) is int and n >= 0 for n in linked)) + + +def selection_valid(value): + if type(value) is not dict or set(value) != {"python", "profiles"} or not exact_version(value["python"]): + return False + profiles = value["profiles"] + if type(profiles) is not list or not 1 <= len(profiles) <= 8: + return False + seen = set() + for profile in profiles: + if type(profile) is not dict or type(profile.get("engine")) is not str or profile["engine"] not in ENGINES: + return False + keys = {"id", "engine", "versions"} | ({"executable"} if profile["engine"] == "Ruby.Psych" else set()) + if set(profile) != keys or type(profile["id"]) is not str or len(profile["id"]) > 64 or not PROFILE_ID.fullmatch(profile["id"]) or profile["id"] in seen: + return False + if not versions_valid(profile["engine"], profile["versions"]): + return False + if profile["engine"] == "Ruby.Psych" and (type(profile["executable"]) is not str or not Path(profile["executable"]).is_absolute()): + return False + seen.add(profile["id"]) + return True + + +def ruby_loader(executable, data): + result = subprocess.run([executable, "-e", RUBY], input=data, capture_output=True, timeout=5, check=False) + if result.returncode == 65: + raise ValueError("LOADER_REFUSED") + if result.returncode: + raise Unavailable("ENGINE_EXECUTION_UNAVAILABLE") + try: + return json.loads(result.stdout) + except (ValueError, UnicodeError): + raise Unavailable("ENGINE_EXECUTION_UNAVAILABLE") from None + + +def initialize(profile): + engine = profile["engine"] + if engine.startswith("PyYAML."): + try: + yaml = importlib.import_module("yaml") + except ModuleNotFoundError: + raise Unavailable("ENGINE_MISSING") from None + loader = getattr(yaml, engine.split(".")[1], None) + if loader is None: + raise Unavailable("ENGINE_MISSING") + linked = None + if getattr(yaml, "__with_libyaml__", False): + # Official PyYAML extension API reports the actually linked library. + linked = list(importlib.import_module("yaml._yaml").get_version()) + versions = {"pyyaml": yaml.__version__, "libyaml": linked} + return versions, lambda data: yaml.load(data, Loader=loader) + path = Path(profile["executable"]) + if not path.is_file() or not os.access(path, os.X_OK): + raise Unavailable("ENGINE_MISSING") + version = subprocess.run([str(path), "-e", RUBY, "version"], capture_output=True, timeout=5, check=False) + if version.returncode: + raise Unavailable("ENGINE_INIT_FAILED") + return json.loads(version.stdout), lambda data: ruby_loader(str(path), data) + + +def discover(): + loaders, unavailable = [], [] + for engine in ("PyYAML.SafeLoader", "PyYAML.CSafeLoader"): + try: + versions, load = initialize({"engine": engine}) + loaders.append((engine, versions, load)) + except Exception: + unavailable.append(engine) + for path in sorted({str(Path(p).resolve()) for p in ("/usr/bin/ruby", "/opt/homebrew/bin/ruby") if Path(p).is_file()}): + try: + versions, load = initialize({"engine": "Ruby.Psych", "executable": path}) + loaders.append(("Ruby.Psych." + versions["psych"], versions, load)) + except Exception: + unavailable.append("Ruby.Psych") + return loaders, unavailable + + +def compare(*, selection=None, initializer=None, discovery=None, supplied_vectors=None, python_version=None): + selected = selection is not None + current_python = platform.python_version() if python_version is None else python_version + report = {"scope": "fresh-selected-loader-synthetic-comparison" if selected else "fresh-installed-loader-synthetic-comparison", + "exit_code": 2, "python": current_python, "host": platform.system(), "machine": platform.machine(), + "selected_comparison": "not_checked", "release_qualification": "not_checked", "other_loaders": "not_checked", + "loaders": [], "unavailable": [], "requirements": []} + source_paths = ("tests/compare_loaders.py", "tests/test_candidate.py", "src/project_manifest/candidate.py") + report["source_sha256"] = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in source_paths} + if selected and not selection_valid(selection): + report["reason"] = "SELECTION_INPUT" + return report + accepted, refused = vectors() if supplied_vectors is None else supplied_vectors + strict = {name: check_candidate(data) for name, data in accepted.items()} + if not accepted or any(result.exit_code for result in strict.values()) or any(check_candidate(data).exit_code != 1 for data in refused.values()): + report.update(exit_code=1, reason="STRICT_CONTROL_FAILED", selected_comparison="failed" if selected else "not_checked") + return report + report.update(accepted_vectors=len(accepted), strict_refusal_vectors=len(refused), + vectors={name: hashlib.sha256(data).hexdigest() for name, data in (accepted | refused).items()}) + loaders = [] + if selected: + report["selection_sha256"] = hashlib.sha256(json.dumps(selection, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + if selection["python"] != current_python: + report["reason"] = "PYTHON_VERSION_MISMATCH" + return report + start = initialize if initializer is None else initializer + for profile in selection["profiles"]: + requirement = {"profile": profile["id"], "engine": profile["engine"], "expected_versions": profile["versions"]} + try: + versions, load = start(profile) + if not versions_valid(profile["engine"], versions) or not callable(load): + raise Unavailable("ENGINE_INIT_FAILED") + requirement["actual_versions"] = versions + requirement["code"] = "SATISFIED" if versions == profile["versions"] else "VERSION_MISMATCH" + except Exception as error: + requirement["code"] = str(error) if isinstance(error, Unavailable) and str(error) in {"ENGINE_MISSING", "ENGINE_INIT_FAILED"} else "ENGINE_INIT_FAILED" + report["requirements"].append(requirement) + if requirement["code"] == "SATISFIED": + loaders.append((profile["id"], versions, load)) + if any(item["code"] != "SATISFIED" for item in report["requirements"]): + report["reason"] = "SELECTED_REQUIREMENT_UNAVAILABLE" + return report + else: + loaders, report["unavailable"] = (discover if discovery is None else discovery)() + mismatch = False + for name, versions, load in loaders: + agreements, differences, broad = [], [], [] + loader_report = {"loader": name, "version": versions, "agreements": agreements, + "differences": differences, "broader_yaml_acceptance": broad} + report["loaders"].append(loader_report) + for label, data in accepted.items(): + try: + agreed = load(data) == strict[label].record + except (Unavailable, OSError, subprocess.TimeoutExpired): + loader_report.update(execution="unavailable", phase="accepted", unavailable_vector=label) + report["unavailable"].append(name) + report.update(exit_code=2, reason="LOADER_EXECUTION_UNAVAILABLE") + return report + except Exception: + agreed = False + (agreements if agreed else differences).append(label) + for label, data in refused.items(): + try: + load(data) + broad.append(label) + except (Unavailable, OSError, subprocess.TimeoutExpired): + loader_report.update(execution="unavailable", phase="strict_refusal", unavailable_vector=label) + report["unavailable"].append(name) + report.update(exit_code=2, reason="LOADER_EXECUTION_UNAVAILABLE") + return report + except Exception: + pass + mismatch = mismatch or bool(differences) + report["exit_code"] = 1 if mismatch else (0 if loaders else 2) + if selected: + report["selected_comparison"] = "failed" if mismatch else "passed" + return report + + +def read_selection(path): + def pairs(items): + result = {} + for key, value in items: + if key in result: + raise Unavailable("SELECTION_INPUT") + result[key] = value + return result + with Path(path).open("rb") as stream: + data = stream.read(MAX_PROFILE_BYTES + 1) + if len(data) > MAX_PROFILE_BYTES: + raise Unavailable("SELECTION_INPUT") + return json.loads(data, object_pairs_hook=pairs) + + +def main(argv=None): + class Parser(argparse.ArgumentParser): + def error(self, message): + raise Unavailable("SELECTION_INPUT") + parser = Parser(description="Optional loader comparison, never release approval") + parser.add_argument("--selected-profiles", metavar="FILE") + try: + args = parser.parse_args(argv) + selection = read_selection(args.selected_profiles) if args.selected_profiles is not None else None + if args.selected_profiles and selection is None: + raise Unavailable("SELECTION_INPUT") + report = compare(selection=selection) + except (Unavailable, OSError, ValueError, TypeError): + report = {"exit_code": 2, "reason": "SELECTION_INPUT", "selected_comparison": "not_checked", "release_qualification": "not_checked"} + print(json.dumps(report, indent=2, sort_keys=True)) + return report["exit_code"] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/project-manifest/release-1/tests/run_corpus.py b/vendor/project-manifest/release-1/tests/run_corpus.py new file mode 100644 index 0000000..202e62b --- /dev/null +++ b/vendor/project-manifest/release-1/tests/run_corpus.py @@ -0,0 +1,40 @@ +"""Portable stdlib harness; unsupported full acquisition hosts return not checked.""" + +import argparse +import json +from pathlib import Path +import platform +import sys +import unittest + +ROOT = Path(__file__).absolute().parent.parent +PURE_MODULES = ("test_candidate", "test_validation") + + +def main(): + parser = argparse.ArgumentParser(description="Candidate reference corpus, never release approval") + parser.add_argument("--scope", choices=("pure", "all"), default="all") + args = parser.parse_args() + host_supported = (platform.system(), platform.machine()) in { + ("Darwin", "arm64"), ("Linux", "x86_64"), + } + if sys.version_info < (3, 11) or (args.scope == "all" and not host_supported): + print(json.dumps({"scope": args.scope, "exit_code": 2, + "qualification": "not_checked", "reason": "UNQUALIFIED_RUNTIME_OR_HOST"})) + return 2 + sys.dont_write_bytecode = True + sys.path.insert(0, str(ROOT / "src")) + sys.path.insert(0, str(ROOT / "tests")) + loader = unittest.TestLoader() + suite = (loader.discover(str(ROOT / "tests"), pattern="test_*.py") if args.scope == "all" + else loader.loadTestsFromNames(PURE_MODULES)) + result = unittest.TextTestRunner(verbosity=2, failfast=True).run(suite) + code = 0 if result.wasSuccessful() and result.testsRun else 1 + print(json.dumps({"scope": args.scope, "exit_code": code, "tests": result.testsRun, + "complete_v1_validity": "not_checked", "release_approval": "not_checked", + "linux_acquisition": "not_checked"}, sort_keys=True)) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/project-manifest/release-1/tests/test_acquisition.py b/vendor/project-manifest/release-1/tests/test_acquisition.py new file mode 100644 index 0000000..58c3ca0 --- /dev/null +++ b/vendor/project-manifest/release-1/tests/test_acquisition.py @@ -0,0 +1,1260 @@ +"""Candidate acquisition behavior using freshly owned, public-safe Git graphs.""" + +import contextlib +import fcntl +import hashlib +import io +import json +import os +from pathlib import Path +import shlex +import signal +import stat +import sys +import subprocess +import unittest +from unittest.mock import patch +import zlib + +sys.path.insert(0, str(Path(__file__).absolute().parent.parent / "src")) +from project_manifest import acquire as acquisition +from project_manifest.acquire import observe_repository +from acquisition_fixtures import FileSpec, FixtureOwner, GitFixture, fixture_parent + + +DATA = b'''spec_version: "1" +project: + id: sample-project + display_name: "Synthetic example" + owner_entity: sample-owner +role: canonical +canonical: null +peers: [] +status: + posture: working-prototype + operational_state: maintained + local_phase: "Synthetic phase" + as_of: "2026-01-01" +authority: + status_of_record: "docs/status.example" +presentation: + visibility: internal + public_alias: null + link_policy: no-link +development: + platforms: [] + commands: + setup: null + check: + runner: script + target: "tools/check.example" + test: null + build: null + dev: null + format_check: null + format_write: null + environment: + owner: none + template: null + secret_launcher: null + stacks: {} + host_requirements: [] +''' + + +def files(data=DATA): + return { + "project.yaml": FileSpec("100644", data), + "docs/status.example": FileSpec("100644", b"Synthetic status\n"), + "tools/check.example": FileSpec("100755", b"#!/bin/sh\nexit 0\n"), + } + + +class AcquisitionTests(unittest.TestCase): + def setUp(self): + self.owner = FixtureOwner(self) + + def fixture(self, entries=None, *, bare=False, object_format="sha1"): + return GitFixture(self.owner, bare=bare, object_format=object_format).seed( + files() if entries is None else entries) + + def observe(self, fixture, mode="rev", *, root=None, rev="HEAD", **kwargs): + return observe_repository(fixture.path if root is None else root, mode=mode, + rev=rev if mode == "rev" else None, + reference_date="2026-01-01", **kwargs) + + def accepted(self, result, mode): + self.assertEqual(result.exit_code, 0, result.diagnostics) + self.assertIsInstance(result.record, dict) + self.assertTrue(result.diagnostics["candidate_declaration_accepted"]) + self.assertEqual(result.diagnostics["complete_v1_validity"], "not_checked") + self.assertEqual(result.diagnostics["scope"], "candidate-git-observation") + self.assertEqual(result.diagnostics["mode"], mode) + self.assertEqual(result.diagnostics["reference_date"], "2026-01-01") + self.assertNotIn("record", result.diagnostics) + return result + + def rejected(self, result, code=None, *, not_checked=False): + self.assertEqual(result.exit_code, 2 if not_checked else 1, result.diagnostics) + self.assertIsNone(result.record) + self.assertFalse(result.diagnostics["candidate_declaration_accepted"]) + self.assertEqual(result.diagnostics["complete_v1_validity"], "not_checked") + if code is not None: + self.assertEqual(result.diagnostics["findings"][0]["code"], code) + return result + + def test_valid_rev_staged_and_worktree_have_actual_mode_provenance(self): + fixture = self.fixture() + for mode in ("rev", "staged", "worktree"): + with self.subTest(mode=mode): + result = self.accepted(self.observe(fixture, mode), mode) + self.assertEqual(result.diagnostics["manifest_sha256"], hashlib.sha256(DATA).hexdigest()) + self.assertRegex(result.diagnostics["observation_digest"], r"^[a-f0-9]{64}$") + if mode == "rev": + self.assertEqual(result.diagnostics["commit_oid"], fixture.head) + self.assertEqual(result.diagnostics["tree_oid"], fixture.tree) + self.assertEqual(result.diagnostics["manifest_blob_oid"], fixture.blobs["project.yaml"]) + elif mode == "staged": + self.assertIsNone(result.diagnostics["commit_oid"]) + self.assertIsNone(result.diagnostics["tree_oid"]) + self.assertEqual(result.diagnostics["manifest_blob_oid"], fixture.blobs["project.yaml"]) + else: + self.assertIsNone(result.diagnostics["commit_oid"]) + self.assertIsNone(result.diagnostics["tree_oid"]) + self.assertIsNone(result.diagnostics["manifest_blob_oid"]) + + def test_sha256_repository_has_qualified_object_ids_and_valid_observations(self): + fixture = self.fixture(object_format="sha256") + self.assertEqual(len(fixture.head), 64) + expected = hashlib.sha256(b"blob " + str(len(DATA)).encode() + b"\0" + DATA).hexdigest() + self.assertEqual(fixture.blobs["project.yaml"], expected) + for mode in ("rev", "staged", "worktree"): + with self.subTest(mode=mode): + result = self.accepted(self.observe(fixture, mode), mode) + self.assertEqual(result.record["project"]["id"], "sample-project") + self.assertIsNotNone(result.tree) + self.assertEqual(result.diagnostics["manifest_sha256"], hashlib.sha256(DATA).hexdigest()) + if mode == "rev": + self.assertEqual(result.diagnostics["commit_oid"], fixture.head) + self.assertEqual(result.diagnostics["tree_oid"], fixture.tree) + if mode != "worktree": + self.assertEqual(result.diagnostics["manifest_blob_oid"], expected) + + def test_pack_only_revision_and_index_read_descriptor_bound_pack_files(self): + for object_format in ("sha1", "sha256"): + with self.subTest(object_format=object_format): + fixture = self.fixture(object_format=object_format) + staged_data = DATA.replace(b"Synthetic example", b"Synthetic packed index") + fixture.set_index(files(staged_data)) + before = {mode: self.accepted(self.observe(fixture, mode), mode) + for mode in ("rev", "staged")} + self.assertEqual(before["rev"].record["project"]["display_name"], "Synthetic example") + self.assertEqual(before["staged"].record["project"]["display_name"], "Synthetic packed index") + pack, pack_index = fixture.pack_only() + self.assertTrue(pack.is_file()) + self.assertTrue(pack_index.is_file()) + for oid in fixture.owned_loose_objects: + self.assertFalse((fixture.git_dir / "objects" / oid[:2] / oid[2:]).exists()) + original = acquisition.bounded_process + pack_reads = [] + def inspected(argv, **kwargs): + pack_identity = {(path.stat().st_dev, path.stat().st_ino) + for path in (pack, pack_index)} + fds = kwargs["pass_fds"] + self.assertEqual({(os.fstat(fd).st_dev, os.fstat(fd).st_ino) for fd in fds}, + pack_identity) + mirror = Path(next(arg.split("=", 1)[1] for arg in argv if arg.startswith("--git-dir="))) + for source in (pack, pack_index): + projected = mirror / "objects/pack" / source.name + self.assertTrue(projected.is_symlink()) + self.assertIn(int(os.readlink(projected).removeprefix("/dev/fd/")), fds) + pack_reads.append(argv) + return original(argv, **kwargs) + with patch.object(acquisition, "bounded_process", side_effect=inspected): + for mode in ("rev", "staged"): + with self.subTest(mode=mode): + after = self.accepted(self.observe(fixture, mode), mode) + self.assertEqual(after.record, before[mode].record) + self.assertEqual(after.tree, before[mode].tree) + self.assertEqual(after.diagnostics["manifest_blob_oid"], + before[mode].diagnostics["manifest_blob_oid"]) + self.assertEqual(after.diagnostics["observation_digest"], + before[mode].diagnostics["observation_digest"]) + self.assertTrue(pack_reads) + + def test_many_loose_objects_do_not_consume_one_descriptor_per_object(self): + fixture = self.fixture() + for number in range(160): + fixture.blob(("Synthetic unreferenced object " + str(number) + "\n").encode()) + self.assertGreater(len(fixture.owned_loose_objects), 128) + original = acquisition.bounded_process + calls = [] + def inspected(argv, **kwargs): + calls.append(argv) + self.assertEqual(kwargs["pass_fds"], ()) + mirror = Path(next(arg.split("=", 1)[1] for arg in argv if arg.startswith("--git-dir="))) + for oid in fixture.owned_loose_objects: + copied = mirror / "objects" / oid[:2] / oid[2:] + self.assertTrue(copied.is_file()) + self.assertFalse(copied.is_symlink()) + return original(argv, **kwargs) + with patch.object(acquisition, "bounded_process", side_effect=inspected): + for mode in ("rev", "staged", "worktree"): + with self.subTest(mode=mode): + result = self.accepted(self.observe(fixture, mode), mode) + self.assertIsNotNone(result.tree) + self.assertTrue(calls) + + def test_file_obstructing_virtual_directory_is_never_read(self): + fixture = self.fixture() + (fixture.path / "docs/status.example").unlink() + (fixture.path / "docs").rmdir() + obstruction = fixture.path / "docs" + obstruction.write_bytes(b"synthetic-untracked-prefix-marker") + identity = obstruction.stat().st_dev, obstruction.stat().st_ino + original = acquisition._read_fd + def guarded(fd, *args, **kwargs): + info = os.fstat(fd) + if (info.st_dev, info.st_ino) == identity: + raise AssertionError("Untracked directory-prefix bytes were consumed") + return original(fd, *args, **kwargs) + with patch.object(acquisition, "_read_fd", side_effect=guarded): + result = self.rejected(self.observe(fixture, "worktree"), "REFERENCE_PATH") + self.assertNotIn("synthetic-untracked-prefix-marker", json.dumps(result.diagnostics)) + + def test_unqualified_index_storage_is_refused_without_blocking_revision(self): + for variant in ("version4", "split", "skip"): + with self.subTest(variant=variant): + fixture = self.fixture() + if variant == "version4": + fixture._run(["update-index", "--index-version=4"]) + elif variant == "split": + fixture._run(["update-index", "--split-index"]) + else: + fixture._run(["update-index", "--skip-worktree", "--", "docs/status.example"]) + self.accepted(self.observe(fixture), "rev") + for mode in ("staged", "worktree"): + with self.subTest(mode=mode): + self.rejected(self.observe(fixture, mode), "NOT_CHECKED", not_checked=True) + + def test_source_selection_distinguishes_object_index_and_working_bytes(self): + fixture = self.fixture() + index_data = DATA.replace(b"Synthetic example", b"Synthetic index") + working_data = DATA.replace(b"Synthetic example", b"Synthetic working") + fixture.set_index(files(index_data)) + (fixture.path / "project.yaml").write_bytes(working_data) + for mode, name, data in (("rev", "Synthetic example", DATA), + ("staged", "Synthetic index", index_data), + ("worktree", "Synthetic working", working_data)): + with self.subTest(mode=mode): + result = self.accepted(self.observe(fixture, mode), mode) + self.assertEqual(result.record["project"]["display_name"], name) + self.assertEqual(result.diagnostics["manifest_sha256"], hashlib.sha256(data).hexdigest()) + + def test_replay_is_stable_and_changed_working_bytes_change_digest(self): + fixture = self.fixture() + for mode in ("rev", "staged", "worktree"): + with self.subTest(mode=mode): + self.assertEqual(self.observe(fixture, mode), self.observe(fixture, mode)) + first = self.accepted(self.observe(fixture, "worktree"), "worktree") + (fixture.path / "docs/status.example").write_bytes(b"Synthetic changed status\n") + second = self.accepted(self.observe(fixture, "worktree"), "worktree") + self.assertNotEqual(first.diagnostics["observation_digest"], second.diagnostics["observation_digest"]) + self.assertEqual(first.diagnostics["manifest_sha256"], second.diagnostics["manifest_sha256"]) + + def test_untracked_files_and_global_excludes_do_not_join_observation(self): + fixture = self.fixture() + before = self.accepted(self.observe(fixture, "worktree"), "worktree") + (fixture.path / "synthetic-untracked-marker").write_bytes(b"Untracked synthetic bytes\n") + config_home = fixture.root / "synthetic-xdg" + (config_home / "git").mkdir(parents=True) + excludes = fixture.root / "synthetic-global-ignore" + excludes.write_text("project.yaml\ndocs/**\ntools/**\n") + (config_home / "git/config").write_text("[core]\nexcludesFile = " + str(excludes) + "\n") + home_before = os.environ.get("HOME") + with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}): + after = self.accepted(self.observe(fixture, "worktree"), "worktree") + self.assertEqual(os.environ.get("HOME"), home_before) + self.assertEqual(before.diagnostics["observation_digest"], after.diagnostics["observation_digest"]) + + def test_missing_wrong_case_nonexecutable_symlink_and_gitlink_references(self): + changes = ( + ("missing", "REFERENCE_MISSING"), ("wrong-case", "REFERENCE_MISSING"), + ("nonexecutable", "REFERENCE_MODE"), ("symlink", "REFERENCE_PATH"), + ("gitlink", "REFERENCE_PATH"), + ) + for kind, code in changes: + for mode in ("rev", "staged", "worktree"): + with self.subTest(kind=kind, mode=mode): + entries = files() + if kind == "missing": + entries.pop("docs/status.example") + elif kind == "wrong-case": + entries["docs/Status.example"] = entries.pop("docs/status.example") + elif kind == "nonexecutable": + entries["tools/check.example"] = FileSpec("100644", b"Synthetic script\n") + elif kind == "symlink": + entries["tools/check.example"] = FileSpec("120000", b"../synthetic-target") + else: + fixture = self.fixture() + entries["tools/check.example"] = FileSpec("160000", oid=fixture.head) + fixture.commit_files(entries) + fixture.set_index(entries) + (fixture.path / "tools/check.example").unlink() + (fixture.path / "tools/check.example").mkdir() + self.rejected(self.observe(fixture, mode), code) + continue + fixture = self.fixture(entries) + self.rejected(self.observe(fixture, mode), code) + + def test_working_mode_is_measured_instead_of_taken_from_index(self): + fixture = self.fixture() + (fixture.path / "tools/check.example").chmod(0o644) + self.accepted(self.observe(fixture, "staged"), "staged") + self.rejected(self.observe(fixture, "worktree"), "REFERENCE_MODE") + + def test_case_only_working_rename_is_missing_even_on_case_insensitive_host(self): + fixture = self.fixture() + status = fixture.path / "docs/status.example" + status.rename(status.with_name("Status.example")) + self.assertIn("Status.example", os.listdir(fixture.path / "docs")) + self.rejected(self.observe(fixture, "worktree"), "REFERENCE_MISSING") + + def test_missing_or_nonregular_manifest_is_rejected_without_following(self): + entries = files() + entries.pop("project.yaml") + fixture = self.fixture(entries) + for mode in ("rev", "staged", "worktree"): + with self.subTest(mode=mode): + self.rejected(self.observe(fixture, mode), "KEY_REQUIRED") + entries = files() + entries["project.yaml"] = FileSpec("120000", b"synthetic-target") + fixture = self.fixture(entries) + (fixture.path / "synthetic-target").write_bytes(DATA) + for mode in ("rev", "staged", "worktree"): + with self.subTest(mode=mode): + self.rejected(self.observe(fixture, mode)) + + def test_bare_root_supports_rev_and_refuses_index_or_worktree(self): + fixture = self.fixture(bare=True) + self.accepted(self.observe(fixture), "rev") + for mode in ("staged", "worktree"): + with self.subTest(mode=mode): + self.rejected(self.observe(fixture, mode), "NOT_CHECKED", not_checked=True) + + def test_linked_worktree_requires_explicit_common_storage(self): + fixture = self.fixture() + linked, _ = fixture.linked() + self.rejected(self.observe(fixture, root=linked), "NOT_CHECKED", not_checked=True) + for mode in ("rev", "staged", "worktree"): + with self.subTest(mode=mode): + self.accepted(self.observe(fixture, mode, root=linked, common_dir=fixture.git_dir), mode) + + def test_local_external_conditional_and_worktree_includes_are_refused_before_git(self): + for section in ("include", 'includeIf "gitdir:synthetic/"'): + for external in (False, True): + with self.subTest(section=section, external=external): + fixture = self.fixture() + target = fixture.root / "synthetic-external-config" if external else fixture.git_dir / "synthetic-local-config" + target.write_text("[core]\nsynthetic = synthetic-include-marker\n") + fixture.append_config("[" + section + "]\npath = " + str(target) + "\n") + with patch.object(acquisition, "bounded_process", side_effect=AssertionError("Git ran before include guard")): + self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + fixture = self.fixture() + linked, admin = fixture.linked() + fixture.append_config("[extensions]\nworktreeConfig = true\n") + target = fixture.root / "synthetic-worktree-include" + target.write_text("[core]\nsynthetic = synthetic-worktree-marker\n") + (admin / "config.worktree").write_text("[include]\npath = " + str(target) + "\n") + with patch.object(acquisition, "bounded_process", side_effect=AssertionError("Git ran before worktree include guard")): + self.rejected(self.observe(fixture, root=linked, common_dir=fixture.git_dir), "NOT_CHECKED", not_checked=True) + + def test_include_target_is_not_opened(self): + fixture = self.fixture() + target = fixture.root / "synthetic-never-open-config" + target.write_bytes(b"synthetic-private-value-marker\n") + fixture.append_config('[include]\npath = "' + str(target) + '"\n') + real_open = os.open + def guarded_open(path, *args, **kwargs): + if os.fspath(path) in {str(target), target.name}: + raise AssertionError("Adapter opened an include target") + return real_open(path, *args, **kwargs) + with patch.object(acquisition.os, "open", side_effect=guarded_open): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertNotIn("synthetic-private-value-marker", json.dumps(result.diagnostics)) + + def test_layout_redirects_are_refused_before_git(self): + fixture = self.fixture() + target = fixture.root / "synthetic-outside-worktree" + fixture.append_config('[core]\nworktree = "' + str(target) + '"\n') + with patch.object(acquisition, "bounded_process", side_effect=AssertionError("Git ran before redirect guard")): + self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + fixture = self.fixture() + linked, admin = fixture.linked() + (admin / "commondir").write_text(str(fixture.root / "synthetic-outside-common") + "\n") + with patch.object(acquisition, "bounded_process", side_effect=AssertionError("Git ran before common-dir guard")): + self.rejected(self.observe(fixture, root=linked, common_dir=fixture.git_dir), "NOT_CHECKED", not_checked=True) + + def test_hooks_fsmonitor_filters_textconv_aliases_and_credentials_never_execute(self): + entries = files() + entries[".gitattributes"] = FileSpec("100644", b"* filter=synthetic diff=synthetic\n") + fixture = self.fixture(entries) + marker, command = fixture.sentinel() + hooks = fixture.root / "synthetic-hooks" + hooks.mkdir() + for hook in ("post-checkout", "pre-commit", "post-index-change", "reference-transaction"): + (hooks / hook).symlink_to(command) + fixture.append_config('[core]\nhooksPath = "' + str(hooks) + '"\nfsmonitor = "' + str(command) + + '"\n[filter "synthetic"]\nclean = "' + str(command) + '"\nsmudge = "' + str(command) + + '"\nrequired = true\n[diff "synthetic"]\ntextconv = "' + str(command) + + '"\ncommand = "' + str(command) + '"\n[alias]\nstatus = !' + str(command) + + '\n[credential]\nhelper = !' + str(command) + '\n') + for mode in ("rev", "staged", "worktree"): + with self.subTest(mode=mode): + self.accepted(self.observe(fixture, mode), mode) + self.assertFalse(marker.exists(), "Synthetic execution sentinel was invoked") + + def test_git_child_has_sanitized_config_and_descriptor_bound_objects(self): + fixture = self.fixture() + fixture.append_config("[alias]\nsynthetic = synthetic-raw-config-marker\n") + original = acquisition.bounded_process + calls = [] + home = os.environ.get("HOME") + def inspected(argv, **kwargs): + calls.append(argv) + self.assertEqual(kwargs["env"].get("HOME"), home) + self.assertEqual(kwargs["env"]["GIT_CONFIG_GLOBAL"], os.devnull) + self.assertEqual(kwargs["env"]["GIT_CONFIG_NOSYSTEM"], "1") + self.assertEqual(kwargs["env"]["GIT_NO_LAZY_FETCH"], "1") + self.assertEqual(kwargs["env"]["GIT_NO_REPLACE_OBJECTS"], "1") + self.assertIn("--no-lazy-fetch", argv) + self.assertIn("--no-replace-objects", argv) + mirror = Path(next(arg.split("=", 1)[1] for arg in argv if arg.startswith("--git-dir="))) + self.assertNotIn("synthetic-raw-config-marker", (mirror / "config").read_text()) + bound = set(kwargs["pass_fds"]) + for fd in bound: + self.assertTrue(stat.S_ISREG(os.fstat(fd).st_mode)) + self.assertEqual(fcntl.fcntl(fd, fcntl.F_GETFL) & os.O_ACCMODE, os.O_RDONLY) + for path in (mirror / "objects").rglob("*"): + if path.is_symlink(): + self.assertIn(int(os.readlink(path).removeprefix("/dev/fd/")), bound) + elif path.is_file(): + relative = path.relative_to(mirror) + self.assertEqual(path.read_bytes(), (fixture.git_dir / relative).read_bytes()) + else: + self.assertTrue(path.is_dir()) + return original(argv, **kwargs) + with patch.object(acquisition, "bounded_process", side_effect=inspected): + self.accepted(self.observe(fixture), "rev") + self.assertGreater(len(calls), 1) + + def test_replacement_refs_do_not_change_requested_commit(self): + fixture = self.fixture() + requested = fixture.head + replacement = fixture.commit_files(files(DATA.replace(b"Synthetic example", b"Synthetic replacement")), update_head=False) + fixture._run(["update-ref", "refs/replace/" + requested, replacement]) + result = self.accepted(self.observe(fixture, rev=requested), "rev") + self.assertEqual(result.record["project"]["display_name"], "Synthetic example") + self.assertEqual(result.diagnostics["commit_oid"], requested) + + def test_missing_promisor_object_does_not_trigger_lazy_fetch(self): + fixture = self.fixture() + missing = fixture.blobs["docs/status.example"] + (fixture.git_dir / "objects" / missing[:2] / missing[2:]).unlink() + marker, command = fixture.sentinel() + helper = fixture.root / "git-remote-synthetic" + helper.symlink_to(command) + fixture.append_config('[extensions]\npartialClone = origin\n[remote "origin"]\n' + 'promisor = true\npartialCloneFilter = blob:none\n' + 'url = synthetic::synthetic-remote-marker\n') + with patch.dict(os.environ, {"PATH": str(fixture.root) + os.pathsep + os.environ.get("PATH", "")}): + self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertFalse(marker.exists(), "Synthetic remote helper was invoked") + + def test_object_alternates_are_refused_before_git(self): + for name in ("alternates", "http-alternates"): + with self.subTest(name=name): + fixture = self.fixture() + target = fixture.root / "synthetic-alternate-object-store" + target.mkdir() + info = fixture.git_dir / "objects/info" + info.mkdir(exist_ok=True) + (info / name).write_text(str(target) + "\n") + with patch.object(acquisition, "bounded_process", side_effect=AssertionError("Git ran before alternates guard")): + self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + + def test_unmerged_and_intent_to_add_index_are_not_checked(self): + for kind in ("unmerged", "intent"): + with self.subTest(kind=kind): + fixture = self.fixture() + if kind == "unmerged": + fixture.unmerged_index("docs/status.example") + else: + fixture.intent_index("synthetic-pending") + for mode in ("staged", "worktree"): + with self.subTest(mode=mode): + self.rejected(self.observe(fixture, mode), "NOT_CHECKED", not_checked=True) + + def test_source_config_index_and_ref_mutations_are_not_checked(self): + for kind in ("config", "index", "ref"): + with self.subTest(kind=kind): + fixture = self.fixture() + original = acquisition.GitView.run + mutated = False + def mutate(view, args, *call_args, **kwargs): + nonlocal mutated + output = original(view, args, *call_args, **kwargs) + if not mutated and args[0] in {"ls-tree", "ls-files"}: + mutated = True + if kind == "config": + fixture.append_config("# synthetic-source-mutation\n") + elif kind == "index": + with (fixture.git_dir / "index").open("ab") as stream: + stream.write(b"synthetic-index-mutation") + else: + (fixture.git_dir / "refs/heads/synthetic").write_text("0" * len(fixture.head) + "\n") + return output + mode = "staged" if kind == "index" else "rev" + with patch.object(acquisition.GitView, "run", new=mutate): + self.rejected(self.observe(fixture, mode), "NOT_CHECKED", not_checked=True) + self.assertTrue(mutated) + + def test_working_byte_and_mode_mutations_are_not_checked(self): + for kind in ("bytes", "mode"): + with self.subTest(kind=kind): + fixture = self.fixture() + original = acquisition._working + calls = 0 + def mutate(*args, **kwargs): + nonlocal calls + output = original(*args, **kwargs) + calls += 1 + if calls == 1: + if kind == "bytes": + (fixture.path / "project.yaml").write_bytes(DATA.replace(b"Synthetic example", b"Synthetic mutation")) + else: + (fixture.path / "tools/check.example").chmod(0o644) + return output + with patch.object(acquisition, "_working", side_effect=mutate): + self.rejected(self.observe(fixture, "worktree"), "NOT_CHECKED", not_checked=True) + self.assertGreaterEqual(calls, 2) + + def test_added_ref_or_object_during_observation_is_not_checked(self): + for kind in ("ref", "object"): + with self.subTest(kind=kind): + fixture = self.fixture() + original = acquisition.GitView.run + mutated = False + def mutate(view, args, *call_args, **kwargs): + nonlocal mutated + output = original(view, args, *call_args, **kwargs) + if not mutated and args[0] == "ls-tree": + mutated = True + if kind == "ref": + (fixture.git_dir / "refs/heads/synthetic-added").write_text(fixture.head + "\n") + else: + payload = b"blob 23\0Synthetic added object\n" + oid = hashlib.sha1(payload).hexdigest() + directory = fixture.git_dir / "objects" / oid[:2] + directory.mkdir(exist_ok=True) + (directory / oid[2:]).write_bytes(zlib.compress(payload)) + return output + with patch.object(acquisition.GitView, "run", new=mutate): + self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertTrue(mutated) + + def test_replaced_object_path_does_not_redirect_descriptor_bound_read(self): + fixture = self.fixture() + oid = fixture.blobs["project.yaml"] + object_path = fixture.git_dir / "objects" / oid[:2] / oid[2:] + target = fixture.root / "synthetic-never-open-object" + target.write_bytes(b"synthetic-object-redirect-marker") + original = acquisition.GitView.run + real_open = os.open + replaced = False + def mutate(view, args, *call_args, **kwargs): + nonlocal replaced + if not replaced and args[0] == "ls-tree": + replaced = True + object_path.unlink() + object_path.symlink_to(target) + return original(view, args, *call_args, **kwargs) + def guarded_open(path, *args, **kwargs): + if os.fspath(path) in {str(target), target.name}: + raise AssertionError("Adapter opened redirected object target") + return real_open(path, *args, **kwargs) + with patch.object(acquisition.GitView, "run", new=mutate): + with patch.object(acquisition.os, "open", side_effect=guarded_open): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertTrue(replaced) + self.assertNotIn("synthetic-object-redirect-marker", json.dumps(result.diagnostics)) + + def test_child_deadline_kills_only_the_owned_child_and_returns_not_checked(self): + fixture = self.fixture() + shim = fixture.root / "synthetic-git-timeout" + shim.write_text("#!/bin/sh\nexec /bin/sleep 30\n") + shim.chmod(0o755) + real_popen = acquisition.subprocess.Popen + real_killpg = acquisition.os.killpg + children, group_signals = [], [] + def captured_popen(*args, **kwargs): + child = real_popen(*args, **kwargs) + children.append((child, kwargs.get("start_new_session"))) + return child + def captured_killpg(group, sent_signal): + group_signals.append((group, sent_signal)) + return real_killpg(group, sent_signal) + # Capture only observation children, after all fixture plumbing finished. + with patch.object(acquisition.shutil, "which", return_value=str(shim)): + with patch.object(acquisition.subprocess, "Popen", side_effect=captured_popen): + with patch.object(acquisition.os, "killpg", side_effect=captured_killpg): + with patch.object(acquisition, "CALL_SECONDS", 0.5): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertEqual(result.diagnostics["acquisition_reason"], "child_timeout") + self.assertEqual(len(children), 1) + child, own_session = children[0] + self.assertIs(own_session, True) + self.assertEqual(group_signals, [(child.pid, signal.SIGKILL)]) + self.assertEqual(child.returncode, -signal.SIGKILL) + with self.assertRaises(ChildProcessError): + os.waitpid(child.pid, os.WNOHANG) + with self.assertRaises(ProcessLookupError): + os.kill(child.pid, 0) + with self.assertRaises(ProcessLookupError): + os.killpg(child.pid, 0) + + def test_child_output_bound_discards_raw_stdout_and_stderr(self): + fixture = self.fixture() + shim = fixture.root / "synthetic-git-output" + marker = "synthetic-child-output-marker" + shim.write_text("#!/bin/sh\nwhile :; do printf " + shlex.quote(marker) + + "; printf " + shlex.quote(marker) + " >&2; done\n") + shim.chmod(0o755) + original = acquisition.bounded_process + def short_bound(argv, **kwargs): + kwargs["limit"] = 2048 + kwargs["seconds"] = min(kwargs["seconds"], 1.0) + return original(argv, **kwargs) + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + with patch.object(acquisition.shutil, "which", return_value=str(shim)): + with patch.object(acquisition, "bounded_process", side_effect=short_bound): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertEqual(result.diagnostics["acquisition_reason"], "child_output_bound") + self.assertNotIn(marker, json.dumps(result.diagnostics)) + self.assertEqual(stdout.getvalue() + stderr.getvalue(), "") + + def test_public_bounds_fail_closed(self): + limits = (("MAX_METADATA", 1, "rev"), ("MAX_INDEX", 1, "staged"), + ("MAX_METADATA_TOTAL", 1, "rev"), ("MAX_DIRECTORY_NAMES", 1, "rev"), + ("MAX_OBJECT_STORAGE", 1, "rev"), ("MAX_OBJECT_FDS", 1, "rev"), + ("MAX_WORKING_FILE", 1, "worktree"), ("MAX_WORKING_BYTES", 1, "worktree"), + ("MAX_ENTRIES", 1, "rev"), ("MAX_PATH_BYTES", 8, "rev"), + ("MAX_PATH_DEPTH", 1, "rev")) + fixture = self.fixture() + packed_fixture = self.fixture() + packed_fixture.pack_only() + for name, limit, mode in limits + (("MAX_LOOSE_OBJECTS", 1, "rev"), + ("MAX_LOOSE_FILE", 1, "rev"), + ("MAX_LOOSE_BYTES", 1, "rev")): + with self.subTest(bound=name): + selected = packed_fixture if name == "MAX_OBJECT_FDS" else fixture + with patch.object(acquisition, name, limit): + self.rejected(self.observe(selected, mode), "NOT_CHECKED", not_checked=True) + + def test_deep_repository_paths_are_not_checked(self): + entries = files() + entries["/".join(["synthetic"] * 17)] = FileSpec("100644", b"Synthetic depth\n") + fixture = self.fixture(entries) + for mode in ("rev", "staged", "worktree"): + with self.subTest(mode=mode): + self.rejected(self.observe(fixture, mode), "NOT_CHECKED", not_checked=True) + + def test_unsupported_git_is_not_checked(self): + fixture = self.fixture() + with patch.object(acquisition, "bounded_process", return_value=(0, b"git version 2.55.9\n", b"")): + self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + + def test_supported_config_booleans_with_comments_match_git(self): + variants = ("true # comment", "yes ; comment", '"on" # comment', "1", None) + for raw in variants: + with self.subTest(raw=raw): + fixture = self.fixture(bare=True) + fixture.append_config("[core]\nbare" + ("\n" if raw is None else " = " + raw + "\n")) + self.assertEqual(fixture._run(["config", "--bool", "--get", "core.bare"]).strip(), b"true") + self.accepted(self.observe(fixture), "rev") + for raw in ("false # comment", "no ; comment", '"off" # comment', "0", ""): + with self.subTest(raw=raw): + fixture = self.fixture() + fixture.append_config("[core]\nbare = " + raw + "\n") + self.assertEqual(fixture._run(["config", "--bool", "--get", "core.bare"]).strip(), b"false") + self.accepted(self.observe(fixture), "rev") + + def test_worktree_boolean_comments_cannot_skip_include_guard(self): + for raw in ("true # comment", '"yes" ; comment', "on", "1"): + with self.subTest(raw=raw): + fixture = self.fixture() + fixture.append_config("[core]\nrepositoryformatversion = 1\n[extensions]\nworktreeConfig = " + raw + "\n") + self.assertEqual(fixture._run(["config", "--no-includes", "--file", str(fixture.git_dir / "config"), + "--bool", "--get", "extensions.worktreeConfig"]).strip(), b"true") + outside = fixture.root / "synthetic-comment-include-target" + outside.write_text("[core]\nsynthetic = synthetic-comment-marker\n") + (fixture.git_dir / "config.worktree").write_text("[include]\npath = " + str(outside) + "\n") + with patch.object(acquisition, "bounded_process", + side_effect=AssertionError("Git ran before worktree include guard")): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertEqual(result.diagnostics["acquisition_reason"], "outside_include") + + def test_unknown_or_ambiguous_config_booleans_are_not_defaulted_false(self): + for key in ("bare", "worktreeConfig"): + for raw in ("synthetic-ambiguous-marker", '"true # quoted marker"', '"true" trailing', '"true "'): + with self.subTest(key=key, raw=raw): + fixture = self.fixture() + section = "core" if key == "bare" else "extensions" + fixture.append_config("[" + section + "]\n" + key + " = " + raw + "\n") + with patch.object(acquisition, "bounded_process", + side_effect=AssertionError("Git ran before invalid boolean guard")): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertNotIn("synthetic-ambiguous-marker", json.dumps(result.diagnostics)) + + def test_quoted_and_dotted_core_subsections_cannot_mask_top_level_bare(self): + for section in ('core "synthetic-subsection"', "core.synthetic-subsection"): + with self.subTest(section=section): + fixture = self.fixture() + fixture.append_config("[core]\nbare = true\n[" + section + "]\nbare = false\n") + self.assertEqual(fixture._run(["config", "--bool", "--get", "core.bare"]).strip(), b"true") + with patch.object(acquisition, "bounded_process", + side_effect=AssertionError("Git ran before storage namespace guard")): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertEqual(result.diagnostics["acquisition_reason"], "unsupported_storage_subsection") + + def test_quoted_and_dotted_extensions_cannot_mask_worktree_include_guard(self): + for section in ('extensions "synthetic-subsection"', "extensions.synthetic-subsection"): + with self.subTest(section=section): + fixture = self.fixture() + fixture.append_config("[core]\nrepositoryformatversion = 1\n" + "[extensions]\nworktreeConfig = true\n[" + section + "]\nworktreeConfig = false\n") + self.assertEqual(fixture._run(["config", "--no-includes", "--file", str(fixture.git_dir / "config"), + "--bool", "--get", "extensions.worktreeConfig"]).strip(), b"true") + target = fixture.root / "synthetic-subsection-include-target" + target.write_text("[core]\nsynthetic = synthetic-subsection-marker\n") + (fixture.git_dir / "config.worktree").write_text("[include]\npath = " + str(target) + "\n") + real_open = os.open + def guarded(path, *args, **kwargs): + if os.fspath(path) in {str(target), target.name}: + raise AssertionError("External include target was opened") + return real_open(path, *args, **kwargs) + with patch.object(acquisition.os, "open", side_effect=guarded): + with patch.object(acquisition, "bounded_process", + side_effect=AssertionError("Git ran before storage namespace guard")): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertEqual(result.diagnostics["acquisition_reason"], "unsupported_storage_subsection") + self.assertNotIn("synthetic-subsection-marker", json.dumps(result.diagnostics)) + + def test_all_storage_keys_refuse_subsections_in_common_and_worktree_config(self): + keys = (("core", "bare", "false"), ("core", "repositoryformatversion", "0"), + ("core", "worktree", "synthetic-path"), + ("extensions", "objectformat", "sha1"), + ("extensions", "worktreeConfig", "false"), + ("extensions", "refstorage", "files"), + ("extensions", "relativeworktrees", "false")) + for location in ("common", "worktree"): + for style in ("quoted", "dotted"): + for base_section, key, value in keys: + with self.subTest(location=location, style=style, key=key): + fixture = self.fixture() + if location == "worktree": + fixture.append_config("[core]\nrepositoryformatversion = 1\n" + "[extensions]\nworktreeConfig = true\n") + section = (base_section + ' "synthetic-subsection"' if style == "quoted" + else base_section + ".synthetic-subsection") + text = "[" + section + "]\n" + key + " = " + value + "\n" + if location == "common": + fixture.append_config(text) + else: + (fixture.git_dir / "config.worktree").write_text(text) + with patch.object(acquisition, "bounded_process", + side_effect=AssertionError("Git ran before storage namespace guard")): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertEqual(result.diagnostics["acquisition_reason"], "unsupported_storage_subsection") + + def test_control_and_unicode_separators_cannot_invent_storage_lines(self): + for separator in ("\u0085", "\u2028", "\u2029", "\x0b", "\x0c", "\r"): + with self.subTest(codepoint=ord(separator)): + fixture = self.fixture() + fixture.append_config("[core]\nbare=true\n# ignored " + separator + + "[core]" + separator + "bare=false\n") + native = fixture._run(["config", "--no-includes", "--file", + str(fixture.git_dir / "config"), "--bool", "--get", "core.bare"]) + self.assertEqual(native.strip(), b"true") + with patch.object(acquisition, "bounded_process", + side_effect=AssertionError("Git ran before config line guard")): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertEqual(result.diagnostics["acquisition_reason"], "unsupported_config_line_characters") + + def test_non_ascii_syntax_padding_is_refused_before_any_git(self): + for padding in ("\u00a0", "\u2003", "\u202f"): + for position in ("section", "key", "assignment", "scalar"): + with self.subTest(codepoint=ord(padding), position=position): + fixture = self.fixture() + if position == "section": + text = padding + "[core]\nbare=false\n" + elif position == "key": + text = "[core]\n" + padding + "bare=false\n" + elif position == "assignment": + text = "[core]\nbare" + padding + "=" + padding + "false\n" + else: + text = "[core]\nbare=" + padding + "false" + padding + "\n" + fixture.append_config(text) + query = subprocess.run([fixture.executable, "config", "--no-includes", "--file", + str(fixture.git_dir / "config"), "--bool", "--get", "core.bare"], + cwd=fixture.root, env=fixture.env, capture_output=True, check=False, timeout=5) + self.assertEqual(query.returncode, 128) + with patch.object(acquisition, "bounded_process", + side_effect=AssertionError("Git ran before config syntax guard")): + self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + + def test_other_config_controls_are_explicitly_unsupported(self): + for separator in ("\x00", "\x07", "\x1c", "\x1d", "\x1e", "\x7f"): + with self.subTest(codepoint=ord(separator)): + fixture = self.fixture() + fixture.append_config("# ignored " + separator + "synthetic\n") + with patch.object(acquisition, "bounded_process", + side_effect=AssertionError("Git ran before config control guard")): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertEqual(result.diagnostics["acquisition_reason"], "unsupported_config_line_characters") + + def test_supported_lf_crlf_and_unicode_comments_keep_git_meaning(self): + for newline in ("\n", "\r\n"): + with self.subTest(newline_length=len(newline)): + fixture = self.fixture() + path = fixture.git_dir / "config" + original = path.read_text() + text = original + "# Synthetic café\n[user]\nname = Synthetic café\n" + path.write_bytes(text.replace("\n", newline).encode("utf-8")) + self.assertEqual(fixture._run(["config", "--no-includes", "--file", str(path), + "--bool", "--get", "core.bare"]).strip(), b"false") + self.accepted(self.observe(fixture), "rev") + + def test_ignored_continuations_cannot_fabricate_core_storage_sections(self): + for section in ("user", "synthetic", 'remote "synthetic"', "remote.synthetic"): + with self.subTest(section=section): + fixture = self.fixture() + fixture.append_config("[core]\nbare=true\n[" + section + "]\n" + "name=synthetic" + chr(92) + "\n[core]\nbare=false\n") + query = fixture._run(["config", "--no-includes", "--file", + str(fixture.git_dir / "config"), "--bool", "--get", "core.bare"]) + self.assertEqual(query.strip(), b"true") + with patch.object(acquisition, "bounded_process", + side_effect=AssertionError("Git ran before ignored-value lexical guard")): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertEqual(result.diagnostics["acquisition_reason"], "unsupported_config_scalar") + + def test_ignored_continuation_cannot_mask_worktree_config_include(self): + for section in ("user", 'remote "synthetic"', "remote.synthetic"): + with self.subTest(section=section): + fixture = self.fixture() + fixture.append_config("[core]\nrepositoryformatversion=1\n" + "[extensions]\nworktreeConfig=true\n[" + section + "]\n" + "name=synthetic" + chr(92) + "\n[extensions]\nworktreeConfig=false\n") + self.assertEqual(fixture._run(["config", "--no-includes", "--file", + str(fixture.git_dir / "config"), "--bool", "--get", + "extensions.worktreeConfig"]).strip(), b"true") + target = fixture.root / "synthetic-continuation-include-target" + target.write_text("[core]\nsynthetic = synthetic-continuation-marker\n") + (fixture.git_dir / "config.worktree").write_text("[include]\npath=" + str(target) + "\n") + real_open = os.open + def guarded(path, *args, **kwargs): + if os.fspath(path) in {str(target), target.name}: + raise AssertionError("Continuation-masked include target opened") + return real_open(path, *args, **kwargs) + with patch.object(acquisition.os, "open", side_effect=guarded): + with patch.object(acquisition, "bounded_process", + side_effect=AssertionError("Git ran before ignored-value lexical guard")): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertEqual(result.diagnostics["acquisition_reason"], "unsupported_config_scalar") + + def test_ignored_escaped_and_multiline_quoted_values_are_refused_before_git(self): + backslash = chr(92) + values = ("synthetic" + backslash + backslash + "tail", + '"synthetic' + backslash + 'nvalue"', + '"synthetic' + backslash + '"value"', + '"synthetic" "pieces"', '"synthetic"tail', + '"synthetic\nnext"', '"synthetic' + backslash + '\nnext"', + '"synthetic') + for location in ("common", "worktree"): + for section in ("user", 'remote "synthetic"', "remote.synthetic"): + for value in values: + with self.subTest(location=location, section=section, value_length=len(value)): + fixture = self.fixture() + if location == "worktree": + fixture.append_config("[core]\nrepositoryformatversion=1\n" + "[extensions]\nworktreeConfig=true\n") + text = "[" + section + "]\nname=" + value + "\n" + if location == "common": + fixture.append_config(text) + else: + (fixture.git_dir / "config.worktree").write_text(text) + with patch.object(acquisition, "bounded_process", + side_effect=AssertionError("Git ran before ignored-value lexical guard")): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertEqual(result.diagnostics["acquisition_reason"], "unsupported_config_scalar") + + def test_simple_ignored_config_values_comments_and_quoted_spaces_remain_supported(self): + fixture = self.fixture() + fixture.append_config('[user]\nname=" Synthetic café " # comment\n' + '[remote "synthetic"]\nurl=synthetic-value ; comment\n' + 'description="Synthetic # data ; kept"\n' + 'enabled\n# A comment may contain ' + chr(92) + '\n') + self.assertEqual(fixture._run(["config", "--no-includes", "--file", + str(fixture.git_dir / "config"), "--get", "user.name"]).rstrip(b"\n"), + " Synthetic café ".encode()) + self.assertEqual(fixture._run(["config", "--no-includes", "--file", + str(fixture.git_dir / "config"), "--bool", "--get", "core.bare"]).strip(), b"false") + self.accepted(self.observe(fixture), "rev") + + def test_unknown_extensions_are_refused_before_git_in_both_versions(self): + for version in (0, 1): + fixture = self.fixture() + fixture.append_config("[core]\nrepositoryformatversion=" + str(version) + + "\n[extensions]\nsyntheticUnsupported=true\n") + query = subprocess.run([fixture.executable, "--git-dir=" + str(fixture.git_dir), + "rev-parse", "--show-object-format"], cwd=fixture.root, env=fixture.env, + capture_output=True, check=False, timeout=5) + self.assertEqual(query.returncode, 0 if version == 0 else 128) + with patch.object(acquisition, "bounded_process", + side_effect=AssertionError("Git ran before extension allowlist")): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertEqual(result.diagnostics["acquisition_reason"], "unsupported_repository_extension") + + def test_objectformat_requires_version_one_for_both_supported_algorithms(self): + for algorithm in ("sha1", "sha256"): + fixture = GitFixture(self.owner, object_format=algorithm).seed(files()) + for version in (0, 1): + with self.subTest(algorithm=algorithm, version=version): + fixture.append_config("[core]\nrepositoryformatversion=" + str(version) + + "\n[extensions]\nobjectFormat=" + algorithm + "\n") + query = subprocess.run([fixture.executable, "--git-dir=" + str(fixture.git_dir), + "rev-parse", "--show-object-format"], cwd=fixture.root, env=fixture.env, + capture_output=True, check=False, timeout=5) + if version == 0: + self.assertEqual(query.returncode, 128) + with patch.object(acquisition, "bounded_process", + side_effect=AssertionError("Git ran before extension version guard")): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertEqual(result.diagnostics["acquisition_reason"], "extension_repository_version") + else: + self.assertEqual(query.returncode, 0) + self.assertEqual(query.stdout.strip().decode(), algorithm) + self.accepted(self.observe(fixture), "rev") + + def test_worktree_config_true_false_behavior_is_qualified_in_both_versions(self): + for version in (0, 1): + for enabled in (False, True): + with self.subTest(version=version, enabled=enabled): + fixture = self.fixture() + flag = "true" if enabled else "false" + fixture.append_config("[core]\nrepositoryformatversion=" + str(version) + + "\n[extensions]\nworktreeConfig=" + flag + "\n") + local = fixture.git_dir / "config.worktree" + local.write_text("[core]\nsyntheticWorktreeProbe=present\n") + query = subprocess.run([fixture.executable, "--git-dir=" + str(fixture.git_dir), + "config", "--get", "core.syntheticWorktreeProbe"], cwd=fixture.root, + env=fixture.env, capture_output=True, check=False, timeout=5) + self.assertEqual(query.returncode, 0 if enabled else 1) + if enabled: + self.assertEqual(query.stdout.strip(), b"present") + self.accepted(self.observe(fixture), "rev") + else: + real_open = os.open + def guarded(path, *args, **kwargs): + if os.fspath(path) in {str(local), local.name}: + raise AssertionError("Disabled worktree config opened") + return real_open(path, *args, **kwargs) + with patch.object(acquisition.os, "open", side_effect=guarded): + self.accepted(self.observe(fixture), "rev") + + def test_partialclone_values_and_no_fetch_are_qualified_in_both_versions(self): + for version in (0, 1): + for value in ("origin", ""): + with self.subTest(version=version, empty=value == ""): + fixture = self.fixture() + fixture.append_config("[core]\nrepositoryformatversion=" + str(version) + + "\n[extensions]\npartialClone=" + value + "\n") + self.assertEqual(fixture._run(["rev-parse", "--show-object-format"]).strip(), b"sha1") + self.accepted(self.observe(fixture), "rev") + missing = fixture.blobs["docs/status.example"] + (fixture.git_dir / "objects" / missing[:2] / missing[2:]).unlink() + marker, command = fixture.sentinel() + helper = fixture.root / "git-remote-synthetic" + helper.symlink_to(command) + fixture.append_config('[remote "origin"]\npromisor=true\n' + 'url=synthetic::synthetic-remote-marker\n') + with patch.dict(os.environ, {"PATH": str(fixture.root) + os.pathsep + + os.environ.get("PATH", "")}): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertEqual(result.diagnostics["acquisition_reason"], "object_unavailable") + self.assertFalse(marker.exists()) + + def test_missing_partialclone_scalar_is_refused_before_git_in_both_versions(self): + for version in (0, 1): + with self.subTest(version=version): + fixture = self.fixture() + fixture.append_config("[core]\nrepositoryformatversion=" + str(version) + + "\n[extensions]\npartialClone\n") + query = subprocess.run([fixture.executable, "--git-dir=" + str(fixture.git_dir), + "rev-parse", "--show-object-format"], cwd=fixture.root, env=fixture.env, + capture_output=True, check=False, timeout=5) + self.assertEqual(query.returncode, 128) + with patch.object(acquisition, "bounded_process", + side_effect=AssertionError("Git ran before extension scalar guard")): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertEqual(result.diagnostics["acquisition_reason"], "unsupported_config_scalar") + + def test_external_shallow_and_graft_environment_is_refused_before_any_git(self): + fixture = self.fixture() + target = fixture.root / "synthetic-external-git-metadata" + target.write_bytes(b"synthetic-outside-metadata-marker") + real_open = os.open + def guarded(path, *args, **kwargs): + if os.fspath(path) in {str(target), target.name}: + raise AssertionError("External environment metadata was opened") + return real_open(path, *args, **kwargs) + for name in ("GIT_SHALLOW_FILE", "GIT_GRAFT_FILE"): + with self.subTest(variable=name): + with patch.dict(os.environ, {name: str(target)}): + with patch.object(acquisition.os, "open", side_effect=guarded): + with patch.object(acquisition, "bounded_process", + side_effect=AssertionError("Git ran before environment guard")): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertEqual(result.diagnostics["acquisition_reason"], "inherited_git_context") + self.assertNotIn("synthetic-outside-metadata-marker", json.dumps(result.diagnostics)) + + def test_invalid_selection_and_inherited_git_context_are_not_checked(self): + fixture = self.fixture() + for mode, rev in (("synthetic-invalid-mode", "HEAD"), ("rev", None), ("rev", 1)): + with self.subTest(mode=mode, rev=rev): + result = observe_repository(fixture.path, mode=mode, rev=rev, reference_date="2026-01-01") + self.rejected(result, "NOT_CHECKED", not_checked=True) + for name in ("GIT_DIR", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT", "GIT_ALTERNATE_OBJECT_DIRECTORIES"): + with self.subTest(name=name): + with patch.dict(os.environ, {name: "synthetic-inherited-marker"}): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertNotIn("synthetic-inherited-marker", json.dumps(result.diagnostics)) + + def test_diagnostics_stdout_stderr_and_repr_do_not_echo_source_or_child_values(self): + marker = "synthetic-nondisclosure-marker" + entries = files(DATA.replace(b"runner: script", b"runner: " + marker.encode())) + fixture = self.fixture(entries) + for mode in ("rev", "staged", "worktree"): + with self.subTest(mode=mode): + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + result = self.rejected(self.observe(fixture, mode), "ENUM_UNSUPPORTED") + self.assertNotIn(marker, json.dumps(result.diagnostics)) + self.assertNotIn(marker, repr(result)) + self.assertEqual(stdout.getvalue() + stderr.getvalue(), "") + fixture = self.fixture() + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + with patch.object(acquisition, "bounded_process", return_value=(91, marker.encode(), marker.encode())): + result = self.rejected(self.observe(fixture), "NOT_CHECKED", not_checked=True) + self.assertNotIn(marker, json.dumps(result.diagnostics)) + self.assertNotIn(marker, repr(result)) + self.assertEqual(stdout.getvalue() + stderr.getvalue(), "") + + +if __name__ == "__main__": + unittest.main() + + +class ReleasedObservationTests(unittest.TestCase): + """New transport, the same one C3 acquisition, no independent approval.""" + + def setUp(self): + self.owner = FixtureOwner(self) + + def fixture(self, entries=None, *, object_format="sha1"): + return GitFixture(self.owner, object_format=object_format).seed(files() if entries is None else entries) + + def observe(self, fixture, mode="rev", **kwargs): + return acquisition.observe_released_repository(fixture.path, mode=mode, + rev="HEAD" if mode == "rev" else None, reference_date="2026-01-01", **kwargs) + + def closed(self, result, status): + self.assertEqual(result.exit_code, status) + report = result.diagnostics + self.assertEqual(set(report), {"scope", "exit_code", "declaration_checks", "complete_v1_validity", + "findings", "mode", "reference_date", "commit_oid", "tree_oid", "manifest_blob_oid", + "manifest_sha256", "observation_digest", "git_version", "tree_observation"}) + self.assertEqual(report["scope"], "project-manifest-git-observation-1") + self.assertEqual(report["exit_code"], status) + self.assertEqual(report["declaration_checks"], ("passed", "failed", "not_checked")[status]) + self.assertEqual(report["complete_v1_validity"], "not_checked") + self.assertNotIn("candidate_declaration_accepted", report) + self.assertEqual(len(report["findings"]), int(status != 0)) + for item in report["findings"]: + self.assertEqual(set(item), {"code", "line", "column"}) + if status: + self.assertIsNone(result.record) + self.assertIsNone(result.tree) + return report + + def test_three_modes_and_both_oid_widths_use_the_existing_acquisition_once(self): + for object_format in ("sha1", "sha256"): + fixture = self.fixture(object_format=object_format) + for mode in ("rev", "staged", "worktree"): + with self.subTest(mode=mode, format=object_format): + original = acquisition.observe_repository + with patch.object(acquisition, "observe_repository", wraps=original) as observer: + result = self.observe(fixture, mode) + observer.assert_called_once_with(fixture.path, mode=mode, + rev="HEAD" if mode == "rev" else None, reference_date="2026-01-01", common_dir=None) + report = self.closed(result, 0) + self.assertEqual(report["reference_date"], "2026-01-01") + self.assertEqual(report["manifest_sha256"], hashlib.sha256(DATA).hexdigest()) + self.assertRegex(report["observation_digest"], r"^[a-f0-9]{64}$") + self.assertEqual(report["tree_observation"], + {"rev":"frozen-revision-tree", "staged":"frozen-stage-zero-index", + "worktree":"tracked-working-bytes"}[mode]) + self.assertEqual(report["commit_oid"], fixture.head if mode == "rev" else None) + self.assertEqual(report["tree_oid"], fixture.tree if mode == "rev" else None) + self.assertEqual(report["manifest_blob_oid"], fixture.blobs["project.yaml"] if mode != "worktree" else None) + + def test_distinct_revision_index_and_working_bytes_keep_their_own_identity(self): + fixture = self.fixture() + staged = DATA.replace(b"Synthetic example", b"Synthetic staged") + working = DATA.replace(b"Synthetic example", b"Synthetic working") + fixture.set_index(files(staged)) + (fixture.path / "project.yaml").write_bytes(working) + reports = {} + for mode, data, name in (("rev", DATA, "Synthetic example"), + ("staged", staged, "Synthetic staged"), + ("worktree", working, "Synthetic working")): + with self.subTest(mode=mode): + result = self.observe(fixture, mode) + reports[mode] = self.closed(result, 0) + self.assertEqual(reports[mode]["manifest_sha256"], hashlib.sha256(data).hexdigest()) + self.assertEqual(result.record["project"]["display_name"], name) + self.assertEqual(len({r["manifest_sha256"] for r in reports.values()}), 3) + self.assertIsNone(reports["staged"]["commit_oid"]) + self.assertIsNone(reports["worktree"]["manifest_blob_oid"]) + + def test_unresolved_revision_is_unavailable_without_reacquisition_or_raw_ref(self): + fixture = self.fixture() + original = acquisition.observe_repository + with patch.object(acquisition, "observe_repository", wraps=original) as observer: + result = acquisition.observe_released_repository(fixture.path, mode="rev", + rev="synthetic-unresolved-reference", reference_date="2026-01-01") + observer.assert_called_once_with(fixture.path, mode="rev", rev="synthetic-unresolved-reference", + reference_date="2026-01-01", common_dir=None) + report = self.closed(result, 2) + self.assertEqual(report["reference_date"], "2026-01-01") + self.assertIsNone(report["observation_digest"]) + self.assertNotIn("synthetic-unresolved-reference", json.dumps(report)) + + def test_same_accepted_record_and_tree_objects_are_returned_without_revalidation(self): + fixture = self.fixture() + observed = observe_repository(fixture.path, mode="rev", rev="HEAD", reference_date="2026-01-01") + with patch.object(acquisition, "observe_repository", return_value=observed) as observer, \ + patch.object(acquisition, "check_declaration", side_effect=AssertionError("Second declaration check")): + result = self.observe(fixture) + observer.assert_called_once() + self.closed(result, 0) + self.assertIs(result.record, observed.record) + self.assertIs(result.tree, observed.tree) + + def test_known_invalid_manifest_cases_keep_acquired_provenance_and_explicit_day(self): + cases = [ + ("missing", {k:v for k,v in files().items() if k != "project.yaml"}, "KEY_REQUIRED"), + ("nonregular", dict(files(), **{"project.yaml":FileSpec("120000", b"docs/status.example")}), "REFERENCE_PATH"), + ("oversized", files(DATA + b"\n" * (65_537-len(DATA))), "FIELD_BOUND"), + ("syntax", files(DATA.replace(b"role: canonical", b"role: yes")), None), + ] + for label, entries, code in cases: + fixture = self.fixture(entries) + for mode in ("rev", "staged", "worktree"): + with self.subTest(case=label, mode=mode): + report = self.closed(self.observe(fixture, mode), 1) + if code is not None: + self.assertEqual(report["findings"][0]["code"], code) + self.assertNotEqual(report["findings"][0]["code"], "NOT_CHECKED") + self.assertEqual(report["reference_date"], "2026-01-01") + self.assertRegex(report["observation_digest"], r"^[a-f0-9]{64}$") + self.assertEqual(report["commit_oid"], fixture.head if mode == "rev" else None) + + def test_unavailable_storage_and_bad_inputs_are_closed_and_value_blind(self): + result = acquisition.observe_released_repository("/synthetic/absent-source", mode="rev", + rev="HEAD", reference_date="2026-01-01") + report = self.closed(result, 2) + self.assertTrue(all(report[key] is None for key in ("commit_oid","tree_oid","manifest_blob_oid", + "manifest_sha256","observation_digest","git_version","tree_observation"))) + self.assertEqual(report["reference_date"], "2026-01-01") + for day in (None, "2026-02-30", "synthetic-raw-day", False, b"2026-01-01"): + with self.subTest(day=type(day).__name__), \ + patch.object(acquisition, "observe_repository", side_effect=AssertionError("Missing day acquired")): + report = self.closed(acquisition.observe_released_repository("/synthetic/root", + mode="rev", rev="HEAD", reference_date=day), 2) + self.assertIsNone(report["reference_date"]) + self.assertNotIn("synthetic-raw", json.dumps(report)) + + def test_mixed_pure_and_malformed_candidate_observations_do_not_enter_the_protocol(self): + from copy import deepcopy + from dataclasses import replace + fixture = self.fixture() + accepted = observe_repository(fixture.path, mode="rev", rev="HEAD", reference_date="2026-01-01") + changes = [ + ("scope", "candidate-declaration-tree-records"), ("exit_code", False), + ("candidate_declaration_accepted", False), ("complete_v1_validity", "valid"), + ("manifest_sha256", None), ("observation_digest", "invalid"), + ("git_version", "synthetic-raw-version"), ("commit_oid", None), ("mode", "staged"), + ("reference_date", "2025-01-01"), + ("findings", [{"code":"synthetic-raw-code","line":0,"column":0}]), + ] + for key,value in changes: + with self.subTest(key=key): + diagnostics = deepcopy(accepted.diagnostics) + diagnostics[key] = value + with patch.object(acquisition, "observe_repository", + return_value=replace(accepted, diagnostics=diagnostics)) as observer: + report = self.closed(self.observe(fixture), 2) + observer.assert_called_once() + self.assertNotIn("synthetic-raw", json.dumps(report)) + from project_manifest import check_declaration + pure = check_declaration(DATA, tree=accepted.tree, reference_date="2026-01-01") + with patch.object(acquisition, "observe_repository", return_value=pure): + self.closed(self.observe(fixture), 2) + + def test_observer_failure_is_not_retried_or_echoed_and_cancellation_propagates(self): + with patch.object(acquisition, "observe_repository", + side_effect=RuntimeError("synthetic-raw-observer-error")) as observer: + report = self.closed(acquisition.observe_released_repository("/synthetic/root", + mode="rev", rev="HEAD", reference_date="2026-01-01"), 2) + observer.assert_called_once() + self.assertNotIn("synthetic-raw", json.dumps(report)) + for error in (KeyboardInterrupt, SystemExit): + with patch.object(acquisition, "observe_repository", side_effect=error): + with self.assertRaises(error): + acquisition.observe_released_repository("/synthetic/root", mode="rev", + rev="HEAD", reference_date="2026-01-01") + + +class TestHostGuardTests(unittest.TestCase): + def test_selected_hosts_have_fixed_roots_without_fixture_creation(self): + with patch("acquisition_fixtures.tempfile.mkdtemp", side_effect=AssertionError("unexpected fixture")): + self.assertEqual(fixture_parent("Darwin", "arm64"), Path("/private/tmp")) + self.assertEqual(fixture_parent("Linux", "x86_64"), Path("/tmp")) + + def test_unused_host_profiles_refuse_before_any_temporary_creation(self): + with patch("acquisition_fixtures.tempfile.mkdtemp", side_effect=AssertionError("unexpected fixture")): + for system, machine in (("Linux", "aarch64"), ("Darwin", "x86_64"), ("Windows", "AMD64")): + with self.subTest(system=system, machine=machine): + with self.assertRaisesRegex(AssertionError, "Unsupported"): + fixture_parent(system, machine) + + def test_fixture_creation_uses_selected_parent_and_retains_existing_ownership_contract(self): + owner = FixtureOwner(self) + root = owner.create_root() + self.assertEqual(root.resolve().parent, fixture_parent().resolve()) + self.assertTrue((root / "fixture-owner").is_file()) + self.assertEqual(root.stat().st_uid, os.getuid()) diff --git a/vendor/project-manifest/release-1/tests/test_candidate.py b/vendor/project-manifest/release-1/tests/test_candidate.py new file mode 100644 index 0000000..540487f --- /dev/null +++ b/vendor/project-manifest/release-1/tests/test_candidate.py @@ -0,0 +1,732 @@ +"""Synthetic public behavior vectors; no manifest or Git fixture files.""" + +import contextlib +import io +import json +from pathlib import Path +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).absolute().parent.parent / "src")) +from project_manifest import check_candidate + +BASE = b'''spec_version: "1" +project: + id: sample-project + display_name: "Synthetic example" + owner_entity: sample-owner +role: sample-role +canonical: null +peers: [] +status: + posture: sample-posture + operational_state: sample-state + local_phase: "Synthetic phase" + as_of: "2026-01-01" +authority: + status_of_record: "docs/status.example" +presentation: + visibility: sample-visibility + public_alias: null + link_policy: sample-link +development: + platforms: [] + commands: + setup: null + check: + runner: sample-runner + target: "check-example" + test: null + build: null + dev: null + format_check: null + format_write: null + environment: + owner: sample-activation + template: null + secret_launcher: null + stacks: {} + host_requirements: [] +''' +STACK = b''' stacks: + sample-stack: + compose: "stacks/compose.example" + instance_scope: sample-scope + port_policy: sample-policy +''' +EXPANDED = BASE.replace(b" stacks: {}\n", STACK) +OPTIONAL_ROLES = (b"setup", b"test", b"build", b"dev", b"format_check", b"format_write") + + +def changed(old, new, data=BASE): + if data.count(old) != 1: + raise AssertionError("Synthetic replacement is not unique") + return data.replace(old, new) + + +def full_declaration_shape(): + data = EXPANDED + for role in OPTIONAL_ROLES: + data = changed(b" " + role + b": null\n", b" " + role + b":\n" + b" runner: sample-runner\n" b' target: "sample-task"\n', data) + for old, new in ( + (b"canonical: null\n", b"canonical: sample-parent\n"), + (b" public_alias: null\n", b' public_alias: "Synthetic alias"\n'), + (b" template: null\n", b' template: "config/example.env"\n'), + (b" secret_launcher: null\n", b' secret_launcher: "scripts/example-launcher"\n'), + ): + data = changed(old, new, data) + return data + + +# Independent public contract positions, not imported from the reader's SHAPE. +REQUIRED_POSITIONS = ( + "project", "role", "canonical", "peers", "status", "authority", + "presentation", "development", +) + tuple(parent + "." + key for parent, keys in ( + ("project", ("id", "display_name", "owner_entity")), + ("status", ("posture", "operational_state", "local_phase", "as_of")), + ("authority", ("status_of_record",)), + ("presentation", ("visibility", "public_alias", "link_policy")), + ("development", ("platforms", "commands", "environment", "stacks", "host_requirements")), + ("development.environment", ("owner", "template", "secret_launcher")), + ("development.stacks.sample-stack", ("compose", "instance_scope", "port_policy")), +) for key in keys) + tuple( + "development.commands." + role.decode() + suffix + for role in (b"check",) + OPTIONAL_ROLES for suffix in ("", ".runner", ".target") +) +NULLABLE_POSITIONS = frozenset(( + "canonical", "presentation.public_alias", "development.environment.template", + "development.environment.secret_launcher", +)) | frozenset("development.commands." + role.decode() for role in OPTIONAL_ROLES) + + +def edit_fixture_field(data, field, *, omit=False): + # Locate only this trusted fixture's line block; no validator/parser internals. + lines, stack = data.splitlines(keepends=True), [] + for start, line in enumerate(lines): + depth = (len(line) - len(line.lstrip(b" "))) // 2 + key = line.lstrip(b" ").split(b":", 1)[0].decode("ascii") + stack = stack[:depth] + [key] + if ".".join(stack) != field: + continue + end = start + 1 + while end < len(lines) and len(lines[end]) - len(lines[end].lstrip(b" ")) > depth * 2: + end += 1 + replacement = b"" if omit else b" " * (depth * 2) + key.encode() + b": null\n" + changed_data = b"".join(lines[:start]) + replacement + b"".join(lines[end:]) + # Keep a sole-child omission syntactically valid so shape requiredness, + # rather than an orphan empty-header grammar error, determines rejection. + if omit and field == "authority.status_of_record": + changed_data = changed(b"authority:\n", b"authority: {}\n", changed_data) + return changed_data + raise AssertionError("Public fixture field was not found") + + +class CandidateTests(unittest.TestCase): + def accepted(self, data): + result = check_candidate(data) + self.assertEqual(result.exit_code, 0, result.diagnostics()) + self.assertTrue(result.syntax_shape_accepted) + self.assertEqual(result.findings, ()) + self.assertIsInstance(result.record, dict) + self.assertEqual(result.diagnostics()["complete_v1_validity"], "not_checked") + self.assertNotIn("record", result.diagnostics()) + return result.record + + def rejected(self, data, code): + result = check_candidate(data) + self.assertEqual(result.exit_code, 1, result.diagnostics()) + self.assertFalse(result.syntax_shape_accepted) + self.assertIsNone(result.record) + self.assertEqual(len(result.findings), 1) + self.assertEqual(result.findings[0].code, code) + self.assertEqual(result.diagnostics()["complete_v1_validity"], "not_checked") + return result + + def test_normalized_record_and_determinism(self): + record = self.accepted(BASE) + self.assertEqual(record["spec_version"], "1") + self.assertEqual(record["project"], { + "id": "sample-project", "display_name": "Synthetic example", + "owner_entity": "sample-owner", + }) + self.assertIsNone(record["canonical"]) + self.assertEqual(record["peers"], []) + self.assertEqual(record["development"]["stacks"], {}) + self.assertEqual(record["development"]["commands"]["check"], { + "runner": "sample-runner", "target": "check-example", + }) + self.assertEqual(check_candidate(BASE), check_candidate(BASE)) + + def test_every_fixed_field_is_required_even_when_nullable(self): + data = full_declaration_shape() + self.accepted(data) + self.assertEqual(len(REQUIRED_POSITIONS), 51) + self.assertEqual(len(set(REQUIRED_POSITIONS)), 51) + for field in REQUIRED_POSITIONS: + with self.subTest(field=field): + self.rejected(edit_fixture_field(data, field, omit=True), "KEY_REQUIRED") + # Version removal belongs to first-content selection, not map requiredness. + self.rejected(changed(b'spec_version: "1"\n', b"", data), "VERSION_LINE") + + def test_nullability_of_every_fixed_record_and_leaf(self): + data = full_declaration_shape() + for field in REQUIRED_POSITIONS: + with self.subTest(field=field): + candidate = edit_fixture_field(data, field) + if field in NULLABLE_POSITIONS: + self.accepted(candidate) + else: + self.rejected(candidate, "FIELD_TYPE") + self.rejected(edit_fixture_field(data, "development.stacks.sample-stack"), "FIELD_TYPE") + + def test_full_development_shape(self): + data = EXPANDED + for role in OPTIONAL_ROLES: + data = changed(b" " + role + b": null\n", + b" " + role + b":\n" + b" runner: sample-runner\n" + b' target: "sample-task"\n', data) + data = changed(b" template: null\n", b' template: "config/example.env"\n', data) + data = changed(b" secret_launcher: null\n", + b' secret_launcher: "scripts/example-launcher"\n', data) + data = changed(b"canonical: null\n", b"canonical: sample-parent\n", data) + data = changed(b" public_alias: null\n", b' public_alias: "Example alias"\n', data) + data = changed(b"peers: []\n", b"peers: [sample-peer, other-peer]\n", data) + data = changed(b" platforms: []\n", + b" platforms: [sample-platform, other-platform]\n", data) + data = changed(b" host_requirements: []\n", + b" host_requirements: [sample-engine]\n", data) + record = self.accepted(data) + self.assertEqual(len(record["development"]["commands"]), 7) + self.assertEqual(record["development"]["stacks"]["sample-stack"]["compose"], + "stacks/compose.example") + self.assertEqual(record["peers"], ["sample-peer", "other-peer"]) + + def test_comments_blanks_and_unordered_keys(self): + self.accepted(b"# Before version\n\n # Odd comment indentation\n" + BASE + b"\n# End\n") + self.accepted(changed(b"role: sample-role\ncanonical: null\n", + b"canonical: null\nrole: sample-role\n")) + self.accepted(changed(b" id: sample-project\n", b"# Outside indentation\n id: sample-project\n")) + self.accepted(b" " * 2047 + b"#\n" + BASE) + + def test_quoted_punctuation_and_null_text(self): + record = self.accepted(changed(b' display_name: "Synthetic example"\n', + b' display_name: "# literal: punctuation [] {}"\n')) + self.assertEqual(record["project"]["display_name"], "# literal: punctuation [] {}") + record = self.accepted(changed(b" public_alias: null\n", b' public_alias: "null"\n')) + self.assertEqual(record["presentation"]["public_alias"], "null") + self.rejected(changed(b"canonical: null\n", b'canonical: "null"\n'), "FIELD_TYPE") + + def test_version_line(self): + for first in (b'spec_version: "2"', b"spec_version: 1", b'spec_version: "0.10"', + b"spec_version: 0.10", b'spec_version: "0.1"', + b"spec_version: '0.1'", b"spec_version: 0.1", + b' spec_version: "1"', b' spec_version: "1"', + b'spec_version: "1"', b'spec_version:"1"', + b'spec_version: "1" # comment'): + with self.subTest(first=first): + code = "GRAMMAR" if first.startswith(b" ") and not first.startswith(b" ") else "VERSION_LINE" + if first in (b'spec_version: "0.1"', b"spec_version: '0.1'", b"spec_version: 0.1"): + code = "LEGACY_VERSION" + self.rejected(changed(b'spec_version: "1"\n', first + b"\n"), code) + self.rejected(b"# Nothing\n\n", "KEY_REQUIRED") + self.rejected(changed(b'spec_version: "1"\n', b""), "VERSION_LINE") + self.rejected(BASE + b'spec_version: "1"\n', "KEY_DUPLICATE") + + def test_bytes(self): + self.rejected(b"", "BYTE_PROFILE") + self.rejected(BASE[:-1], "BYTE_PROFILE") + for prefix in (b"\xef\xbb\xbf", b"\x00", b"\t", b"\r", b"\x7f", b"\x85", + "\u2028".encode(), "\u2029".encode(), "\u200b".encode(), + "\u00e9".encode(), "e\u0301".encode(), "\u0441".encode()): + with self.subTest(prefix=prefix): + self.rejected(prefix + BASE, "BYTE_PROFILE") + self.rejected(BASE.replace(b"\n", b"\r\n"), "BYTE_PROFILE") + self.rejected(BASE + b" \n", "BYTE_PROFILE") + self.rejected(changed(b"peers: []\n", b"peers: [] \n"), "BYTE_PROFILE") + self.rejected(changed(b"project:\n", b"project:\t\n"), "BYTE_PROFILE") + + def test_prohibited_forms(self): + for value in (b"&sample", b"*sample", b"!sample", b"!!str sample", + b"{sample: value}", b"|", b">", b"'sample'", b"~", + b"sample # comment", b'"sample" # comment', + b'["sample"]', b"[sample,\n other]", b"[ ]"): + with self.subTest(value=value): + self.rejected(changed(b"role: sample-role\n", b"role: " + value + b"\n"), "GRAMMAR") + for line in (b"---\n", b"...\n", b"<<: *sample\n", b"- sample\n", b"? sample\n"): + with self.subTest(line=line): + self.rejected(BASE + line, "GRAMMAR") + self.rejected(changed(b"peers: []\n", b"peers:\n - sample\n"), "GRAMMAR") + self.rejected(changed(b' local_phase: "Synthetic phase"\n', + b' local_phase: "first\n second"\n'), "GRAMMAR") + + def test_spacing_indentation_and_depth(self): + for old, new in ( + (b" id: sample-project\n", b" id: sample-project\n"), + (b" id: sample-project\n", b" id: sample-project\n"), + (b" id: sample-project\n", b" id:sample-project\n"), + (b" id: sample-project\n", b" id: sample-project\n"), + (b" id: sample-project\n", b" id : sample-project\n"), + (b"project:\n", b"project: {}\n"), + (b"peers: []\n", b"peers:\n"), + (b" stacks: {}\n", b" stacks:\n"), + (b' target: "check-example"\n', b" target:\n deep: sample\n"), + ): + with self.subTest(new=new): + self.rejected(changed(old, new), "GRAMMAR") + for value in (b"[sample,other]", b"[sample, other]", b"[ sample, other]", + b"[sample, other ]", b"[sample,]", b"[sample , other]"): + with self.subTest(value=value): + self.rejected(changed(b"peers: []\n", b"peers: " + value + b"\n"), "GRAMMAR") + self.accepted(changed(b"peers: []\n", b"peers: [sample, other]\n")) + + def test_duplicate_keys_at_all_mapping_levels(self): + for line, data in ( + (b"role: sample-role\n", BASE), + (b" id: sample-project\n", BASE), + (b" posture: sample-posture\n", BASE), + (b' status_of_record: "docs/status.example"\n', BASE), + (b" visibility: sample-visibility\n", BASE), + (b" platforms: []\n", BASE), + (b" setup: null\n", BASE), + (b" runner: sample-runner\n", BASE), + (b" owner: sample-activation\n", BASE), + (b" sample-stack:\n", EXPANDED), + (b" port_policy: sample-policy\n", EXPANDED), + ): + with self.subTest(line=line): + if line == b" sample-stack:\n": + duplicate = STACK[STACK.index(line):] + candidate = EXPANDED.replace(STACK, STACK + duplicate) + else: + candidate = changed(line, line + line, data) + self.rejected(candidate, "KEY_DUPLICATE") + + def test_unknown_keys_at_all_mapping_levels(self): + for line, data in ( + (b"role: sample-role\n", BASE), + (b" id: sample-project\n", BASE), + (b" posture: sample-posture\n", BASE), + (b' status_of_record: "docs/status.example"\n', BASE), + (b" visibility: sample-visibility\n", BASE), + (b" platforms: []\n", BASE), + (b" setup: null\n", BASE), + (b" runner: sample-runner\n", BASE), + (b" owner: sample-activation\n", BASE), + (b" port_policy: sample-policy\n", EXPANDED), + ): + with self.subTest(line=line): + indent = len(line) - len(line.lstrip(b" ")) + self.rejected(changed(line, line + b" " * indent + b"extra: sample\n", data), + "KEY_UNKNOWN") + self.accepted(EXPANDED.replace(b"sample-stack:", b"other_stack-name:")) + + def test_missing_keys_at_all_mapping_levels(self): + for line, data in ( + (b"role: sample-role\n", BASE), + (b"canonical: null\n", BASE), + (b" id: sample-project\n", BASE), + (b" posture: sample-posture\n", BASE), + (b" visibility: sample-visibility\n", BASE), + (b" platforms: []\n", BASE), + (b" setup: null\n", BASE), + (b" runner: sample-runner\n", BASE), + (b" owner: sample-activation\n", BASE), + (b" port_policy: sample-policy\n", EXPANDED), + ): + with self.subTest(line=line): + self.rejected(changed(line, b"", data), "KEY_REQUIRED") + self.rejected(changed(b' status_of_record: "docs/status.example"\n', b" extra: sample\n"), + "KEY_UNKNOWN") + self.rejected(changed(b'authority:\n status_of_record: "docs/status.example"\n', + b"authority: {}\n"), "KEY_REQUIRED") + + def test_nonnullable_records_and_leaf_types(self): + for old, new in ( + (b"project:\n id: sample-project\n display_name: \"Synthetic example\"\n owner_entity: sample-owner\n", + b"project: null\n"), + (b'authority:\n status_of_record: "docs/status.example"\n', b"authority: null\n"), + (b' check:\n runner: sample-runner\n target: "check-example"\n', b" check: null\n"), + (b" stacks: {}\n", b" stacks: null\n"), + (b"peers: []\n", b"peers: null\n"), + (b" platforms: []\n", b' platforms: "sample-platform"\n'), + (b" host_requirements: []\n", b" host_requirements: {}\n"), + (b' display_name: "Synthetic example"\n', b" display_name: sample\n"), + (b" id: sample-project\n", b' id: "sample"\n'), + (b" setup: null\n", b" setup: sample\n"), + ): + with self.subTest(new=new): + self.rejected(changed(old, new), "FIELD_TYPE") + for key in (b"template", b"secret_launcher"): + self.rejected(changed(b" " + key + b": null\n", b" " + key + b": sample\n"), + "FIELD_TYPE") + self.rejected(changed(b" setup: null\n", b" setup: {}\n"), "KEY_REQUIRED") + + def test_token_boundaries_and_reserved_spellings(self): + for value in (b"a", b"a" * 64, b"a-b_c2"): + self.accepted(changed(b" id: sample-project\n", b" id: " + value + b"\n")) + self.rejected(changed(b" id: sample-project\n", b" id: " + b"a" * 65 + b"\n"), "FIELD_BOUND") + for value in (b"y", b"n", b"yes", b"no", b"on", b"off", b"true", b"false", + b"Null", b"NULL", b"Yes", b"TRUE", b"1", b"0x10", b".inf", b"1e3", + b"a--b", b"a__b", b"a-_b", b"a_-b", b"-a", b"a-", b"a_", b"A"): + with self.subTest(value=value): + self.rejected(changed(b" id: sample-project\n", b" id: " + value + b"\n"), "GRAMMAR") + self.rejected(changed(b" id: sample-project\n", b" id: null\n"), "FIELD_TYPE") + self.rejected(changed(b"peers: []\n", b"peers: [null]\n"), "GRAMMAR") + for name, code in ((b"a" * 64, None), (b"a" * 65, "FIELD_BOUND"), + (b"yes", "GRAMMAR"), (b"a--b", "GRAMMAR")): + data = EXPANDED.replace(b"sample-stack:", name + b":") + if code: + self.rejected(data, code) + else: + self.accepted(data) + + def test_text_boundaries(self): + for old, maximum in ( + (b' display_name: "Synthetic example"\n', 100), + (b" public_alias: null\n", 100), + (b' local_phase: "Synthetic phase"\n', 400), + (b' target: "check-example"\n', 255), + ): + key = old.split(b":", 1)[0] + for length in (1, maximum): + with self.subTest(key=key, length=length): + self.accepted(changed(old, key + b': "' + b"x" * length + b'"\n')) + self.rejected(changed(old, key + b': "' + b"x" * (maximum + 1) + b'"\n'), "FIELD_BOUND") + self.rejected(changed(old, key + b': ""\n'), "GRAMMAR") + for value in (b'" leading"', b'"trailing "', b'"contains\\"quote"', b'"back\\slash"'): + self.rejected(changed(b' display_name: "Synthetic example"\n', + b" display_name: " + value + b"\n"), "GRAMMAR") + + def test_path_boundaries_and_lexical_rules(self): + for old, data in ( + (b' status_of_record: "docs/status.example"\n', BASE), + (b" template: null\n", BASE), + (b" secret_launcher: null\n", BASE), + (b' compose: "stacks/compose.example"\n', EXPANDED), + ): + key = old.split(b":", 1)[0] + for length in (1, 255): + self.accepted(changed(old, key + b': "' + b"x" * length + b'"\n', data)) + self.rejected(changed(old, key + b': "' + b"x" * 256 + b'"\n', data), "FIELD_BOUND") + self.rejected(changed(old, key + b': ""\n', data), "GRAMMAR") + for path in (b"/absolute", b"C:/absolute", b"a//b", b"a/", b".", b"..", + b"a/../b", b"a/./b", b".git/config", b"a/.git/b", + b"a/ b", b"a /b"): + with self.subTest(key=key, path=path): + self.rejected(changed(old, key + b': "' + path + b'"\n', data), "REFERENCE_PATH") + self.accepted(changed(b' status_of_record: "docs/status.example"\n', + b' status_of_record: ".GIT/Not-a-tree-proof"\n')) + + def test_date_format_and_semantic_deferrals(self): + for value, code in ((b'"2026-1-01"', "DATE_FORMAT"), + (b'"2026-aa-01"', "DATE_FORMAT"), + (b'"20260-01-01"', "FIELD_BOUND"), (b"sample-date", "FIELD_TYPE")): + self.rejected(changed(b' as_of: "2026-01-01"\n', b" as_of: " + value + b"\n"), code) + for date in (b"0000-00-00", b"2026-02-31", b"9999-12-31"): + self.accepted(changed(b' as_of: "2026-01-01"\n', b' as_of: "' + date + b'"\n')) + self.accepted(changed(b"role: sample-role\n", b"role: canonical\n")) + self.accepted(changed(b"canonical: null\n", b"canonical: sample-project\n")) + self.accepted(changed(b"peers: []\n", b"peers: [sample-project, sample-project]\n")) + self.accepted(changed(b" platforms: []\n", b" platforms: [sample, sample]\n")) + self.accepted(changed(b" host_requirements: []\n", b" host_requirements: [sample, sample]\n")) + self.accepted(changed(b" runner: sample-runner\n", b" runner: mise\n").replace( + b' target: "check-example"\n', b' target: "' + b"x" * 255 + b'"\n')) + + def test_file_and_line_boundaries(self): + self.accepted(BASE + b"\n" * (65_536 - len(BASE))) + self.rejected(BASE + b"\n" * (65_537 - len(BASE)), "FIELD_BOUND") + self.accepted(BASE + b"#" + b"x" * 2047 + b"\n") + self.rejected(BASE + b"#" + b"x" * 2048 + b"\n", "FIELD_BOUND") + + def test_diagnostics_are_value_blind_and_silent(self): + marker = b"synthetic-marker-abcdef" + cases = ( + BASE + marker + b": sample\n", + EXPANDED.replace(b"sample-stack:", marker + b":").replace( + b" port_policy: sample-policy\n", b" " + marker + b": sample\n"), + changed(b" id: sample-project\n", b' id: "' + marker + b'"\n'), + changed(b' status_of_record: "docs/status.example"\n', + b' status_of_record: "../' + marker + b'"\n'), + ) + for data in cases: + with self.subTest(case=len(data)): + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + result = check_candidate(data) + self.assertEqual(result.exit_code, 1) + self.assertNotIn(marker.decode(), json.dumps(result.diagnostics())) + self.assertNotIn(marker.decode(), repr(result)) + self.assertNotIn(marker.decode(), stdout.getvalue() + stderr.getvalue()) + finding = result.findings[0] + self.assertGreater(finding.line, 0) + self.assertGreater(finding.column, 0) + self.assertNotIn("sample-stack", finding.field) + + def test_wrong_input_type_not_checked(self): + for data in (BASE.decode(), bytearray(BASE), None, 1, memoryview(BASE)): + with self.subTest(kind=type(data).__name__): + result = check_candidate(data) + self.assertEqual(result.exit_code, 2) + self.assertEqual(result.findings[0].code, "NOT_CHECKED") + self.assertIsNone(result.record) + self.assertFalse(result.syntax_shape_accepted) + + +class LoaderComparisonTests(unittest.TestCase): + """Stdlib injected preparation controls, never actual optional engines.""" + + def setUp(self): + import compare_loaders + self.comparison = compare_loaders + + def selection(self): + # These versions/executable are synthetic controls, not a release profile. + return {"python": "1.2.3", "profiles": [ + {"id": "safe", "engine": "PyYAML.SafeLoader", "versions": {"pyyaml": "1.2.3", "libyaml": None}}, + {"id": "compiled", "engine": "PyYAML.CSafeLoader", "versions": {"pyyaml": "1.2.3", "libyaml": [0, 1, 2]}}, + {"id": "psych", "engine": "Ruby.Psych", "executable": "/synthetic/prepared/ruby", + "versions": {"ruby": "1.2.3", "psych": "4.5.6", "libyaml": [0, 1, 2]}}, + ]} + + def run_injected(self, initializer, **kwargs): + from unittest.mock import patch + with patch.object(self.comparison, "discover", side_effect=AssertionError("Selected discovery ran")), \ + patch.object(self.comparison.importlib, "import_module", side_effect=AssertionError("Optional module imported")), \ + patch.object(self.comparison.subprocess, "run", side_effect=AssertionError("Optional runtime executed")): + return self.comparison.compare(initializer=initializer, python_version="1.2.3", **kwargs) + + def test_selected_requires_every_engine_and_every_accepted_vector_without_discovery(self): + selected = self.selection() + initialized, loaded = [], [] + def initialize(profile): + initialized.append(profile["id"]) + def load(data): + loaded.append(profile["id"]) + return check_candidate(data).record + return profile["versions"], load + report = self.run_injected(initialize, selection=selected) + accepted, refused = self.comparison.vectors() + self.assertEqual(report["exit_code"], 0) + self.assertEqual(report["selected_comparison"], "passed") + self.assertEqual(report["release_qualification"], "not_checked") + self.assertEqual(initialized, ["safe", "compiled", "psych"]) + self.assertEqual(len(loaded), len(initialized) * (len(accepted) + len(refused))) + for engine in report["loaders"]: + self.assertEqual(engine["agreements"], list(accepted)) + self.assertEqual(engine["differences"], []) + # General acceptance of strict-refused input is informational. + self.assertEqual(engine["broader_yaml_acceptance"], list(refused)) + self.assertEqual(len(report["requirements"]), 3) + self.assertTrue(all(item["code"] == "SATISFIED" for item in report["requirements"])) + + def test_missing_version_and_initialization_failures_never_reduce_selected_set(self): + for condition, expected in (("missing", "ENGINE_MISSING"), ("version", "VERSION_MISMATCH"), + ("initialization", "ENGINE_INIT_FAILED")): + with self.subTest(condition=condition): + loaded = [] + def initialize(profile): + if profile["id"] == "compiled": + if condition == "missing": + raise self.comparison.Unavailable("ENGINE_MISSING") + if condition == "initialization": + raise RuntimeError("synthetic-raw-error-never-reported") + version = dict(profile["versions"], pyyaml="9.9.9") + else: + version = profile["versions"] + return version, lambda data: loaded.append(data) + report = self.run_injected(initialize, selection=self.selection()) + self.assertEqual(report["exit_code"], 2) + self.assertEqual(report["selected_comparison"], "not_checked") + self.assertEqual(len(report["requirements"]), 3) + self.assertEqual(report["requirements"][1]["code"], expected) + self.assertEqual(loaded, []) + self.assertNotIn("synthetic-raw-error", json.dumps(report)) + + def test_python_context_mismatch_stops_before_initialization(self): + def initialize(profile): + raise AssertionError("Unqualified interpreter initialized an engine") + selected = self.selection() + selected["python"] = "9.9.9" + report = self.run_injected(initialize, selection=selected) + self.assertEqual(report["exit_code"], 2) + self.assertEqual(report["reason"], "PYTHON_VERSION_MISMATCH") + + def test_unknown_duplicate_or_unqualified_profile_is_refused_before_engine_activity(self): + cases = [] + for change in ({"engine": "Synthetic.Other"}, {"engine": []}, {"versions": {"pyyaml": "latest", "libyaml": None}}): + value = self.selection() + value["profiles"][0].update(change) + cases.append(value) + duplicate = self.selection() + duplicate["profiles"][1]["id"] = "safe" + cases.append(duplicate) + relative = self.selection() + relative["profiles"][2]["executable"] = "relative/ruby" + cases.append(relative) + extra = self.selection() + extra["profiles"][0]["fallback"] = True + cases.append(extra) + cases.extend(({"python": "1.2.3", "profiles": []}, None)) + for index, value in enumerate(cases): + with self.subTest(case=index): + if value is None: + self.assertFalse(self.comparison.selection_valid(value)) + continue + report = self.run_injected(lambda profile: self.fail("Unqualified profile initialized"), selection=value) + self.assertEqual(report["exit_code"], 2) + self.assertEqual(report["reason"], "SELECTION_INPUT") + + def test_accepted_record_disagreement_and_strict_control_failure_are_exit_one(self): + selected = self.selection() + report = self.run_injected(lambda profile: (profile["versions"], lambda data: {}), selection=selected) + self.assertEqual(report["exit_code"], 1) + self.assertEqual(report["selected_comparison"], "failed") + def initialize(profile): + self.fail("Strict control failure initialized optional engine") + report = self.run_injected(initialize, selection=selected, supplied_vectors=({"bad": b"not-a-manifest\n"}, {})) + self.assertEqual(report["exit_code"], 1) + self.assertEqual(report["reason"], "STRICT_CONTROL_FAILED") + + def test_post_initialization_loss_is_unavailable_in_both_vector_loops_without_fallback(self): + import subprocess + accepted, refused = self.comparison.vectors() + sample = {"base": accepted["base"]} + exclusion = {"reserved_token": refused["reserved_token"]} + for phase in ("accepted", "strict_refusal"): + for failure in ("os", "timeout", "unavailable"): + for earlier_mismatch in (False, True): + with self.subTest(phase=phase, failure=failure, earlier_mismatch=earlier_mismatch): + initialized, loaded = [], [] + def initialize(profile): + initialized.append(profile["id"]) + def load(data): + loaded.append(profile["id"]) + if profile["id"] == "psych": + self.fail("Lost selected loader was skipped or retried through another profile") + is_refused = data == exclusion["reserved_token"] + if profile["id"] == "compiled" and is_refused == (phase == "strict_refusal"): + if failure == "os": + raise OSError("synthetic-raw-operational-error") + if failure == "timeout": + raise subprocess.TimeoutExpired("synthetic-raw-command", 5, + output=b"synthetic-raw-output") + raise self.comparison.Unavailable("synthetic-raw-operational-error") + if is_refused: + raise ValueError("synthetic-parser-refusal") + return {} if earlier_mismatch and profile["id"] == "safe" else check_candidate(data).record + return profile["versions"], load + report = self.run_injected(initialize, selection=self.selection(), + supplied_vectors=(sample, exclusion)) + self.assertEqual(report["exit_code"], 2) + self.assertEqual(report["reason"], "LOADER_EXECUTION_UNAVAILABLE") + self.assertEqual(report["selected_comparison"], "not_checked") + self.assertEqual(report["release_qualification"], "not_checked") + self.assertEqual(initialized, ["safe", "compiled", "psych"]) + self.assertEqual(report["unavailable"], ["compiled"]) + self.assertNotIn("psych", loaded) + self.assertEqual(report["loaders"][-1]["phase"], phase) + self.assertEqual(report["loaders"][-1]["execution"], "unavailable") + self.assertEqual(report["loaders"][0]["differences"], ["base"] if earlier_mismatch else []) + self.assertNotIn("synthetic-raw", json.dumps(report)) + + def test_parser_refusal_of_accepted_input_is_mismatch_and_strict_refusal_is_informational(self): + accepted, refused = self.comparison.vectors() + sample = {"base": accepted["base"]} + exclusion = {"reserved_token": refused["reserved_token"]} + for refuse_accepted in (False, True): + def initialize(profile): + def load(data): + if refuse_accepted or data == exclusion["reserved_token"]: + raise ValueError("synthetic-parser-refusal") + return check_candidate(data).record + return profile["versions"], load + report = self.run_injected(initialize, selection=self.selection(), + supplied_vectors=(sample, exclusion)) + self.assertEqual(report["exit_code"], 1 if refuse_accepted else 0) + self.assertEqual(report["selected_comparison"], "failed" if refuse_accepted else "passed") + self.assertEqual(report["unavailable"], []) + self.assertEqual(report["loaders"][0]["differences"], ["base"] if refuse_accepted else []) + + def test_ruby_parser_refusal_is_distinct_from_process_loss_or_malformed_output(self): + from types import SimpleNamespace + from unittest.mock import patch + cases = ((65, b"", ValueError), (1, b"", self.comparison.Unavailable), + (-9, b"", self.comparison.Unavailable), (0, b"invalid-output", self.comparison.Unavailable)) + for code, output, error in cases: + with self.subTest(code=code), patch.object(self.comparison.subprocess, "run", + return_value=SimpleNamespace(returncode=code, stdout=output, + stderr=b"synthetic-raw-ruby-error")): + with self.assertRaises(error) as raised: + self.comparison.ruby_loader("/synthetic/prepared/ruby", BASE) + self.assertNotIn("synthetic-raw", str(raised.exception)) + with patch.object(self.comparison.subprocess, "run", + return_value=SimpleNamespace(returncode=0, stdout=b'{"sample":1}', stderr=b"")): + self.assertEqual(self.comparison.ruby_loader("/synthetic/prepared/ruby", BASE), {"sample": 1}) + + def test_reconnaissance_remains_incomplete_and_does_not_adopt_a_release_profile(self): + report = self.comparison.compare(discovery=lambda: ( + [("synthetic-one", {"pyyaml": "1.2.3", "libyaml": None}, lambda data: check_candidate(data).record)], + ["PyYAML.CSafeLoader", "Ruby.Psych"])) + self.assertEqual(report["exit_code"], 0) + self.assertEqual(report["selected_comparison"], "not_checked") + self.assertEqual(report["release_qualification"], "not_checked") + self.assertEqual(report["unavailable"], ["PyYAML.CSafeLoader", "Ruby.Psych"]) + + def test_linked_libyaml_version_is_observed_from_extension_not_boolean(self): + from types import SimpleNamespace + from unittest.mock import patch + yaml = SimpleNamespace(__version__="1.2.3", __with_libyaml__=True, CSafeLoader=object()) + extension = SimpleNamespace(get_version=lambda: (0, 1, 2)) + names = [] + def imported(name): + names.append(name) + return {"yaml": yaml, "yaml._yaml": extension}[name] + with patch.object(self.comparison.importlib, "import_module", side_effect=imported): + versions, load = self.comparison.initialize({"engine": "PyYAML.CSafeLoader"}) + self.assertEqual(versions, {"pyyaml": "1.2.3", "libyaml": [0, 1, 2]}) + self.assertTrue(callable(load)) + self.assertEqual(names, ["yaml", "yaml._yaml"]) + + def test_explicit_invalid_selected_cli_input_never_falls_back_to_reconnaissance(self): + from unittest.mock import patch + for argv in (["--selected-profiles", ""], ["--selected-profiles"], ["--unknown"]): + with self.subTest(argv=argv), \ + patch.object(self.comparison, "discover", side_effect=AssertionError("CLI selection fell back")), \ + patch.object(self.comparison, "initialize", side_effect=AssertionError("CLI selection initialized")): + output = io.StringIO() + with contextlib.redirect_stdout(output): + code = self.comparison.main(argv) + report = json.loads(output.getvalue()) + self.assertEqual(code, 2) + self.assertEqual(report["reason"], "SELECTION_INPUT") + self.assertEqual(report["selected_comparison"], "not_checked") + + def test_selected_representation_classes_have_explicit_strict_record_controls(self): + accepted, refused = self.comparison.vectors() + records = {label: self.comparison.check_candidate(data) for label, data in accepted.items()} + self.assertTrue(all(result.exit_code == 0 for result in records.values())) + self.assertTrue(all(self.comparison.check_candidate(data).exit_code == 1 for data in refused.values())) + self.assertEqual(records["singleton_list"].record["peers"], ["a"]) + self.assertEqual(records["ordered_lists"].record["peers"], ["sample_two", "sample-one"]) + self.assertEqual(records["duplicate_list"].record["peers"], ["sample-peer", "sample-peer"]) + self.assertEqual(records["quoted_null_and_numeric"].record["presentation"]["public_alias"], "null") + self.assertEqual(records["quoted_null_and_numeric"].record["project"]["display_name"], "001") + self.assertIsNone(records["base"].record["presentation"]["public_alias"]) + self.assertEqual(records["quoted_punctuation"].record["project"]["display_name"], "# literal: [] {}") + self.assertEqual(records["reordered_keys"].record, records["base"].record) + self.assertEqual(records["interior_comments"].record, records["base"].record) + self.assertEqual(list(records["two_mixed_stack_keys"].record["development"]["stacks"]), ["first_stack-one", "second-stack_two"]) + self.assertEqual(len(records["full_shape"].record["development"]["commands"]), 7) + self.assertEqual(len(records["token_limits"].record["project"]["id"]), 64) + self.assertEqual(len(next(iter(records["token_limits"].record["development"]["stacks"]))), 64) + self.assertEqual(len(records["text_and_path_limits"].record["status"]["local_phase"]), 400) + self.assertEqual(len(records["text_and_path_limits"].record["authority"]["status_of_record"]), 255) + self.assertEqual(len(accepted["file_limit"]), 65_536) + self.assertEqual(len(accepted["line_limit"].splitlines()[-1]), 2_048) + + +if __name__ == "__main__": + unittest.main() diff --git a/vendor/project-manifest/release-1/tests/test_cli.py b/vendor/project-manifest/release-1/tests/test_cli.py new file mode 100644 index 0000000..6419852 --- /dev/null +++ b/vendor/project-manifest/release-1/tests/test_cli.py @@ -0,0 +1,101 @@ +"""Offline CLI boundary tests using only guarded synthetic fixtures.""" +from contextlib import redirect_stdout +import io +import json +import os +from pathlib import Path +import runpy +import subprocess +import sys +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).absolute().parent.parent +sys.path.insert(0, str(ROOT / "src")) + +from project_manifest.cli import main +from acquisition_fixtures import FixtureOwner, GitFixture +from test_acquisition import files + + +class CliTests(unittest.TestCase): + def capture(self, argv): + output = io.StringIO() + with redirect_stdout(output): + code = main(argv) + report = json.loads(output.getvalue()) + self.assertEqual(report["exit_code"], code) + self.assertEqual(report["complete_v1_validity"], "not_checked") + return code, report, output.getvalue() + + def test_invalid_arguments_are_value_blind(self): + marker = "SYNTHETIC_CLI_ARGUMENT_MARKER" + for args in ([], ["--rev", marker, "--staged"], + ["--unknown-" + marker], ["--rev"]): + with self.subTest(args=args): + code, report, output = self.capture(args) + self.assertEqual(code, 2) + self.assertNotIn(marker, output) + self.assertEqual(report["findings"][0]["field"], "arguments") + + def test_invalid_date_and_root_are_value_blind(self): + marker = "SYNTHETIC_CLI_PATH_MARKER" + code, _, output = self.capture(["--repo", "/" + marker, + "--rev", "HEAD", "--reference-date", marker]) + self.assertEqual(code, 2) + self.assertNotIn(marker, output) + + def test_module_bootstrap_reports_unsupported_runtime(self): + output = io.StringIO() + with patch("sys.version_info", (3, 10, 99)), redirect_stdout(output): + with self.assertRaises(SystemExit) as caught: + runpy.run_module("project_manifest.__main__", run_name="__main__") + self.assertEqual(caught.exception.code, 2) + self.assertEqual(json.loads(output.getvalue())["findings"][0]["field"], "runtime") + + def test_source_module_cli_exits_and_never_prints_record(self): + # This remains a source invocation, not an installed or vendored command. + owner = FixtureOwner(self) + fixture = GitFixture(owner).seed(files()) + env = os.environ.copy() + env["PYTHONPATH"] = str(ROOT / "src") + env["PYTHONDONTWRITEBYTECODE"] = "1" + result = subprocess.run([sys.executable, "-B", "-m", "project_manifest", + "--repo", str(fixture.path), "--rev", "HEAD", + "--reference-date", "2026-01-01"], env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, timeout=20) + self.assertEqual(result.returncode, 0, result.stderr.decode("utf-8", "replace")) + self.assertEqual(result.stderr, b"") + report = json.loads(result.stdout) + self.assertTrue(report["candidate_declaration_accepted"]) + self.assertEqual(report["vocabulary_state"]["development_policy"], + "owner-frozen-contract-policy") + self.assertEqual(report["vocabulary_state"]["development_policy_approval_date"], + "2026-10-13") + self.assertNotIn("development_vocabulary_approval", report["checks_remaining"]) + self.assertIn("release_binding", report["checks_remaining"]) + self.assertIn("native_task_and_declaration_conformance", report["checks_remaining"]) + self.assertEqual(report["mode"], "rev") + self.assertEqual(report["complete_v1_validity"], "not_checked") + self.assertNotIn("record", report) + self.assertNotIn("project", report) + self.assertEqual(len(report["commit_oid"]), 40) + + def test_deleted_cwd_keeps_explicit_repository_and_coded_default_failure(self): + owner = FixtureOwner(self) + fixture = GitFixture(owner).seed(files()) + with patch("os.getcwd", side_effect=FileNotFoundError("synthetic-cwd-error-marker")): + code, report, output = self.capture(["--repo", str(fixture.path), "--rev", "HEAD", + "--reference-date", "2026-01-01"]) + self.assertEqual(code, 0) + self.assertTrue(report["candidate_declaration_accepted"]) + self.assertNotIn("synthetic-cwd-error-marker", output) + code, report, output = self.capture(["--rev", "HEAD", "--reference-date", "2026-01-01"]) + self.assertEqual(code, 2) + self.assertEqual(report["findings"][0]["code"], "NOT_CHECKED") + self.assertNotIn("synthetic-cwd-error-marker", output) + + def test_cli_requires_source_selection(self): + code, report, _ = self.capture(["--repo", "/private/tmp"]) + self.assertEqual(code, 2) + self.assertEqual(report["findings"][0]["field"], "arguments") diff --git a/vendor/project-manifest/release-1/tests/test_validation.py b/vendor/project-manifest/release-1/tests/test_validation.py new file mode 100644 index 0000000..521b406 --- /dev/null +++ b/vendor/project-manifest/release-1/tests/test_validation.py @@ -0,0 +1,431 @@ +"""C2 public semantics and exact-tree behavior, entirely in memory.""" + +import contextlib +from dataclasses import replace +from datetime import date, datetime +import io +import json +import os +import socket +import subprocess +import time +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).absolute().parent.parent / "src")) +from project_manifest import TreeEntry, TreeSnapshot, check_declaration +from test_candidate import BASE, OPTIONAL_ROLES, STACK, changed + +DATA = BASE.replace(b"role: sample-role\n", b"role: canonical\n").replace( + b" runner: sample-runner\n", b" runner: script\n").replace( + b' target: "check-example"\n', b' target: "tools/check.example"\n').replace( + b" posture: sample-posture\n", b" posture: working-prototype\n").replace( + b" operational_state: sample-state\n", b" operational_state: maintained\n").replace( + b" visibility: sample-visibility\n", b" visibility: internal\n").replace( + b" link_policy: sample-link\n", b" link_policy: no-link\n").replace( + b" owner: sample-activation\n", b" owner: none\n") +TREE = TreeSnapshot(( + TreeEntry("docs", "directory"), + TreeEntry("docs/status.example", "file"), + TreeEntry("tools", "directory"), + TreeEntry("tools/check.example", "file", True), +)) + + +def modified_tree(path, **changes): + return TreeSnapshot(tuple(replace(entry, **changes) if entry.path == path else entry + for entry in TREE.entries)) + + +def with_stack(data=DATA): + stack = STACK.replace(b"instance_scope: sample-scope", b"instance_scope: checkout").replace( + b"port_policy: sample-policy", b"port_policy: loopback_only") + return changed(b" stacks: {}\n", stack, data).replace( + b" host_requirements: []\n", b" host_requirements: [container_engine]\n") + + +STACK_TREE = TreeSnapshot(TREE.entries + ( + TreeEntry("stacks", "directory"), TreeEntry("stacks/compose.example", "file"), +)) + + +class DeclarationTests(unittest.TestCase): + def result(self, data=DATA, tree=TREE, reference_date="2026-01-01"): + return check_declaration(data, tree=tree, reference_date=reference_date) + + def accepted(self, data=DATA, **kwargs): + result = self.result(data, **kwargs) + self.assertEqual(result.exit_code, 0, result.diagnostics()) + self.assertTrue(result.candidate_declaration_accepted) + self.assertIsInstance(result.record, dict) + self.assertEqual(result.diagnostics()["complete_v1_validity"], "not_checked") + self.assertEqual(result.diagnostics()["tree_observation"], "caller-supplied-unverified") + self.assertEqual(result.diagnostics()["vocabulary_state"], { + "manifest_domains": "owner-adopted-generic-snapshot", + "development_policy": "owner-frozen-contract-policy", + "development_policy_approval_date": "2026-10-13", + }) + self.assertNotIn("record", result.diagnostics()) + return result + + def rejected(self, data, code, exit_code=1, **kwargs): + result = self.result(data, **kwargs) + self.assertEqual(result.exit_code, exit_code, result.diagnostics()) + self.assertFalse(result.candidate_declaration_accepted) + self.assertIsNone(result.record) + self.assertEqual(len(result.findings), 1) + self.assertEqual(result.findings[0].code, code) + return result + + def test_valid_normalized_record_and_explicit_replay(self): + first = self.accepted() + self.assertEqual(first.record["canonical"], None) + self.assertEqual(first.reference_date, "2026-01-01") + self.assertEqual(first, self.accepted(reference_date=date(2026, 1, 1))) + self.assertEqual(first, self.result()) + self.assertNotIn("manifest_enum_membership", first.diagnostics()["checks_remaining"]) + + def test_activation_owner_domain_and_rejected_alias(self): + for owner in ("none", "direnv", "mise", "project"): + with self.subTest(owner=owner): + self.accepted(changed(b" owner: none\n", b" owner: " + owner.encode() + b"\n", DATA)) + for owner in ("native", "sample-activation", "python", "unknown"): + result = self.rejected(changed(b" owner: none\n", b" owner: " + owner.encode() + b"\n", DATA), + "ENUM_UNSUPPORTED") + self.assertEqual(result.findings[0].field, "development.environment.owner") + + def test_activation_template_and_secret_launcher_are_independent(self): + tree = TreeSnapshot(TREE.entries + (TreeEntry("docs/environment.example", "file"), + TreeEntry("tools/environment.example", "file", True))) + for owner in ("none", "direnv", "mise", "project"): + for template in (False, True): + for launcher in (False, True): + with self.subTest(owner=owner, template=template, launcher=launcher): + data = changed(b" owner: none\n", b" owner: " + owner.encode() + b"\n", DATA) + if template: + data = changed(b" template: null\n", b' template: "docs/environment.example"\n', data) + if launcher: + data = changed(b" secret_launcher: null\n", b' secret_launcher: "tools/environment.example"\n', data) + self.accepted(data, tree=tree) + + def test_policy_approval_is_not_release_native_or_runtime_acceptance(self): + report = self.accepted().diagnostics() + self.assertEqual(report["vocabulary_state"]["development_policy"], + "owner-frozen-contract-policy") + self.assertEqual(report["vocabulary_state"]["development_policy_approval_date"], + "2026-10-13") + self.assertNotIn("development_vocabulary_approval", report["checks_remaining"]) + for check in ("release_runtime_qualification", "release_binding", + "native_task_and_declaration_conformance", "producer_admission", + "runtime_acceptance"): + self.assertIn(check, report["checks_remaining"]) + self.assertEqual(report["complete_v1_validity"], "not_checked") + self.assertEqual(report["tree_observation"], "caller-supplied-unverified") + + def test_calendar_dates_and_future_boundary(self): + for value in (b"0000-01-01", b"2026-00-01", b"2026-13-01", b"2026-01-00", + b"2026-02-29", b"1900-02-29", b"2026-04-31", b"2026-01-02"): + with self.subTest(value=value): + self.rejected(changed(b' as_of: "2026-01-01"\n', b' as_of: "' + value + b'"\n', DATA), + "DATE_RULE") + for value in (b"0001-01-01", b"2000-02-29", b"2024-02-29", b"2026-01-01"): + self.accepted(changed(b' as_of: "2026-01-01"\n', b' as_of: "' + value + b'"\n', DATA)) + self.accepted(reference_date="9999-12-31") + + def test_reference_date_evidence_not_checked(self): + for value in (None, b"2026-01-01", "2026-1-1", "2026-02-29", "0000-01-01", + "2026-01-01Z", datetime(2026, 1, 1), 1): + with self.subTest(kind=type(value).__name__): + self.rejected(DATA, "NOT_CHECKED", 2, reference_date=value) + + def test_selected_uniform_canonical_and_own_id_rules(self): + self.rejected(changed(b"canonical: null\n", b"canonical: sample-parent\n", DATA), "ROLE_RELATION") + noncanonical = changed(b"role: canonical\n", b"role: component\n", DATA) + self.rejected(noncanonical, "ROLE_RELATION") + self.accepted(changed(b"canonical: null\n", b"canonical: sample-parent\n", noncanonical)) + self.rejected(changed(b"canonical: null\n", b"canonical: sample-project\n", noncanonical), "ROLE_RELATION") + self.rejected(changed(b"peers: []\n", b"peers: [sample-project]\n", DATA), "ROLE_RELATION") + self.accepted(changed(b"peers: []\n", b"peers: [sample-peer]\n", DATA)) + + def test_candidate_platform_domain_is_closed_and_empty_remains_no_claim(self): + for token in ("macos-arm64", "macos-x86_64", "linux-arm64", "linux-x86_64", + "windows-arm64", "windows-x86_64"): + with self.subTest(token=token): + self.accepted(changed(b" platforms: []\n", + (" platforms: [" + token + "]\n").encode(), DATA)) + for token in ("unknown", "macos-unknown", "unknown-arm64", "sample-platform"): + with self.subTest(token=token): + self.rejected(changed(b" platforms: []\n", + (" platforms: [" + token + "]\n").encode(), DATA), "ENUM_UNSUPPORTED") + + def test_list_uniqueness_and_empty_platform_claim(self): + for old, new in ( + (b"peers: []\n", b"peers: [sample-peer, sample-peer]\n"), + (b" platforms: []\n", b" platforms: [sample-platform, sample-platform]\n"), + (b" host_requirements: []\n", b" host_requirements: [android_sdk, android_sdk]\n"), + ): + self.rejected(changed(old, new, DATA), "LIST_DUPLICATE") + self.accepted() + self.accepted(changed(b" platforms: []\n", b" platforms: [macos-arm64]\n", DATA)) + + def test_closed_candidate_runner_vocabulary(self): + self.rejected(changed(b" runner: script\n", b" runner: sample-runner\n", DATA), + "ENUM_UNSUPPORTED") + self.accepted(changed(b" runner: script\n", b" runner: mise\n", DATA).replace( + b' target: "tools/check.example"\n', b' target: "check"\n')) + + def test_task_spelling_and_bounds(self): + data = changed(b" runner: script\n", b" runner: mise\n", DATA) + old = b' target: "tools/check.example"\n' + for value in (b"a", b"a" * 64, b"format:check", b"task_one:task-two", b"true"): + self.accepted(changed(old, b' target: "' + value + b'"\n', data)) + self.rejected(changed(old, b' target: "' + b"a" * 65 + b'"\n', data), "FIELD_BOUND") + for value in (b"Task", b"task value", b"task;other", b"a/b", b":task", b"task:", + b"task::name", b"a--b", b"a__b", b"task$var", b"task(arg)", b"../task"): + with self.subTest(value=value): + self.rejected(changed(old, b' target: "' + value + b'"\n', data), "TARGET_RULE") + + def test_script_target_literal_path_and_bounds(self): + old = b' target: "tools/check.example"\n' + for value in (b"tools/check.example --flag", b"tools/check.example;other", b"tools/check$var", + b"tools/check(arg)", b"tools/.GIT/check", b"tools/check:task"): + self.rejected(changed(old, b' target: "' + value + b'"\n', DATA), "TARGET_RULE") + path = "s" * 255 + tree = TreeSnapshot(TREE.entries + (TreeEntry(path, "file", True),)) + self.accepted(changed(old, b' target: "' + path.encode() + b'"\n', DATA), tree=tree) + self.rejected(changed(old, b' target: "' + b"s" * 256 + b'"\n', DATA), "FIELD_BOUND") + + def test_each_command_role_requires_executable_script(self): + for role in OPTIONAL_ROLES: + with self.subTest(role=role): + data = changed(b" " + role + b": null\n", b" " + role + b":\n" + b" runner: script\n" b' target: "tools/check.example"\n', DATA) + self.accepted(data) + bad = changed(b' target: "tools/check.example"\n', b' target: "missing"\n', + DATA) + # The optional role has a separate missing target. + data = changed(b" " + role + b": null\n", b" " + role + b":\n" + b" runner: script\n" b' target: "missing"\n', DATA) + self.rejected(data, "REFERENCE_MISSING") + self.rejected(bad, "REFERENCE_MISSING") + self.rejected(DATA, "REFERENCE_MODE", + tree=modified_tree("tools/check.example", executable=False)) + + def test_optional_script_roles_require_their_own_executable_mode(self): + good = TreeSnapshot(TREE.entries + (TreeEntry("tools/optional.example", "file", True),)) + bad = TreeSnapshot(TREE.entries + (TreeEntry("tools/optional.example", "file", False),)) + for role in OPTIONAL_ROLES: + with self.subTest(role=role): + data = changed(b" " + role + b": null\n", b" " + role + b":\n" + b" runner: script\n" b' target: "tools/optional.example"\n', DATA) + self.accepted(data, tree=good) + result = self.rejected(data, "REFERENCE_MODE", tree=bad) + self.assertEqual(result.findings[0].field, + "development.commands." + role.decode() + ".target") + + def test_mise_target_rules_in_every_role_do_not_check_native_existence(self): + for role in (b"check",) + OPTIONAL_ROLES: + with self.subTest(role=role): + old = (b' check:\n runner: script\n target: "tools/check.example"\n' + if role == b"check" else b" " + role + b": null\n") + def declare(target): + return changed(old, b" " + role + b":\n runner: mise\n" + b' target: "' + target + b'"\n', DATA) + # These names have no supplied mise task declaration. Validity + # cannot infer existence/effects or execute native project tools. + with contextlib.ExitStack() as stack: + for target in ("builtins.open", "pathlib.Path.read_bytes", "subprocess.run"): + stack.enter_context(patch(target, side_effect=AssertionError("Native task was evaluated"))) + result = self.accepted(declare(b"synthetic_missing:task-name")) + self.accepted(declare(b"a" * 64)) + too_long = self.rejected(declare(b"a" * 65), "FIELD_BOUND") + self.rejected(declare(b"a/b"), "TARGET_RULE") + unsupported = changed(b" runner: mise\n", b" runner: sample-runner\n", + declare(b"check")) + self.rejected(unsupported, "ENUM_UNSUPPORTED") + self.assertEqual(too_long.findings[0].field, + "development.commands." + role.decode() + ".target") + self.assertIn("native_task_and_declaration_conformance", + result.diagnostics()["checks_remaining"]) + + def test_environment_optional_references_and_launcher_mode(self): + tree = TreeSnapshot(TREE.entries + (TreeEntry("config", "directory"), + TreeEntry("config/example.env", "file"), TreeEntry("tools/launcher.example", "file", True))) + data = changed(b" template: null\n", b' template: "config/example.env"\n', DATA) + data = changed(b" secret_launcher: null\n", b' secret_launcher: "tools/launcher.example"\n', data) + self.accepted(data, tree=tree) + self.rejected(data, "REFERENCE_MISSING") + noexec = TreeSnapshot(tuple(replace(e, executable=False) if e.path == "tools/launcher.example" else e + for e in tree.entries)) + self.rejected(data, "REFERENCE_MODE", tree=noexec) + self.accepted() + self.rejected(changed(b" secret_launcher: null\n", + b' secret_launcher: "tools/a launcher"\n', DATA), "REFERENCE_PATH", + tree=TreeSnapshot(TREE.entries + (TreeEntry("tools/a launcher", "file", True),))) + + def test_stack_requirement_scope_policy_and_compose(self): + data = with_stack() + self.accepted(data, tree=STACK_TREE) + self.rejected(data, "REFERENCE_MISSING") + self.rejected(changed(b" host_requirements: [container_engine]\n", + b" host_requirements: []\n", data), "FIELD_RELATION", tree=STACK_TREE) + self.accepted(changed(b" instance_scope: checkout\n", b" instance_scope: host\n", data), + tree=STACK_TREE) + self.accepted(changed(b" port_policy: loopback_only\n", b" port_policy: unpublished\n", data), + tree=STACK_TREE) + self.rejected(changed(b" instance_scope: checkout\n", b" instance_scope: sample-scope\n", data), + "ENUM_UNSUPPORTED", tree=STACK_TREE) + self.rejected(changed(b" port_policy: loopback_only\n", b" port_policy: public\n", data), + "ENUM_UNSUPPORTED", tree=STACK_TREE) + self.rejected(changed(b" host_requirements: []\n", + b" host_requirements: [sample-engine]\n", DATA), "ENUM_UNSUPPORTED") + self.accepted(changed(b" host_requirements: []\n", + b" host_requirements: [android_sdk]\n", DATA)) + + def test_exact_case_lookup_and_directory_nodes(self): + for path in (b"Docs/status.example", b"docs/Status.example", b"docs/status.Example"): + self.rejected(changed(b' status_of_record: "docs/status.example"\n', + b' status_of_record: "' + path + b'"\n', DATA), "REFERENCE_MISSING") + self.rejected(DATA, "REFERENCE_PATH", + tree=modified_tree("docs/status.example", kind="directory")) + self.accepted(tree=TreeSnapshot(tuple(reversed(TREE.entries)))) + self.accepted(tree=TreeSnapshot(TREE.entries + (TreeEntry("unrelated-\u00e9", "file"),))) + + def test_symlink_and_submodule_components_are_never_followed(self): + for kind in ("symlink", "submodule"): + with self.subTest(kind=kind): + self.rejected(DATA, "REFERENCE_PATH", tree=modified_tree("docs/status.example", kind=kind)) + no_children = TreeSnapshot(tuple(e for e in TREE.entries if not e.path.startswith("tools/") and + e.path != "tools") + (TreeEntry("tools", kind),)) + self.rejected(DATA, "REFERENCE_PATH", tree=no_children) + target = modified_tree("tools/check.example", kind=kind, executable=False) + self.rejected(DATA, "REFERENCE_PATH", tree=target) + + def test_git_reservation_and_literal_nonexecutable_paths(self): + for path in (b".GIT/config", b"docs/.Git/status", b"docs/.gIt/status"): + self.rejected(changed(b' status_of_record: "docs/status.example"\n', + b' status_of_record: "' + path + b'"\n', DATA), "REFERENCE_PATH") + data = changed(b' status_of_record: "docs/status.example"\n', + b' status_of_record: "docs/example status"\n', DATA) + self.accepted(data, tree=TreeSnapshot(TREE.entries + (TreeEntry("docs/example status", "file"),))) + + def test_unavailable_or_malformed_tree_is_not_checked(self): + for tree in (None, {}, TreeSnapshot((), False), TreeSnapshot([], True), + TreeSnapshot((object(),)), TreeSnapshot((TreeEntry("", "file"),)), + TreeSnapshot((TreeEntry("/absolute", "file"),)), + TreeSnapshot((TreeEntry("../escape", "file"),)), + TreeSnapshot((TreeEntry("C:/drive", "file"),)), + TreeSnapshot((TreeEntry("a//b", "file"),)), + TreeSnapshot((TreeEntry("a\nb", "file"),)), + TreeSnapshot((TreeEntry("a", []),)), + TreeSnapshot((TreeEntry("a", "other"),)), + TreeSnapshot((TreeEntry("a", "directory", True),)), + TreeSnapshot((TreeEntry("a", "file", 1),)), + TreeSnapshot((TreeEntry(1, "file"),)), + TreeSnapshot((TreeEntry("a/b", "file"),)), + TreeSnapshot((TreeEntry("a", "file"), TreeEntry("a", "file"))), + TreeSnapshot((TreeEntry("a", "symlink"), TreeEntry("a/b", "file")))): + with self.subTest(kind=type(tree).__name__): + self.rejected(DATA, "NOT_CHECKED", 2, tree=tree) + self.rejected(DATA, "REFERENCE_MISSING", tree=TreeSnapshot(())) + + def test_syntax_then_semantics_then_evidence_order(self): + self.rejected(b"bad\n", "VERSION_LINE", tree=None, reference_date=None) + self.rejected(changed(b"canonical: null\n", b"canonical: sample-parent\n", DATA), + "ROLE_RELATION", tree=None) + self.rejected(DATA, "NOT_CHECKED", 2, tree=None) + self.rejected(DATA, "NOT_CHECKED", 2, reference_date=None) + + def test_diagnostics_do_not_disclose_values_paths_or_dynamic_keys(self): + marker = b"synthetic-marker-abcdef" + data = with_stack().replace(b"sample-stack:", marker + b":").replace( + b'"stacks/compose.example"', b'"stacks/' + marker + b'"') + cases = ( + (data, STACK_TREE, "2026-01-01"), + (changed(b" runner: script\n", b" runner: " + marker + b"\n", DATA), TREE, "2026-01-01"), + (DATA, TreeSnapshot((TreeEntry(marker.decode(), "file"), + TreeEntry(marker.decode(), "file"))), "2026-01-01"), + (DATA, TREE, marker.decode()), + ) + for data, tree, day in cases: + stdout, stderr = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + result = self.result(data, tree=tree, reference_date=day) + self.assertIn(result.exit_code, (1, 2)) + self.assertIsNone(result.record) + self.assertNotIn(marker.decode(), json.dumps(result.diagnostics())) + self.assertNotIn(marker.decode(), repr(result)) + self.assertNotIn(marker.decode(), stdout.getvalue() + stderr.getvalue()) + self.assertNotIn("sample-stack", result.findings[0].field) + + def test_all_five_owner_adopted_enum_sets_and_unknown_rejection(self): + cases = ( + (b"role: canonical\n", b"role: ", ("canonical", "component", "satellite", "peer", "predecessor")), + (b" posture: working-prototype\n", b" posture: ", ( + "deployed-system", "public-package", "public-repo", "private-tool", + "course-taught", "published-artifact", "client-deliverable", + "working-prototype", "design-spec", "source-held", "archive-only")), + (b" operational_state: maintained\n", b" operational_state: ", ( + "production-live", "staging-live", "active-build", "maintained", + "paused", "retired", "unverified", "unknown")), + (b" visibility: internal\n", b" visibility: ", ( + "public-promoted", "public-available", "shareable-private", "internal", "archive")), + (b" link_policy: no-link\n", b" link_policy: ", ("link-ok", "no-link")), + ) + for old, prefix, values in cases: + for value in values: + with self.subTest(field=prefix, value=value): + data = changed(old, prefix + value.encode() + b"\n", DATA) + if prefix == b"role: " and value != "canonical": + data = changed(b"canonical: null\n", b"canonical: sample-parent\n", data) + self.accepted(data) + self.rejected(changed(old, prefix + b"synthetic-unsupported\n", DATA), "ENUM_UNSUPPORTED") + self.rejected(changed(b"role: canonical\n", b"role: internal\n", DATA), "ENUM_UNSUPPORTED") + + def test_legacy_forms_remain_invalid_and_dispatchable(self): + for line in (b"spec_version: 0.1\n", b'spec_version: "0.1"\n', b"spec_version: '0.1'\n"): + self.rejected(changed(b'spec_version: "1"\n', line, DATA), "LEGACY_VERSION") + self.rejected(b"# Intro\n" + changed(b'spec_version: "1"\n', line, DATA), "LEGACY_VERSION") + self.rejected(changed(b'spec_version: "1"\n', b"spec_version: 0.10\n", DATA), "VERSION_LINE") + + def test_runtime_floor_guard_is_not_live_minimum_evidence(self): + with patch("project_manifest.validation.sys.version_info", (3, 10)): + self.rejected(DATA, "NOT_CHECKED", 2) + with patch("project_manifest.validation.sys.version_info", (3, 11)): + self.accepted() + + def test_no_filesystem_process_network_or_clock_operations(self): + with contextlib.ExitStack() as stack: + for target in ("builtins.open", "pathlib.Path.read_bytes", "subprocess.run", + "os.system", "socket.socket", "time.time"): + stack.enter_context(patch(target, side_effect=AssertionError("Pure API performed I/O"))) + self.accepted() + + def test_distinct_list_order_is_preserved_with_stack_engine_independent_of_order(self): + cases = ( + (b"peers: []\n", b"peers: ", ("sample-two", "sample-one"), ("peers",)), + (b" platforms: []\n", b" platforms: ", ("linux-x86_64", "macos-arm64"), ("development", "platforms")), + (b" host_requirements: []\n", b" host_requirements: ", ("android_sdk", "container_engine"), ("development", "host_requirements")), + ) + for old, prefix, values, path in cases: + for order in (values, tuple(reversed(values))): + with self.subTest(field=path, order=order): + data = changed(old, prefix + ("[" + ", ".join(order) + "]\n").encode(), DATA) + result = self.accepted(data) + value = result.record + for part in path: + value = value[part] + self.assertEqual(value, list(order)) + if path == ("development", "host_requirements"): + # A stack's engine implication must not sort or reduce the list. + stack = STACK.replace(b"instance_scope: sample-scope", b"instance_scope: checkout").replace( + b"port_policy: sample-policy", b"port_policy: loopback_only") + result = self.accepted(changed(b" stacks: {}\n", stack, data), tree=STACK_TREE) + self.assertEqual(result.record["development"]["host_requirements"], list(order)) + + + +if __name__ == "__main__": + unittest.main() diff --git a/vendor/project-manifest/release_verifier.py b/vendor/project-manifest/release_verifier.py new file mode 100644 index 0000000..372629d --- /dev/null +++ b/vendor/project-manifest/release_verifier.py @@ -0,0 +1,277 @@ +"""Trusted consumer bootstrap; inspect candidate bytes before importing them.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import hashlib +import json +import os +import re +import stat +import sys + +PAYLOAD_FILES = tuple(sorted(( + "LICENSE", "docs/specification-candidate.md", "docs/field-table.md", + "docs/enum-provenance.md", "docs/validation-candidate.md", + "docs/acquisition-candidate.md", "docs/offline-binding-candidate.md", + "src/project_manifest/__init__.py", "src/project_manifest/__main__.py", + "src/project_manifest/candidate.py", "src/project_manifest/validation.py", + "src/project_manifest/acquire.py", "src/project_manifest/cli.py", + "tests/acquisition_fixtures.py", "tests/test_candidate.py", + "tests/test_validation.py", "tests/test_acquisition.py", "tests/test_cli.py", + "tests/run_corpus.py", "tests/compare_loaders.py", +))) +TABLE_NAME = "release.json" +DOMAIN = b"project-manifest-release-table-v1\0" +MAX_TABLE_BYTES = 65_536 +MAX_FILE_BYTES = 1_048_576 +MAX_TOTAL_BYTES = 16_777_216 +HEADERS = { + "format": "candidate-binding-1", "origin": "jefahnierocks/project-manifest", + "spec_version": "1", "release_id": "candidate-1", "python_minimum": "3.11", + "git_minimum": "2.56.0", +} +RELEASE_HEADERS = { + **HEADERS, "format": "project-manifest-release-1", "release_id": "1", + "observation_protocol": "project-manifest-git-observation-1", +} +BINDING_FORMATS = frozenset({"candidate-binding-1", "project-manifest-release-1"}) +DIRECTORIES = frozenset( + "/".join(path.split("/")[:depth]) + for path in PAYLOAD_FILES for depth in range(1, len(path.split("/"))) +) + + +@dataclass(frozen=True) +class VerificationResult: + exit_code: int + findings: tuple[str, ...] + descriptor: dict | None = None + binding_format: str | None = "candidate-binding-1" + + def diagnostics(self): + return { + "scope": { + "candidate-binding-1": "candidate-offline-byte-binding", + "project-manifest-release-1": "project-manifest-release-byte-binding-1", + }.get(self.binding_format, "offline-byte-binding-protocol-unavailable"), + "exit_code": self.exit_code, "findings": list(self.findings), + "bytes_verified": self.exit_code == 0, + "release_approval": "not_checked", "complete_v1_validity": "not_checked", + "producer_admission": "not_checked", "runtime_acceptance": "not_checked", + } + + +class _Failure(Exception): + def __init__(self, code, exit_code=1): + self.code = code + self.exit_code = exit_code + + +def _require(condition, code): + if not condition: + raise _Failure(code) + + +def _fingerprint(info): + return (info.st_dev, info.st_ino, info.st_mode, info.st_nlink, info.st_size, + info.st_mtime_ns, info.st_ctime_ns) + + +def _identity(info): + return info.st_dev, info.st_ino, stat.S_IFMT(info.st_mode) + + +def _open_root(root): + try: + raw = os.fspath(root) + if type(raw) is not str or not raw.startswith("/"): + raise _Failure("ROOT_UNAVAILABLE", 2) + parts = raw.split("/")[1:] + if not parts or any(part in {"", ".", ".."} for part in parts): + raise _Failure("ROOT_UNAVAILABLE", 2) + fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + for part in parts: + observed = os.stat(part, dir_fd=fd, follow_symlinks=False) + _require(stat.S_ISDIR(observed.st_mode), "ROOT_TYPE") + child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=fd) + try: + _require(_identity(observed) == _identity(os.fstat(child)), + "BINDING_CHANGED") + except BaseException: + os.close(child) + raise + os.close(fd) + fd = child + return fd + except BaseException: + os.close(fd) + raise + except (TypeError, ValueError, OSError): + raise _Failure("ROOT_UNAVAILABLE", 2) from None + + +def _namespace(fd, prefix=""): + observed = {} + # Fixed paths bound width/depth before any nonregular payload is opened. + with os.scandir(fd) as entries: + for entry in entries: + _require(len(observed) <= len(PAYLOAD_FILES) + len(DIRECTORIES), "LAYOUT") + name = prefix + entry.name + _require(name in DIRECTORIES or name in PAYLOAD_FILES or name == TABLE_NAME, + "LAYOUT") + info = entry.stat(follow_symlinks=False) + observed[name] = _fingerprint(info) + if name in DIRECTORIES: + _require(stat.S_ISDIR(info.st_mode), "FILE_TYPE") + child = os.open(entry.name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=fd) + try: + _require(_fingerprint(info) == _fingerprint(os.fstat(child)), + "BINDING_CHANGED") + observed.update(_namespace(child, name + "/")) + finally: + os.close(child) + else: + _require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1, "FILE_TYPE") + return observed + + +def _read(fd, name, maximum): + parent = os.dup(fd) + parts = name.split("/") + try: + for part in parts[:-1]: + child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + dir_fd=parent) + os.close(parent) + parent = child + file_fd = os.open(parts[-1], os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW, + dir_fd=parent) + try: + before = os.fstat(file_fd) + _require(stat.S_ISREG(before.st_mode) and before.st_nlink == 1, "FILE_TYPE") + _require(before.st_size <= maximum, "BYTE_BOUND") + chunks, count = [], 0 + while True: + chunk = os.read(file_fd, min(65_536, maximum + 1 - count)) + if not chunk: + break + count += len(chunk) + _require(count <= maximum, "BYTE_BOUND") + chunks.append(chunk) + after = os.fstat(file_fd) + bound = os.stat(parts[-1], dir_fd=parent, follow_symlinks=False) + _require(_fingerprint(before) == _fingerprint(after) == _fingerprint(bound) + and count == before.st_size, "BINDING_CHANGED") + return b"".join(chunks), _fingerprint(before) + finally: + os.close(file_fd) + finally: + os.close(parent) + + +def _object(pairs): + record = {} + for key, value in pairs: + _require(key not in record, "TABLE_FORMAT") + record[key] = value + return record + + +def canonical_table_bytes(record): + """One pure canonical encoder; no filesystem, packaging or approval behavior.""" + return (json.dumps(record, ensure_ascii=True, sort_keys=True, + separators=(",", ":"), allow_nan=False) + "\n").encode("ascii") + + +def _descriptor(data, *, expected_format="candidate-binding-1"): + headers = HEADERS if expected_format == "candidate-binding-1" else RELEASE_HEADERS + try: + record = json.loads(data.decode("ascii"), object_pairs_hook=_object) + canonical = canonical_table_bytes(record) + except (UnicodeError, ValueError, RecursionError, TypeError): + raise _Failure("TABLE_FORMAT") from None + _require(data == canonical and type(record) is dict, "TABLE_FORMAT") + if "format" in record: + _require(record["format"] == expected_format, "TABLE_IDENTITY") + _require(set(record) == set(headers) | {"source_revision", "files"}, "TABLE_FORMAT") + _require(all(record[key] == value for key, value in headers.items()), "TABLE_IDENTITY") + revision = record["source_revision"] + _require(type(revision) is str and bool(re.fullmatch(r"(?:[0-9a-f]{40}|[0-9a-f]{64})", revision)), + "TABLE_IDENTITY") + rows = record["files"] + _require(type(rows) is list and len(rows) == len(PAYLOAD_FILES), "TABLE_FILES") + total = 0 + for path, row in zip(PAYLOAD_FILES, rows): + _require(type(row) is dict and set(row) == {"path", "bytes", "sha256"}, "TABLE_FILES") + _require(row["path"] == path, "TABLE_FILES") + _require(type(row["bytes"]) is int and 0 <= row["bytes"] <= MAX_FILE_BYTES, + "BYTE_BOUND") + _require(type(row["sha256"]) is str and + bool(re.fullmatch(r"[0-9a-f]{64}", row["sha256"])), "TABLE_FILES") + total += row["bytes"] + _require(total <= MAX_TOTAL_BYTES, "BYTE_BOUND") + return record + + +def verify_release(root, *, expected_digest, expected_format="candidate-binding-1"): + """Inspect one explicitly selected byte profile; never approve or import it.""" + if type(expected_format) is not str or expected_format not in BINDING_FORMATS: + return VerificationResult(2, ("BINDING_PROTOCOL",), binding_format=None) + + def result(exit_code, findings, descriptor=None): + return VerificationResult(exit_code, findings, descriptor, expected_format) + if type(expected_digest) is not str or not re.fullmatch(r"[0-9a-f]{64}", expected_digest): + return result(2, ("EXPECTED_DIGEST_UNAVAILABLE",)) + if sys.version_info < (3, 11) or os.name != "posix" or not all( + hasattr(os, flag) for flag in ("O_DIRECTORY", "O_NOFOLLOW", "O_NONBLOCK") + ): + return result(2, ("RUNTIME_UNAVAILABLE",)) + fd = None + try: + fd = _open_root(root) + root_info = _fingerprint(os.fstat(fd)) + layout = _namespace(fd) + _require(set(layout) == set(PAYLOAD_FILES) | DIRECTORIES | {TABLE_NAME}, "LAYOUT") + table, table_info = _read(fd, TABLE_NAME, MAX_TABLE_BYTES) + _require(table_info == layout[TABLE_NAME], "BINDING_CHANGED") + descriptor = _descriptor(table, expected_format=expected_format) + _require(hashlib.sha256(DOMAIN + table).hexdigest() == expected_digest, "EXPECTED_DIGEST") + for row in descriptor["files"]: + data, info = _read(fd, row["path"], MAX_FILE_BYTES) + _require(info == layout[row["path"]], "BINDING_CHANGED") + _require(len(data) == row["bytes"] and + hashlib.sha256(data).hexdigest() == row["sha256"], "PAYLOAD_DIGEST") + _require(_namespace(fd) == layout and _fingerprint(os.fstat(fd)) == root_info, + "BINDING_CHANGED") + rebound = _open_root(root) + try: + _require(_fingerprint(os.fstat(rebound)) == root_info, "BINDING_CHANGED") + finally: + os.close(rebound) + return result(0, (), descriptor) + except _Failure as error: + return result(error.exit_code, (error.code,)) + except (OSError, ValueError, RecursionError, OverflowError): + return result(1, ("BINDING_UNAVAILABLE_OR_CHANGED",)) + finally: + if fd is not None: + os.close(fd) + + +def main(): + parser = argparse.ArgumentParser(description="Trusted candidate byte-binding verifier") + parser.add_argument("--root", required=True) + parser.add_argument("--expected-digest", required=True) + args = parser.parse_args() + result = verify_release(args.root, expected_digest=args.expected_digest) + print(json.dumps(result.diagnostics(), sort_keys=True)) + return result.exit_code + + +if __name__ == "__main__": + raise SystemExit(main()) From 9b95312d3fddab350a4dd6d6ba86ec46b0dfc8a3 Mon Sep 17 00:00:00 2001 From: verlyn13 Date: Sat, 17 Oct 2026 09:38:29 -0800 Subject: [PATCH 2/3] fix(ci): remove checkout credentials before declaration checks Disable checkout credential persistence before the offline declaration reader observes the repository. Release 1 intentionally refuses includeIf config; checkout v6 otherwise leaves its credential includes behind for verification. Add an owned-repository regression proving those includes remain refused, the observer leaves config unchanged, and an ordinary revision observation works before and after removal within the fixture. Keep the release and consumer unchanged. The 24-test focused suite and native gate pass locally. --- .github/workflows/verify.yml | 1 + tests/test_project_manifest.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 2edf7c1..0ebc48f 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -13,6 +13,7 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 + persist-credentials: false - name: Prepare contributor Python id: python uses: actions/setup-python@v6 diff --git a/tests/test_project_manifest.py b/tests/test_project_manifest.py index 5085ebc..3e5b471 100644 --- a/tests/test_project_manifest.py +++ b/tests/test_project_manifest.py @@ -143,6 +143,22 @@ def test_inherited_git_override_does_not_redirect_observation(self): self.assertEqual(result["declaration_checks"], "not_checked") self.assertEqual(before, hashlib.sha256((self.root / ".git/config").read_bytes()).hexdigest()) + def test_checkout_auth_include_refuses_revision_observation(self): + self.invoke("--rev", self.rev) + config = self.root / ".git/config" + original = config.read_bytes() + included = original + ( + '\n[includeIf "gitdir:' + str(self.root / ".git") + '"]\n' + ' path = ' + str(self.root / "synthetic-auth.config") + '\n' + ).encode("utf-8") + config.write_bytes(included) + result = self.invoke("--rev", self.rev, expected=2) + self.assertEqual(result["declaration_checks"], "not_checked") + self.assertIsNone(result["commit_oid"]) + self.assertEqual(config.read_bytes(), included) + config.write_bytes(original) + self.invoke("--rev", self.rev) + def test_missing_git_is_unavailable(self): self.invoke(expected=2, environment={"PATH": str(self.root / "absent")}) From b2fdff752d0f2803078128791bfb8725df2498d5 Mon Sep 17 00:00:00 2001 From: verlyn13 Date: Sat, 17 Oct 2026 09:49:55 -0800 Subject: [PATCH 3/3] fix(lint): document the optional lesson directory argument Scope SC2120 to lesson(), whose directory parameter intentionally defaults to SANDBOX for every built-in caller. Add explanatory comments only, preserving all executable learner code and the existing optional ShellCheck gate. The required native gate passes locally, including all 24 regressions and ShellCheck. The preceding hosted runs passed revision declaration validation and the 24 tests before reporting this warning. --- shelltutor | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shelltutor b/shelltutor index 2d302e1..66805db 100755 --- a/shelltutor +++ b/shelltutor @@ -393,6 +393,8 @@ show_screen() { echo } +# Optional directory argument; built-in lesson calls intentionally use SANDBOX. +# shellcheck disable=SC2120 lesson() { show_screen practice "${1:-$SANDBOX}"