Skip to content

Verify the staged project tree once during creation - #151

Merged
Deicyde merged 15 commits into
mainfrom
golf/project-create-verification
Oct 7, 2026
Merged

Deicyde merged 15 commits into
mainfrom
golf/project-create-verification

Conversation

@Deicyde

@Deicyde Deicyde commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #96. Before publishing, project new read the staged tree back three times and compared the plan against a second copy of itself. With this change it verifies the stage once, after its final chmod. It checks the stage root's identity where main does, minus two checks: the one after main's staged validation, which no longer exists, and the one after the final verification, which the check at the parent reopen right after it covers. Main's per-directory checks during materialization are dropped too (see the security claim).

Error codes, messages, the autoform-project-creation/v1 JSON and the publication states are unchanged, except for the accepted trades and the other differences listed under the security claim below.

Also in the first commit:

  • The plan is nested once, by _plan_tree before anything is written; main nested it again in _materialize_project and in each _verify_project_plan run.
  • Release lookup, the artifact-length check, the strict-JSON hooks and the per-call failure messages now share helpers and constants.
  • renameatx_np and renameat2 share one call path; a conditional picks the function name and flag.
  • The stat and S_ISDIR check of the stage entry before its open is gone. The O_DIRECTORY | O_NOFOLLOW open refuses the same entries, with the same code and message.

Security claim

Claim. Against a process running under a different uid, this change keeps every defence main has that affects what gets published. Such a process can at most rename the stage entry, or remove it while it is still empty, which fails creation as on main. The only renames this change misses that main could catch are undone before the parent reopen, so the published tree is the planned one either way. The other detections it drops all need a process running as the same user or as root, and that process can edit the published project after the rename on main too. On macOS, main and this change are both weaker, because neither reads ACLs (see the first reasoning bullet).

Reasoning.

  • Who can change the stage. The stage is created with mode 0700, under our euid, in a parent that _open_parent accepts only if its mode bits are not group- or world-writable, or it is sticky and owned by us or root. On Linux, an ACL that grants write on the parent also sets its group write bit, which the check sees, and entries the stage inherits are masked by its 0700 and later 0755 mode. So there, only our uid or root can chmod the stage root or create, replace or remove anything inside it. Any other uid can at most rename the stage entry, or remove it while it is still empty, and only if it owns the parent. On macOS, ACLs are not read. An inheritable entry on an accepted 0755 parent is copied onto the stage and everything created in it, and it survives the chmod, so the uids it names can change the stage, including between _verify_project_tree and the rename. Main has the same exposure. Refusing a parent that carries an ACL, or checking that the stage has none, is left for a separate change.
  • Renaming the stage entry. _require_stage_identity checks the entry right after the stage is opened, again after the tree is written, and at the parent reopen, and _publication_state covers the rename itself. The codes are main's. A directory owned by another uid fails the first check before anything is written. Before the first write, the opened stage must also be 0700 and empty, so a directory of ours renamed into its place before the open is refused before anything is written into it; main refused it too, before its chmod, but only after writing into it whatever of the tree it could. Only a directory that cannot be told apart from a fresh stage gets through, on main as here, and it is published like one.
  • Changing the contents. Every entry's bytes, mode, type, link count and owner are checked by _verify_project_tree after the last write and sync, and that check gates publication. It is the same strength as main's _verify_project_plan. Main ran it twice more and, during materialization, checked each new subdirectory's identity and every directory's listing; those extra checks are what this change drops.

Accepted trades. Apart from the last, each one needs the same uid or root, per the reasoning above.

  • Stage-root mode. The final fchmod(0o755) of the stage root overwrites a mode change made to it during materialization. Main detected that change.
  • Subdirectory replaced during materialization. This is no longer detected when it happens. The final verification checks the replacement's entries like any others, so it is published only if it matches the plan exactly.
  • Reverted or empty-directory changes. A change made during materialization and reverted before the final verification is missed, and so is a subdirectory swapped for another directory while still empty. Main's intermediate checks could catch some of these; its final check misses them too. In both cases the published tree matches the plan.
  • Stage renamed away and back. The parent's owner can rename the stage entry after the check that follows the write and restore it before the parent reopen without being noticed. Main noticed if the rename was still in effect when its checks after the staged validation or after the final verification ran. The published tree matches the plan. Unless the manifest refusal stops creation first, as it does on main, a rename left in place is refused at the parent reopen, but after the chmod, so the renamed stage is kept at 0755; main kept it at 0700 when the rename came before its check after the staged validation.

