Skip to content

test(artifact): cover the RECOTEM_ARTIFACT_ROOT write-time containment call site - #397

Merged
marevol merged 5 commits into
mainfrom
r13/12-artifact-root-coverage
Sep 10, 2026
Merged

marevol merged 5 commits into
mainfrom
r13/12-artifact-root-coverage

Conversation

@marevol

@marevol marevol commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Milestone 2.2.0 — independent of the v2.1.0 stack; merges cleanly onto it (verified with git merge-tree against the stack tip).

Three separate changes:

test(artifact): cover the RECOTEM_ARTIFACT_ROOT write-time containment call site

Removing the _assert_output_root_containment call from _write_atomic passed
the whole suite. Measured against the same tree with only that line replaced
by pass, a directory that becomes a symlink out of the artifact root after
recipe load lets write_artifact complete and land bytes outside the root; with
the guard it raises ArtifactError and writes nothing.

The existing tests missed it two ways. Every other test around this guard
calls _assert_output_root_containment directly, so the helper is covered but
its use is not. The one test that drives _write_atomic accepts
(ArtifactError, OSError), and its own setup rmtree's the directory holding the
temp file, so os.replace raises OSError on a tree with no guard at all -- it
passed on both arms.

Tighten that assertion to ArtifactError matching RECOTEM_ARTIFACT_ROOT and
assert nothing landed outside; the guard runs before os.replace, so on a tree
that has it the ArtifactError always wins. Add two tests through the real
write_artifact entry point -- parent directory symlinked out, and destination
file itself symlinked out. The second is not redundant: a guard pointed at the
temp path instead of at dest still refuses the first case, because the temp
file is created inside the same escaped directory. A control asserts the same
call succeeds against a real in-root directory, so the pair cannot be
satisfied by a write_artifact that refuses everything.

Mutation matrix at the call site: call deleted -> caught (3 tests); guard
watches the temp path instead of dest -> caught (1 test); guard runs only when
dest already exists -> caught (3 tests); unmutated -> 35 passed.

docs(claude-md): the header_len boundary claim overstates when HMAC: OK appears

CLAUDE.md said that moving the 4-byte header_len field "still passes
verify_hmac (recotem inspect prints HMAC: OK) and is caught one layer later by
the header JSON parse or the deserializer".

Measured across ten boundary moves on a real signed artifact, with a tripwire
on SafeUnpickler.load:

  • Only the three shrinking moves (header_len lowered, 0 included) reach
    verify_hmac and print HMAC: OK. The authenticated run is unchanged, so the
    verify passes; the header JSON parse then fails on the truncated slice.
  • All seven enlarging moves are refused earlier, inside
    parse_header_from_bytes, so verify_hmac is never called and HMAC: OK is
    never printed: the widened header slice swallows payload bytes and fails the
    UTF-8 decode, or the file is shorter than the claimed header, or the value
    trips the 64 KiB MAX_HEADER_LEN cap.
  • The deserializer never catches this. The header JSON parse always fires
    first, on the CLI path and on the serve path (ModelRegistry._build_entry
    decodes the header before unpickle_payload).

The load-bearing parts held exactly: every move is exit 5, and the tripwire
fired only for the untouched control. Only the description of which layer
refuses, and of when HMAC: OK is the tell, was wrong. Rewrite that bullet to
split the two directions so nobody reads a missing HMAC: OK as a different
class of failure.

docs(http-fetch): replace the stale :doc:/security reference with the live URL

_is_address_internal's docstring pointed a reader at "MAJOR-3 in
:doc:/security" -- a Sphinx target in the docs/ tree this repo no longer
carries, under an internal audit label that never appeared on the published
page. It is the last :doc: reference left in src/.

Point at https://recotem.org/2.1/docs/security and name the section heading a
reader can actually find, matching how every other cross-reference in the tree
now cites the site.

…t call site

Removing the _assert_output_root_containment call from _write_atomic passed
the whole suite. Measured against the same tree with only that line replaced
by `pass`, a directory that becomes a symlink out of the artifact root after
recipe load lets write_artifact complete and land bytes outside the root; with
the guard it raises ArtifactError and writes nothing.

The existing tests missed it two ways. Every other test around this guard
calls _assert_output_root_containment directly, so the helper is covered but
its use is not. The one test that drives _write_atomic accepts
(ArtifactError, OSError), and its own setup rmtree's the directory holding the
temp file, so os.replace raises OSError on a tree with no guard at all -- it
passed on both arms.

Tighten that assertion to ArtifactError matching RECOTEM_ARTIFACT_ROOT and
assert nothing landed outside; the guard runs before os.replace, so on a tree
that has it the ArtifactError always wins. Add two tests through the real
write_artifact entry point -- parent directory symlinked out, and destination
file itself symlinked out. The second is not redundant: a guard pointed at the
temp path instead of at dest still refuses the first case, because the temp
file is created inside the same escaped directory. A control asserts the same
call succeeds against a real in-root directory, so the pair cannot be
satisfied by a write_artifact that refuses everything.

Mutation matrix at the call site: call deleted -> caught (3 tests); guard
watches the temp path instead of dest -> caught (1 test); guard runs only when
dest already exists -> caught (3 tests); unmutated -> 35 passed.
…OK appears

CLAUDE.md said that moving the 4-byte header_len field "still passes
verify_hmac (recotem inspect prints HMAC: OK) and is caught one layer later by
the header JSON parse or the deserializer".

Measured across ten boundary moves on a real signed artifact, with a tripwire
on SafeUnpickler.load:

- Only the three shrinking moves (header_len lowered, 0 included) reach
  verify_hmac and print HMAC: OK. The authenticated run is unchanged, so the
  verify passes; the header JSON parse then fails on the truncated slice.
- All seven enlarging moves are refused earlier, inside
  parse_header_from_bytes, so verify_hmac is never called and HMAC: OK is
  never printed: the widened header slice swallows payload bytes and fails the
  UTF-8 decode, or the file is shorter than the claimed header, or the value
  trips the 64 KiB MAX_HEADER_LEN cap.
- The deserializer never catches this. The header JSON parse always fires
  first, on the CLI path and on the serve path (ModelRegistry._build_entry
  decodes the header before unpickle_payload).

The load-bearing parts held exactly: every move is exit 5, and the tripwire
fired only for the untouched control. Only the description of which layer
refuses, and of when HMAC: OK is the tell, was wrong. Rewrite that bullet to
split the two directions so nobody reads a missing HMAC: OK as a different
class of failure.
…he live URL

_is_address_internal's docstring pointed a reader at "MAJOR-3 in
:doc:`/security`" -- a Sphinx target in the docs/ tree this repo no longer
carries, under an internal audit label that never appeared on the published
page. It is the last :doc: reference left in src/.

Point at https://recotem.org/2.1/docs/security and name the section heading a
reader can actually find, matching how every other cross-reference in the tree
now cites the site.
@marevol marevol added this to the 2.2.0 milestone Sep 7, 2026
@marevol marevol self-assigned this Sep 10, 2026
The reference added in this branch was written before the dev bump. On the
current tree `check-release-tag.sh` section 4b refuses a tag whose scanned
roots name a documentation line other than the version being released, and
`src` is one of those roots -- so this line would have failed the v2.2.0 tag
after the tag already existed.

The cited section keeps its wording at the new path:
2.2/docs/security.md carries "IPv4-mapped IPv6 inputs are explicitly
unwrapped" as a heading of its own.

Also merges current main into the branch so its checks run against the tree
this will land on rather than a base 34 commits old.
@marevol
marevol merged commit d4177c7 into main Sep 10, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant