Skip to content

fix(trace): write bundle files with LF regardless of platform - #37

Open
how2how2how2-arch wants to merge 1 commit into
utsab345:mainfrom
how2how2how2-arch:fix/bundle-lf-writes
Open

how2how2how2-arch wants to merge 1 commit into
utsab345:mainfrom
how2how2how2-arch:fix/bundle-lf-writes

Conversation

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Fixes #36

Summary

Makes bundle files byte-identical on every platform, so the digests recorded in integrity.json mean the same thing everywhere.

Two parts, because there are two ways the bytes can change:

  1. The writers. _write_manifest, _write_events and _write_redactions in trace/storage.py now pass newline="". Without it, text mode on Windows turned \n into \r\n for the same trace.
  2. The checkout. A .gitattributes rule, **/*.sftrace/** text eol=lf. Without it, git's default core.autocrlf=true on Windows rewrites the 35 committed bundle files before any test runs, so the recorded digests are already wrong on disk.

+106/-1 across three files.

Why this matters

Bundle digests are SHA-256 over file bytes — trace/integrity.py::_hash_file, export/receipt.py::bundle_digest/file_sha256. So line endings are not abstracted away; they are part of what is hashed. Measured on the committed legacy_v01.sftrace by rewriting its files as CRLF in a temp copy:

files rewritten to CRLF: 3
digest before: 4cdc805dda5e69e84d8327355d16d9eb8b631b15a3c43d078897511eabb7ec5a
digest after : 2d8f91b50e6266e95de9d6c72103aa6d56bfa5c2db66eb3e82fbb080ec3c9dea

And the user-visible version, on a bundle that carries an integrity.json (8 of the 35 do):

# as committed (LF)
$ stepfork validate --verify-integrity langgraph_failure.sftrace
Validation passed.

# same bundle after a CRLF rewrite, i.e. a Windows checkout
$ stepfork validate --verify-integrity langgraph_failure.sftrace
✗ integrity_mismatch
  SHA-256 mismatch.
  redactions.json
Validation failed: 3 issues.        (exit 1)

Note for anyone reading this as "it still works": events.jsonl is JSONL, so a CRLF file still parses. The breakage is in the digests, not in parsing — verified --verify-integrity is opt-in, which is exactly why Linux CI never noticed.

On the .gitattributes pattern

**/*.sftrace/** is the form that works; *.sftrace/* and *.sftrace/**/* do not match nested files. I checked rather than assumed:

$ git check-attr eol -- tests/golden/legacy_v01.sftrace/manifest.json
# *.sftrace/*            -> eol: unspecified
# **/*.sftrace/**       -> eol: lf
# *.sftrace/**/*        -> eol: unspecified

The rule is deliberately narrow. git status shows no bundle file modified after adding it, so there is no renormalisation commit hiding in here, and non-bundle files keep git's default behaviour.

Tests

Three tests in tests/unit/test_storage.py, and I would rather state what each one can and cannot prove than let the count imply more:

test what it shows
test_saved_bundle_files_contain_no_crlf the assertion that fails on Windows if newline="" is dropped. On POSIX, newline translation is a no-op, so it passes before and after here — a guard for the platform CI can reach but this machine cannot
test_bundle_digest_changes_when_a_file_gains_crlf the stake, not the fix: it shows the digest covers line endings. Passes either way, and exists so the requirement is visible to the next person editing the writers
test_gitattributes_pins_bundle_files_to_lf fails on every platform when the rule is removed — verified by deleting .gitattributes and watching it go red, then restoring it and watching it pass

I deliberately did not fake a stronger writer guard. Simulating Windows by patching os.linesep does not reproduce the translation (CPython decides it elsewhere), so an assertion built that way would pass regardless of the defect — the failure mode where a test certifies something it cannot see.

Testing

uv run pytest                    701 passed in 26.77s
uv run pytest tests/unit/test_storage.py    28 passed
uv run ruff check .              All checks passed!
uv run ruff format --check .     196 files already formatted
uv run mypy                      Success: no issues found in 132 source files

Committed bundles still verify after the writer change, which is the check that this did not disturb existing data:

examples/langgraph_agent/regression/langgraph_failure.sftrace -> Validation passed.
examples/real-llm-case-study/traces/incident-triage.sftrace   -> Validation passed.
tests/golden/successful_run.sftrace                           -> Validation passed.

Checklist

  • Tests added/updated
  • pytest passes
  • ruff check passes
  • ruff format check passes
  • mypy passes
  • docs updated if necessary — the reason for newline="" is commented at each writer, and .gitattributes carries the same explanation; no user-facing docs change
  • no secrets/sensitive traces included

Bundle files are hashed as bytes (trace/integrity.py::_hash_file,
export/receipt.py::bundle_digest), but the three writers in storage.py used
text mode without newline="", so on Windows "\n" became "\r\n" and the same
trace produced different bytes and a different digest per platform.

Pass newline="" in _write_manifest, _write_events and _write_redactions, and
add a .gitattributes rule so a Windows checkout cannot rewrite the committed
bundles either (git's default core.autocrlf would corrupt the recorded digests
before any test runs). The rule is narrow: **/*.sftrace/** matches nested
bundle files, while *.sftrace/* and *.sftrace/**/* do not, and no committed
file changes in the working tree as a result.

Adds three tests, documented for what each one can and cannot show: the
no-CRLF assertion fails on Windows if newline="" is dropped but is a no-op on
POSIX, the digest-sensitivity test describes the stake rather than the fix, and
the gitattributes test fails on every platform when the rule is removed.
@utsab345

Copy link
Copy Markdown
Owner

Verified

  • 701 passed for the full suite, and 28 passed in tests/unit/test_storage.py.
  • The .gitattributes rule works: all 35 committed bundle files show i/lf w/lf.
  • One gap remains. write_bundle_integrity in src/stepfork/trace/integrity.py:66 still writes integrity.json without newline="".

Why it matters

export/receipt.py::bundle_digest hashes every file in the bundle, including integrity.json. On Windows, that file will still be written with CRLF, so bundle digests will still differ by platform. I reproduced this by rewriting only integrity.json to CRLF: bundle_digest changed, while verify_bundle_integrity still returned verified, because integrity.json is not hashed by itself.

The new test test_saved_bundle_files_contain_no_crlf checks only manifest.json, events.jsonl, and redactions.json, so it does not catch this.

Small changes

  1. Add newline="" to the write_text call in write_bundle_integrity.
  2. Include "integrity.json" in the loop in test_saved_bundle_files_contain_no_crlf.

Also please rebase onto main, since this branch predates #35 and the new macOS and Windows smoke jobs have not run on it.

This branch has not been deployed

No deployments
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.

Bundle files are not byte-stable across platforms: text-mode writes plus autocrlf break recorded digests

2 participants