Other differences from main. In each one, this change refuses to publish.

  • A template manifest plus a changed stage. The manifest refusal now runs before the tree verification. So when a same-uid process changes the tree inside the stage before the refusal runs, without making a write fail, and main's checks during materialization or its first read-back caught the change, this change reports project-create-validation-failed where main reported project-create-failed. A stage renamed before the check that follows the write, or one that was not 0700 and empty when opened, keeps main's code, because those checks run first.
  • A template directory named lake-manifest.json. Main's check looked only at planned file paths, so with an unlisted pair it published that directory. It is now refused like a template manifest file.
  • A stage renamed after the check that follows the write, plus a parent rebind or an unverifiable parent. This reports project-parent-changed or project-parent-unverifiable, where main's identity check after its staged validation or final verification reported project-create-failed first. Both keep the stage.
  • A directory of ours renamed into the stage name before the open. If it is not 0700 or not empty, it is now refused before anything is written into it and left as it was. Main first wrote into it whatever of the tree it could, then refused it with the same code and message, so a 0755 one could be left holding a readable copy of the tree.
  • A stage that is not 0700 when created. A setgid parent on Linux, or a umask that clears owner bits when running as root, gives the stage another mode. It is now refused before the tree is written, so the kept stage is empty. Main refused it with the same code and message after writing the tree. Without root, such a umask already made main fail at the stage open or the first write, with the same code, message and empty stage.

The descriptor message for release resource names at or above U+E000 was a regression in the first commit and is fixed below, not traded.

Fixes

The second commit restores two checks that the first commit dropped:

  • Template lake-manifest.json. An unlisted toolchain pair again refuses to publish one. _scaffold_plan keeps a root lake-manifest.json from the templates, rendered like any other text template, and with no catalog release nothing collides with it. That commit put the refusal after the final verification and called the order main's; main refuses before its chmod, and a later commit moves it there.
  • Release resource names. Descriptors must again name their manifest resource below U+D800, and names at or above U+E000 get the descriptor message again.

After merging main at 71b251c, eight commits address review findings:

  • Refuse a swapped stage before writing into it. Restores main's identity check between opening the stage and the first write. Without it, when another uid owned the parent, that owner could swap in a directory of its own, and creation wrote the whole tree into it before refusing.
  • Refuse a template manifest while the stage is still private. The refusal runs right after the tree is written and before the chmod, so the kept stage is 0700, as on main. The condition checks one direction only, because a catalog release always plans the manifest and _plan_tree refuses a duplicate. The message is the shared _STAGED_MESSAGE.
  • Test that a template cannot replace a core project file. See Test changes.
  • Say that the parent check does not read ACLs. A comment on _unsafe_parent_metadata, matching the first reasoning bullet.
  • Keep main's workflow pin tail and URL parse guard. _resolve_workflow_pin's tail and _safe_https_git_url are main's code again. The first commit's rewrites returned the same results and only added diff.
  • Refuse a stage renamed while the tree is written. Restores main's identity check after the write. Without it, a stage renamed during the write was chmodded to 0755 before the check at the parent reopen refused it, and a template manifest changed the error to project-create-validation-failed.
  • Refuse a swapped-in stage before writing. The opened stage must be 0700 and empty before the first write. Main also refused a directory renamed into the stage name before the open if it was not 0700 or not empty, but only after writing into it whatever of the tree it could (still before its chmod): one holding an entry failed the listing check at the end of materialization, and one with another mode failed the 0700 root check in its staged validation, unless a write failed first. Without this check, a 0700 directory holding a file was refused only after the chmod had made it 0755, and an empty 0755 directory passed the final verification and was published.
  • Test the stage owner check and the manifest refusal message. See Test changes.

In the meantime, 45fffc4 on this PR moved the manifest refusal before the chmod, as 2a9137c does; 2439944 merges it and keeps this branch's tree. The last commit, Test the manifest refusal's order and its directory case, adds the cases listed under Test changes.

Smaller removals

A third commit reuses the strict-JSON hooks from claims.py and drops checks that cannot fire:

  • an inner try in _parse_release_bundle repeated its caller's handler;
  • the S_ISDIR checks on descriptors opened with O_DIRECTORY and on entries that match such a descriptor by device and inode;
  • a second target-name check;
  • parent.absolute() on a path that is already absolute.

Size

create.py goes from 1493 lines to 1034.

Test changes

  • Patched helper. Tests that patched the removed _validate_staged_project now wrap _materialize_project.
  • test_unlisted_plan_must_not_carry_a_manifest. The first commit removed it, saying the builder cannot produce that plan. That premise was false: the real builder produces it from a template tree with a root manifest. The second commit replaces it with test_an_unlisted_pair_never_publishes_a_template_manifest. The new test uses the real builder and asserts the code, the exact message with the stage notice, that the target is absent, and that exactly one stage remains, holds the manifest and is still 0700. It runs with a manifest file and with a template directory of that name.
  • test_corrupt_core_plan_is_rejected_without_path_based_inspection. Removed. On main, _build_project_plan takes the core files from _core_project_plan, and the staged check re-derives them from the same function. Both versions refuse a template file at a core path as a _plan_tree collision before staging. So only a patched builder could reach that check, and this test patched the builder. test_a_template_cannot_replace_a_core_project_file now pins the collision with the real builder: a template at lean-toolchain, lakefile.toml, lake-manifest.json (with a catalog release) or src/Project.lean is refused with the contracts message before a stage exists.
  • test_injected_validation_failure_preserves_stage_for_safe_recovery. Removed. Two tests now pin the same stage-preserving branch with real errors, and both assert the stage notice: test_requested_parent_rebind_before_publish_preserves_the_stage and the new manifest test.
  • New resource-name test. test_release_metadata_names_its_manifest_below_the_surrogate_range covers U+E000 and an astral character.
  • Plan test. The container and content type cases are removed, and the test is renamed to test_plan_requires_safe_paths_and_file_modes_before_writing.
  • Rename. test_mutation_after_validation_is_not_published is now test_mutated_stage_is_not_published.
  • Stage tests. Three new tests pin the checks made before the first write:
    • test_stage_substitution_is_refused_before_writing swaps the stage entry just after the open, and asserts that neither the opened stage nor its replacement received a file;
    • test_swapped_in_stage_directory_is_refused_before_writing renames either a 0700 directory holding a file or an empty 0755 one (cases nonempty-0700 and empty-0755) into the stage name before the open, and asserts that each is refused unchanged and that the original stage stays empty;
    • test_foreign_owned_stage_is_refused_before_writing makes the stage look owned by another uid, and asserts that it is refused while still empty. An unprivileged test cannot hand the creator a directory owned by another uid, so it patches os.geteuid once the stage exists.
  • Renamed stage. test_workspace_substitution_fails_before_publication also asserts that the renamed stage is still 0700. A second case runs it with an unlisted pair and a template manifest, where the code must stay project-create-failed.

This is independent of the other #96 follow-ups. The branch includes main at 71b251c, with the test-sharing PR (#152). #162 edits the same test file, and the two merge cleanly.

Tests: These ran on e0f31f2 with Python 3.13.14 and pytest 9.1.0 from a venv built from uv.lock; uv run could not resolve the workspace in this checkout. The rest of the suite was left to CI, where all 9 checks pass on e0f31f2.

  • ruff check autoform_cli servers tests (ruff 0.15.17): clean.
  • tests/test_project_create.py: 257 passed, and 257 passed merged with Use _refused in the remaining refusal tests and drop a dead hash-length branch #162.
  • Merged with main at 7fa6d1d: test_project_create.py, test_project_inspect.py, test_skill_examples.py and test_plugin_runtime.py, 514 passed.
  • Each new or tightened test fails with only its check removed:
    • the stage-swap test without the identity check after the open (a file reaches the opened stage);
    • the workspace-substitution test without the identity check after the write (the catalog case's renamed stage is 0755; the template-manifest case's code becomes project-create-validation-failed);
    • its template-manifest case with the manifest refusal moved above that check (the code becomes project-create-validation-failed);
    • one case of the swapped-in-stage test each without the empty check and without the 0700 check, and both cases without either;
    • the foreign-owner test without the owner clause in _entry_matches_descriptor;
    • the template-manifest test with the refusal moved back after the chmod (the kept stage is 0755), or with another message;
    • its directory case with the refusal limited to manifest files, as on main (creation succeeds);
    • all four core-path cases without _plan_tree's duplicate check (creation succeeds).
  • Before the follow-ups, on b2343cb: 247 passed, and both tests added by the second commit failed without it (3 failed).

Same codes, messages, JSON, and publication states for every reachable
input; fewer lines and about a third less wall time per creation.

- Nest the immutable plan once (_plan_tree) and check paths, modes, and
  file/directory collisions before anything is written.
- Verify the stage once, after its final chmod, instead of three full
  read-backs plus an in-memory comparison of the plan against a second
  copy of itself.
- Bind the stage by identity at the parent reopen and after publication;
  the interleaved identity checks were subsumed by those two.
- Fold the release lookup, artifact-length check, strict JSON hooks, and
  per-call failure messages into fewer helpers and shared constants.
- Map renameatx_np/renameat2 through one table.

Tests that patched the removed _validate_staged_project now wrap
_materialize_project. Removed: the two tests that fed the builder output
it cannot produce (a manifest on an unlisted pair, a corrupted core
file compared against itself) and the container/content type cases.
The previous commit dropped two checks that main still performs.

An unlisted toolchain pair must not publish a lake-manifest.json. The
builder adds one only for catalog releases, but _scaffold_plan passes a
root manifest from the templates through unchanged. Refuse after the
final tree verification, as main does, so the populated stage is kept
for recovery.

Release descriptors must name their manifest resource with characters
below U+D800. Names at or above U+E000 were reaching the manifest
loader and failing there with the manifest message instead of the
descriptor message.
- Reuse the strict JSON hooks from claims.py; both callers discard the
  error text, so only the parse rules matter.
- Let _load_release_bundle map JSON errors from _parse_release_bundle;
  its handler catches the same exceptions with the same message.
- Every descriptor passed to _descriptor_identity is opened with
  O_DIRECTORY, and an entry with the same device and inode is that
  directory, so the S_ISDIR checks are dead. The owner check stays.
- The target name is already checked before absolute(), which cannot
  change it, and _open_parent always receives an absolute path.
- The injected validation failure test raised an error that the code
  does not raise at that point. The unlisted template manifest test now
  pins the same stage-preserving branch with a real refusal.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 6, 2026
@Deicyde

Deicyde commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

One fix is required before this is ready. The unlisted-template-manifest refusal now happens after fchmod(stage, 0755), so it leaves a populated retained stage whose generated/template files are readable by other local UIDs. Current main rejects that case while the stage is still 0700; this contradicts the PR's “private sibling” and preserved cross-UID-defense claims.

Move the manifest-membership check immediately after materialization and before the root chmod, while keeping the same error code/message and populated-stage recovery behavior. Add an assertion that the retained stage is 0700. The single final verification can still remain only on publishable trees. Everything else reviewed cleanly; 247 focused tests, Ruff, and all nine CI checks pass.

@Deicyde Deicyde added the awaiting author Review is complete and author action is required label Oct 6, 2026
The first commit dropped the identity check main runs between opening
the stage and materializing it. That left the first owner check in the
final verification and the first entry check just before publication.
_open_parent accepts a parent that another uid owns, as when root creates
the project in a user's directory, and that owner can rename the stage
entry and put its own directory in its place before the open. Creation
then wrote the whole tree into the replacement before refusing it; main
refuses before writing anything.

Restore main's _require_stage_identity call after the open. It raises
main's code and message, and the existing handler adds the stage notice.

The test swaps the stage just after the open, which the check also
catches. An unprivileged test cannot hand the creator a directory owned
by another uid, and a same-uid directory swapped in before the open
matches its descriptor. The test asserts that neither directory receives
a file.
The second commit placed this refusal after the final tree verification,
saying main does the same. Main refuses from _validate_staged_project,
before its chmod, so the populated stage it keeps for recovery is still
0700. Here the kept stage was already 0755.

Move the refusal to just after materialization, before the chmod. It
keeps its code, its message, and the populated stage. Its condition now
checks one direction only: with a catalog release the plan always holds
lake-manifest.json, and _plan_tree refuses any template entry that
collides with it. The message, which the roadmap check shares, becomes
_STAGED_MESSAGE beside _CONTRACTS_MESSAGE.

The template-manifest test now requires the kept stage to be 0700.
The first commit dropped main's comparison of the staged core files
with a freshly built core plan. A template at a core path still fails,
because _plan_tree refuses two plan entries at one path, but nothing
pinned that. Add a case per core path, each refused with the contracts
message before a stage exists.
_unsafe_parent_metadata trusts a parent from its mode bits and owner.
On macOS an inheritable ACL on an accepted 0755 parent is copied onto
the 0700 stage and survives its chmod, so another uid can change the
stage. Main has the same exposure. Say so where the trust rule is
stated, rather than leave the comment implying that only the invoking
user and root can change the stage.
The first commit rewrote both without changing their results, so the
rewrites only add diff. plugin_pin returns strings and
_normalize_autoform_source returns None or a non-empty string, so main's
_resolve_workflow_pin tail picks the same source and ref. In
_safe_https_git_url the widened try covers only urlsplit property reads,
which do not raise on Python 3.10 or 3.13.
Main checked the stage's identity again after materialization, before the
chmod. Without that check, a stage renamed during the write reached
fchmod(0o755) and the manifest refusal first, so the renamed tree became
readable and a template manifest changed the error code.
@Deicyde Deicyde removed the awaiting author Review is complete and author action is required label Oct 6, 2026
@Deicyde

Deicyde commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

The review blocker is fixed at 45fffc47: manifest membership is checked immediately after materialization while the populated stage remains 0700, before the final chmod/verification. The recovery test now asserts the retained stage mode. Focused regression, Ruff, and diff check pass; exact-head CI is rerunning.

@Deicyde
Deicyde marked this pull request as ready for review October 6, 2026 08:16
@Deicyde Deicyde added the review: ready Review complete with no known merge blockers label Oct 6, 2026
@Deicyde

Deicyde commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Final exact-head review at 45fffc47: ready for human review. The invalid-manifest refusal now occurs before fchmod(0755), so a retained populated recovery stage remains private at 0700; the regression test pins that invariant. All 247 focused project-creation tests, Ruff, and git diff --check pass, all 9 GitHub checks are green, and GitHub reports MERGEABLE/CLEAN against current main.

Main refused a directory renamed into the stage name before the open,
but only after writing the tree into it: one holding an entry failed
the listing check at the end of materialization, and one with another
mode failed the 0700 root check in its staged validation. This branch
verifies once, after the chmod, so an empty 0755 directory passed and
was published, and a 0700 one holding a file was refused only after
the chmod had made it 0755. Check before the first write that the
opened stage is 0700 and empty.
No test failed when the owner clause in _entry_matches_descriptor was
removed. The manifest test now pins the exact message, not a substring.
45fffc4 moved the template-manifest refusal before the chmod, which
2a9137c already does; keep this branch's version.
The workspace-substitution test gains an unlisted pair with a template
manifest. The identity check after the write runs first, so the code is
main's project-create-failed; with the refusal moved above that check it
becomes project-create-validation-failed. The template-manifest test
gains a template directory named lake-manifest.json, which main
published and this branch refuses. The swapped-in stage cases get
readable IDs.
@Deicyde Deicyde removed the review: ready Review complete with no known merge blockers label Oct 6, 2026
@Deicyde
Deicyde marked this pull request as draft October 6, 2026 17:03
@Deicyde

Deicyde commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Published the independently audited fast-forward at e0f31f26. It restores the reachable pre-write stage identity/owner/0700/emptiness checks and the post-materialization identity check without adding another full-tree verification. Six substitution regressions fail at 45fffc47 and pass here; 257 focused tests, Python 3.10/3.13 repetitions, Ruff, and diff checks pass. Keeping this draft until exact-head CI finishes; then it should be squash-merged.

@Deicyde Deicyde added the review: ready Review complete with no known merge blockers label Oct 6, 2026
@Deicyde
Deicyde marked this pull request as ready for review October 6, 2026 17:25
@Deicyde

Deicyde commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Final gate at exact head e0f31f26: all 9 GitHub checks pass, including both real-Lean and Windows jobs. The focused project-creation suite reports 257 passing tests; six genuine pre-write substitution regressions fail on prior head 45fffc47 and pass here; repeated Python 3.10/3.13 checks, Ruff, and diff checks are clean. Three independent agent audits found no blocker. Ready for human review; squash-merge after approval.

@Deicyde
Deicyde merged commit 70e7781 into main Oct 7, 2026
9 checks passed
@Deicyde
Deicyde deleted the golf/project-create-verification branch October 7, 2026 03:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. review: ready Review complete with no known merge blockers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant