Skip to content

[RAPTOR-19525] fix(workload): sync upload integrity — hash-while-streaming, Phase 6 write order, zip overwrite form field - #867

Open
ajalon1 wants to merge 20 commits into
mainfrom
aj/raptor-19525-integrity-fixes
Open

[RAPTOR-19525] fix(workload): sync upload integrity — hash-while-streaming, Phase 6 write order, zip overwrite form field#867
ajalon1 wants to merge 20 commits into
mainfrom
aj/raptor-19525-integrity-fixes

Conversation

@ajalon1

@ajalon1 ajalon1 commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

RATIONALE

RAPTOR-19525 reported that dr artifact code sync silently skips changed files when size and mtime are unchanged. Investigation proved that mechanism has never existed in this codebase (detection is content-hash only; Diff receives only hashes, hashEntries rehashes every file every run, fileops.Entry carries no size or mtime). The investigation did surface real integrity gaps, and this PR fixes them. This is the fixes half of a two-PR stack; the follow-up PR adds the --verify flag and symlink surfacing on top.

Three distinct defects are fixed here:

  1. Zip-path re-uploads silently stored as name (2).ext (the reporter's most likely symptom). The CLI sent overwrite=REPLACE as a URL query parameter (internal/drapi/filesapi/fromfile.go), but the server's /files/<catalogID>/fromFile/ route reads overwrite only from the multipart form body and silently defaults to RENAME when the form field is absent (monorepo MLData/files/public_api/files/validators.py). Every re-uploaded file was therefore stored as name (2).ext: the original path kept the old bytes while the manifest recorded the new hash, so a following plain --dry-run falsely printed Up to date. — the reported symptom. Reproduced race-independently (20 of 22 files). Only projects taking the zip path (>20 files or >50 MiB) were affected; the per-file stage route was unaffected because the CLI sends overwrite as a JSON body there, which the server does read. Fixed by moving overwrite into the multipart form body (fields framed before the file part, Content-Length stays byte-exact).

  2. Torn reads: the manifest could record a hash of bytes the server never received. Hashing happened at plan time, hours or milliseconds before the upload read; a file rewritten mid-stream (build artifacts, logs) produced a manifest entry describing neither the old nor the new server content. Fixed by hashing while streaming: io.TeeReader on the stage path, io.MultiWriter on the zip path, so the manifest records exactly the bytes that went over the wire.

  3. Phase 6 wrote config before manifest, so a crash between the two individually-atomic writes left config.json advanced past a stale manifest.json — silent, permanent BASE poisoning. Reordered to manifest-before-config so the surviving (non-transactional) window only ever fails safe: an advanced manifest with a stale config makes the next sync detect drift and rebuild BASE from the real remote.

The governing invariant throughout: BASE (manifest.json) must describe the REMOTE, not the local disk.

Also included: the supporting test infrastructure (a self-consistent fake Files API client that models server state, with fault-injection hooks) and regression suites pinning all of the above, including a codified refutation of the ticket's claimed size/mtime mechanism (a same-size, same-mtime content change via os.Chtimes restoration IS detected).

CHANGES

  • 398e2e57 test: rework fakeFilesClient into a self-consistent server model
  • 8da0d086 fix: hash uploads while streaming so the manifest records what was sent
  • 95de8b9b test: cover zip uploader hash-while-streaming
  • 0b2723d0 test: prove failed/partial uploads never advance persisted state
  • ad7b90c2 fix: write manifest before config in Phase 6
  • 4204464d test: guard sync surroundings against regression
  • 7d8ebb5e test: lock in content-hash-only change detection
  • 630e2f40 fix: send zip-path overwrite as a multipart form field

TESTING

  • task test (full suite, -race -shuffle=on) and task lint (GOOS=linux/darwin/windows) green.
  • Validated end-to-end against live staging: incremental zip re-uploads now replace in place (22/22 paths hold the new bytes; three-way agreement between disk shasum -a 256, manifest, and server fileChecksum; zero name (2).ext artifacts; a following --dry-run truthfully prints Up to date.).
  • Every new test was confirmed to fail for the right reason against the pre-fix code.

NOTES

  • The server implements its documented OpenAPI contract; the zip defect was on the CLI side. A separate hardening ask to the files-API owners (reject or honor query-param overwrite rather than silently defaulting to RENAME) is being filed independently.
  • Draft: opened early for team feedback per repo workflow.

Note

Medium Risk
Changes core sync upload and manifest persistence paths (zip overwrite wire format, streamed hashing, write ordering); behavior fixes are high-impact but heavily regression-tested.

Overview
Fixes three workload sync integrity bugs and adds regression coverage around upload and state persistence.

Zip re-uploads (overwrite). UploadFromZipExisting no longer sends overwrite in the query string (the server ignores it and defaults to RENAME). The value is sent as a multipart form field before the file part; streaming multipart framing was extended to support optional form fields with correct Content-Length.

Manifest matches bytes on the wire. Stage and zip upload paths hash while streaming (TeeReader / MultiWriter). Phase 5 stores an UploadOutcome with per-path Sent hashes; Phase 6 builds manifest.json from those streamed hashes and errors if any upload path lacks a Sent entry (no fallback to plan-time hashes).

Safer Phase 6 persistence. manifest.json is written before config.json so a partial failure does not advance config ahead of a stale manifest.

Tests. The in-memory Files API fake models server versions, REPLACE merge, zip extraction, pagination, and fault injection; large regression suites cover dry-run, failures, concurrency, classification, and detection behavior.

Reviewed by Cursor Bugbot for commit 630e2f4. Configure here.

@datarobot-pr-review-router

Copy link
Copy Markdown

🎫 Jira: RAPTOR-19525 — dr artifact code sync silently skips changed files when size and mtime are unchanged

@github-actions github-actions Bot added the go Pull requests that update go code label Aug 28, 2026
@ajalon1
ajalon1 marked this pull request as ready for review August 29, 2026 05:33
@ajalon1
ajalon1 requested review from a team as code owners August 29, 2026 05:33

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 630e2f4. Configure here.


// buildNewBaseManifest computes NEW_BASE = REMOTE + uploads (local hashes)
// - deletes, with conflicts resolved as remote-wins.
func buildNewBaseManifest(e *Engine, syncedVersionID string, syncedAt time.Time) wapi.Manifest {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale rollback undoes fail-safe reorder

High Severity

Writing manifest.json before config.json is only fail-safe if the next run does not restore Phase 5 backups. On SaveConfig failure, Discard never runs, so Phase 0's stale-rollback restore rewinds downloaded or conflict-resolved files. The advanced manifest then classifies those restored bytes as local edits and uploads them over the remote.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: Bugbot Rules for DataRobot CLI

Reviewed by Cursor Bugbot for commit 630e2f4. Configure here.

@ajalon1
ajalon1 force-pushed the aj/raptor-19525-integrity-fixes branch from 630e2f4 to 440767c Compare August 29, 2026 05:44

@wojtekwdr wojtekwdr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with some comments

Comment thread internal/workload/sync/phase6_state.go Outdated
// still names the old version. The version mismatch makes the next
// sync detect drift and fetch the remote; BASE (the advanced
// manifest) truthfully describes that remote, so the plan is empty
// and the run merely converges config. The rollback dir is already

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] This clause doesn't hold. If SaveConfig fails, the next sync's plan is empty, and an empty plan returns before Execute in Run (engine.go:207) and in both codesync paths (cmd.go:233 and :290), so Phase 6 never runs and config keeps the old version until some later sync has real work to do. TestSaveConfigFailure_ManifestAdvanced_NextSyncResyncs only shows convergence because it calls Execute(plan) directly right after asserting the plan is empty, which no production caller does.

@ajalon1 ajalon1 Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed. however

Follow-up design observation that fell out of addressing this: after a SaveConfig failure, if the project's disk contents never change again, every subsequent sync pays a drift-triggered AllFiles round-trip and config.json stays stale indefinitely — not just "until some later sync has real work". It is safe (BASE advanced + stale config re-triggers drift detection every run and self-heals at the first sync with real work; the reverse write order would be silent permanent poisoning), so we left the behavior as-is and corrected the comment/tests to describe it honestly.

A narrowly-scoped alternative would be a config-only Phase 6 reconciliation when drift was detected AND the plan is empty — but that cuts against the pinned invariant that an empty plan makes no state writes, so it felt like a team decision rather than something to fold into this PR. Happy to file a follow-up ticket if you think the perpetual round-trip is worth closing.

// runs in Phase 2 (manifests), before Phase 5 (execute) where uploads happen.
// We cannot create case-colliding files on macOS, so we verify the check
// position by confirming that a Plan with no case collisions succeeds and
// the fake's upload counters are zero (Plan does not execute).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[medium] This doesn't test what the name says. I deleted the case-collision check from phase2_manifests.go and the whole sync package still passed, this test included. Zero upload calls follows from Plan never calling Execute, which is true for every plan, so nothing here pins where the check runs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really need to use a better model for validation. :D

"case-folded system exclude must still be excluded")
}

// TestCaseFoldedIgnorePatterns verifies that case-folding of ignore patterns

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] The header says a .drignore pattern BUILD/ must exclude a directory named build/, but the comment inside the body and the assertion at the end say the opposite: user patterns are case-sensitive and *.TMP doesn't match scratch.tmp. Worth fixing the header, otherwise someone grepping for case-folding coverage lands on the test that guarantees its absence.

assert.Equal(t, 1, manifest.Version, "manifest version must stay 1")

// Verify the manifest has exactly the expected top-level keys by reading
// the raw JSON and checking no unexpected fields were added.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] assert.Contains can't catch an added field, so this checks the opposite of what the comment claims. Adding "newField": 1 to either file leaves all six assertions passing. Unmarshalling into a map[string]any and comparing key sets would actually pin the schema.


// Get the files for the requested version from server state.
files, ok := f.versions[versionID]
if !ok {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] An unregistered version reads as an empty server here, so a fixture typo in a version ID produces a plausible-looking plan (every base entry REMOTE_DELETED, every local file LOCAL_ADDED) instead of an error. DownloadFile twenty lines down already fails loudly for exactly this reason. I tried making this return an error and nothing in the package cared, so it looks free.

// restore resurrects pre-sync bytes as phantom local edits which the next
// sync silently re-uploads over the remote.
func TestPhase6DiscardFailure_AbortsBeforeStateWrites(t *testing.T) {
if runtime.GOOS == "windows" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] nit: the chmod fault is inert as root, so this fails rather than skips in a root container. The repo's three other chmod-based fault tests pair the windows guard with os.Geteuid() == 0 (fsutil_test.go:82, wapi/migrate_test.go:107, workload/del/cmd_test.go:255).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, good call.

ajalon1 and others added 20 commits August 31, 2026 19:44
…stent server model

Replace the shallow fakeFilesClient stub with an in-memory server model that
records per-path content and SHA-256, models stage REPLACE merge semantics,
supports configurable AllFiles pagination, returns numFiles counting all
files in the resulting version, and exposes per-method call counters and
fault-injection hooks. Guard every shared map with a mutex so -race is clean
under 4-way concurrent uploads.

Add a syncedProject helper that builds a temp project tree with initialized
workload state (config.json + manifest.json matching file hashes) for reuse
by later features.

Add contract tests for VAL-UPLOAD-015 (empty plan issues zero upload-side
calls) and VAL-UPLOAD-017 (dry-run issues zero upload-side calls), with a
positive control proving the counters have teeth.

Zero production (non-_test.go) files modified.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…ifest records what was sent

The manifest recorded the Phase-2 planned hash for uploaded files, not the
hash of the bytes actually sent. A file rewritten between plan and upload left
BASE describing content the server never received, and the next sync then
reported "Up to date." forever — the TOCTOU poisoning reproduced 3/3 through
the real binary.

ApplyUploads now returns UploadOutcome{CatalogID, VersionID, Sent} where Sent
carries the per-path SHA-256 and size of the bytes that crossed the wire. The
stage path wraps the open file in io.TeeReader(f, sha256.New()) and derives
content-length from f.Stat() on the already-open handle (never a fresh
os.Stat). The zip path wraps the archive entry writer in io.MultiWriter(w,
sha256.New()) and takes the streamed size from io.Copy's return. The shared
hash-while-streaming piece is factored into hashstream.go so jscpd stays quiet.

Phase 6 seeds each uploaded path's manifest entry from outcome.Sent[path] and
hard-fails with an error naming the path if an entry is missing — a per-path
fallback to the Phase-2 hash is the original bug and must not exist anywhere.

The Engine struct gains an uploadOutcome field as the channel between Phase 5
and Phase 6 (runPhases has no per-phase return threading). Manifest schema stays
at version 1; no new fields are added to manifest.json or config.json.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Add internal/workload/sync/upload_zip_test.go, the first test file for
the zip uploader, covering the archive path's hash-while-streaming
behaviour introduced by upload-outcome-streamed-hashes.

Tests:
- TestZipBuild_HashesStreamedBytes: a normal multi-file archive whose
  per-path Sent hashes equal the SHA-256 of the file contents, with a
  genuine read-back (open the produced archive, extract entries, hash
  the extracted bytes and confirm they match the recorded Sent hash).
- TestZipBuild_SameSizeContentChange: a file rewritten to different
  bytes of the SAME size between plan and addToZip, asserting the Sent
  hash is the archived bytes' hash and not the Phase-2 planned hash,
  with read-back confirming the archive contains the build-time bytes.
- TestZipBuild_SizeChange: grow and shrink cases, asserting Sent size
  equals the byte count that entered the archive (io.Copy's return)
  rather than fa.LocalSize, with hash and size self-consistent and
  read-back confirming the extracted bytes match.
- TestStageAndZipProduceIdenticalSent: given the same project tree, the
  stage path and the zip path produce identical Sent maps (same hashes
  and sizes for every file), so Phase 6 would write identical manifests.

Read-back approach: call buildZip (the production function), open the
archive it returns the path to, and hash the extracted entries. This
asserts "the archive contains what we claimed" rather than "we hashed
something."

Every test was proven to fail first by temporarily reverting
addToZip's hash-while-streaming (all hash assertions failed) and
buildZip's streamed-size usage (size assertions failed with the
planned size instead of the streamed size). Both reverts were
restored; no production code was modified.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…ance persisted state

Add regression coverage proving that a failed or partial upload never
advances manifest.json or config.json, and that the Phase 6 hard-fail on a
missing Sent entry actually holds. Eleven new test cases across four files:

upload_failure_test.go (VAL-UPLOAD-011, VAL-UPLOAD-013, VAL-UPLOAD-021):
- Missing Sent entry: buildNewBaseManifest hard-fails naming the path;
  phase6State does not write manifest.json. Also documents the
  SaveConfig-before-SaveManifest ordering hazard (config IS advanced
  when buildNewBaseManifest fails — not reachable through normal
  operation but a real defect, reported in handoff).
- Partial Sent from worker error: uploadFilesParallel returns an error,
  Phase 6 never runs, state untouched.
- One file fails late, all files fail, ApplyStage fails: each leaves
  manifest.json byte-identical, config.json unchanged, working tree
  rolled back, ApplyStage not called (or called but not persisted).
- File deleted between Plan and Execute: error names the path, no panic,
  manifest/config unchanged, remaining files not applied.

upload_concurrency_test.go (VAL-UPLOAD-012):
- 4-way concurrent upload of 13 mixed-size files (0-byte, small,
  chunk-boundary 32 KiB) under -race: no race, exactly one Sent entry
  per file, no duplicate upload, all hashes match content.

dry_run_integrity_test.go (VAL-UPLOAD-016):
- Dry-run with pending changes: manifest.json content AND mtime
  unchanged, config.json unchanged, zero upload calls, zero AllFiles
  calls. Notes in comments what remains for staging validator (real
  mtime across a process invocation).

interruption_test.go (VAL-CROSS-011):
- Simulated mid-upload interruption via fault injection: first sync
  exits non-zero without advancing state, rollback directory remains;
  second sync restores stale rollback via Phase 0 and converges to
  a manifest matching the fake's server state. Notes in comments what
  remains for staging validator (real SIGINT delivery).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
… so config never advances ahead of manifest

Phase 6 previously wrote config.json before manifest.json. A SaveManifest
I/O failure (reachable in normal operation) left config advanced past a
stale manifest — the manifest-poisoning shape this mission exists to close:
the next sync sees no drift, fast-paths, copies the stale BASE to REMOTE,
and reports "Up to date." forever.

Reorder to buildNewBaseManifest → SaveManifest → SaveConfig. The failure
direction is now safe: a SaveConfig failure leaves the manifest advanced
and config stale, so the next sync detects drift, fetches AllFiles, and
rebuilds BASE from real remote data — self-healing. The reorder is
data-safe: nothing between the two writes reads config from disk.

Flip TestMissingSentEntry_Phase6DoesNotAdvanceManifest to assert config is
NOT advanced (was asserting it IS advanced as a known hazard). Add
TestSaveManifestFailure_DoesNotAdvanceConfig and
TestSaveConfigFailure_ManifestAdvanced_NextSyncResyncs to guard both
failure directions.

The write pair is still not transactional: each write is individually
atomic via fsutil.AtomicWriteFile, but a crash between two successful
writes still leaves one advanced. This residual risk is documented in
library/phase6-write-ordering.md for the findings writeup. No
transactionality mechanism is introduced.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…tion

Add regression tests that would fail loudly if a size+mtime fast path
(the mechanism RAPTOR-19525 alleged) were ever introduced:

- TestSameSizeSameMtimeDetected: rewrites app.py to different bytes of
  identical length with mtime restored via os.Chtimes, asserts it appears
  in the upload plan — the codified refutation of the ticket's claim.
- TestDetectionIsContentOnly: 2x2 matrix over {size matches manifest,
  size differs} x {content matches, content differs}, asserting detection
  fires iff content differs in every cell.
- TestDetectionControls: true-positive controls (size+content change,
  4 MiB large-file change) and false-positive controls (untouched file,
  mtime-only advance).
- TestPlanRowSizesMatchDisk: each upload plan row's displayed byte size
  equals the real on-disk size at plan time.
- TestClassifyTruthTable: comprehensive table over every (base, local,
  remote) hash triple covering all fourteen classifications and their
  mapped actions, transcribed from the current Classify implementation.
- TestClassifyActionMappingIsExhaustive: every classification has a
  defined non-default action.

Every detection test was proven to fail against a deliberately stubbed
size+mtime fast path before being accepted. The false-positive controls
were proven to fail against a mark-everything-dirty stub. The truth table
was proven to catch a changed action mapping.

No production code changes. Only *_test.go files added.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…sion

Add regression coverage for the behaviour immediately surrounding the
upload-integrity fix so it cannot regress silently. Covers ignore rules
(.drignore exclusion, .wapiignore legacy shadow warning, system excludes,
case-folding), case-collision detection before any upload, path
normalization (forward slashes, NFC, no leading ./ or trailing slash)
in both manifest build and base/remote comparison, plan action mapping
(local-deleted → upload-delete, remote-modified → download, both-sides
→ conflict, remote-wins resolution), dr workload up's code-change
measurement (modified tree count, unchanged tree zero, first-deploy
flag for unlinked project), the sync lock (second concurrent sync
rejected, stale-rollback recovery, forward state migration).

22 new test cases across two files. Every representative test was proven
to fail against a deliberately broken production line before being
accepted. Full task test green; task lint clean on linux, darwin, and
windows. No production files touched.

Fulfills VAL-REGRESSION-007, VAL-REGRESSION-008, VAL-REGRESSION-009.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…form field

The zip-path incremental sync (UploadFromZipExisting) sent overwrite=REPLACE
as a URL query parameter, but the server's /files/<catalogID>/fromFile/ route
binds its validator fields from the multipart form body only and silently
defaults to RENAME when the form field is absent. Every re-uploaded path was
therefore stored as "name (2).ext" while the original path kept its stale
bytes, and the manifest recorded the new streamed hash at the original path —
so every later sync falsely reported "Up to date." while the server served
old content.

Move overwrite into the multipart form body. newStreamingMultipartRequest now
frames extra form fields as complete parts BEFORE the file part (fields-first
lets streaming parsers collect parameters without buffering the file), with
field names sorted for deterministic framing; Content-Length stays exact
because the fields fold into the prologue (verified byte-for-byte by test).

useArchiveContents stays in the query string: the monorepo validator
(MLData/files/public_api/files/validators.py, AddFilesIntoContainerBaseValidator)
declares use_archive_contents as a form field whose default is 'True' — the
server defaults to archive extraction and never reads request.args, so the
query flag is ignored and extraction happens regardless. Moving it would
change nothing observable; the test pins that decision.

UploadFromZipNew is unchanged apart from the shared framing helper: a first
sync has no pre-existing paths, so the server's RENAME default is correct and
it sends no overwrite field.

Verified end to end against staging: 25-file first sync (zip path), 22-file
incremental re-sync (zip path) — all 22 re-uploaded paths hold the new bytes
(disk shasum == manifest hash == server fileChecksum), zero "name (2).ext"
entries, unmodified controls intact, and a following plain --dry-run
truthfully prints "Up to date.".
…hree regression test files

The Copyrights CI check flagged surroundings_regression_test.go,
upload_failure_test.go, and codechange_regression_test.go. Each carried a
header that was silently corrupted during an edit: two had the license URL
truncated to licenses/2.0 and one had dropped "in" from "except in
compliance with the License". All three now byte-match the canonical header
used by their sibling test files, and license-eye reports 0 invalid files.
…try, before any state write

phase6State assigned its rollback.Discard() to the very bottom, after
SaveManifest and SaveConfig. A SaveConfig failure returned early and
stranded the rollback dir holding pre-sync bytes. The next run's Phase 0
(RestoreStaleIfPresent) blindly copied those bytes back into the working
tree; against the manifest the failed run had already advanced, the diff
classified them as local edits, and the sync silently re-uploaded them
over the remote — a self-perpetuating corruption chain with no prompt.

Discarding at Phase 6 entry is safe: e.rollback is only assigned after
executePlan succeeds (a Phase 5 failure restores and returns), Phase 6
never restores because the remote has already advanced, and discarding
unconditionally makes cleanup independent of state-write success.

Regression tests cover all three Phase 6 outcomes (clean success,
SaveManifest failure, SaveConfig failure) plus the two-run corruption
chain: fault-injected SaveConfig failure leaves no rollback dir, and the
next run performs no stale restore and produces zero false
LOCAL_MODIFIED uploads for rollback-covered paths. The mid-Phase-5
interruption behavior is unchanged (rollback dir still strands and the
next run still restores). The tests were demonstrated to fail against
the pre-fix ordering.

The fake files client gains opt-in downloadable content so engine tests
can exercise a plan with downloads, which is what puts real pre-sync
bytes into the rollback tree.
… fails

Phase 6 discarded the rollback at entry but swallowed the Discard error
(`_ = e.rollback.Discard()`). If RemoveAll itself fails, the rollback dir
survives next to ADVANCED state, and the next run's stale-rollback recovery
resurrects pre-sync bytes that the advanced manifest misclassifies as local
edits and silently re-uploads over the remote — the strand/stale-restore/
false-upload chain, reopened through the Discard-failure path.

Surface the error instead. At Phase 6 entry nothing has been persisted, so a
wrapped error is the safe direction: the next run keeps the rollback dir AND
un-advanced state, which is the recoverable mid-Phase-5 outcome — the stale
restore puts back bytes the un-advanced manifest still matches, and the next
diff schedules downloads, not false uploads. The discard is extracted into
discardRollback so the phase's own failure contract stays explicit.

Fault-injection test: a hook on Phase 5's final step (PatchCodeRef) strips
write permission from the rollback dir between rollback creation and Phase 6,
proving a Discard failure aborts with the wrapped error before SaveManifest —
manifest byte-identical, config un-advanced, rollback dir still present.

Also drop the now-unused //nolint:gosec on the config-restore write: gosec's
taint analysis no longer flags that site once the package gains the new test,
and nolintlint rejects directives that suppress nothing.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…ed setup

The discard-failure test's t.Cleanup closure dereferences s.rollDir to
restore the read-only permissions. If scenario setup itself fails, the
scenario (and its rollback dir) may never have been assigned; guard the
closure so the cleanup cannot nil-deref during teardown of an already
failing test.
…nd adopt at chmod-fault sites

Root containers make chmod faults inert: the fault never fires and the test
fails on its own setup instead of the behaviour it pins. The discard-failure
test had only the Windows guard, so it failed instead of skipping as root.

Factor the six-line windows+root skip idiom into internal/testutil:
SkipIfWindows(t, reason) and SkipIfRoot(t) (real guard build-tagged !windows,
no-op on windows where os.Geteuid does not compile and the Windows guard has
already skipped). Adopt both at all four chmod-fault sites, keeping the
per-site skip messages. Test-only; no production code touched.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…nvergence through Run, not direct Execute

The old test asserted convergence after a SaveConfig failure but then forced
it: right after asserting plan.IsEmpty() it called e2.Execute(plan) directly,
which no production caller ever does with an empty plan. Run short-circuits
empty plans before Execute, so in production the post-failure sync detects
drift, fetches AllFiles, finds an empty plan, and returns WITHOUT running
Phase 6 — config.json keeps the old version until some later sync has real
work.

Rename to TestSaveConfigFailure_ManifestAdvanced_ConfigConvergesOnNextRealSync
and drive the post-failure sync through Run(). Assert the honest sequence:
drift detected (one AllFiles call, not the fast path), empty plan, zero
upload-side calls, no new version, config still old while the manifest stays
advanced — the safe asymmetry, since manifest-ahead-of-config self-heals via
drift detection on every later run while the reverse silently poisons BASE
forever. Then introduce a real change and assert a third Run converges
config to the manifest's version through Phase 6. The direct Execute call is
gone.

Teeth proven both ways: replacing Run with the old Plan+Execute pattern
fails the config-unchanged, no-new-version, and AllFiles-count assertions,
and temporarily removing production's empty-plan short-circuit in Run fails
the same three. Test-only; no production code touched.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…ase2 call site on any host

The only end-to-end case-collision test (TestCaseCollision_ErrorIsBeforeUpload)
asserted an error it never produced and counted upload calls that Plan never
makes, so deleting the caseCollisionsFromManifest call in phase2Manifests left
the whole package green on macOS. Replace it with TestCaseCollision_
Phase2StopsBeforeRemoteLoad, which drives phase2Manifests directly with a
seam-injected colliding local manifest and asserts the collision error, so the
check is pinned at its call site on case-insensitive hosts too.

The hashEntriesFn seam (diskspace.go availableBytesFn pattern) is the only
production change and is behavior-neutral: hashEntries remains the default.

Mutation-verified on macOS: deleting the call site makes the new test fail
("An error is expected but got nil"); restoring it makes the test pass.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…config schemas against added fields

Two review-867 fixes, both test/comment-only:

- TestCaseFoldedIgnorePatterns: the doc comment claimed a .drignore pattern
  "BUILD/" must exclude "build/" (case-folded matching), while the body pins
  the opposite — user patterns are case-sensitive via the gitignore library
  (*.TMP does not match scratch.tmp), and only system excludes fold case.
  The header now states exactly what the assertions guarantee.

- TestManifestSchemaUnchanged: the comments claimed "standard fields only" /
  "no new fields" but assert.Contains on the raw JSON stays green when a
  field is ADDED. Replaced with an exact top-level key-set comparison
  (map[string]any against an allow-list transcribed from the wapi.Manifest
  and wapi.Config json tags: manifest = version/syncedAt/syncedVersionId/
  files, config = artifactId/catalogId/lastSyncedVersionId/createdAt/
  cliVersion). Mutation-verified: adding a junk field to either writer
  struct fails the test naming the unexpected key.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…th an unregistered version

fakeFilesClient.AllFiles returned an empty map with a nil error for a
version ID that was never registered, so a typo'd fixture version produced
a plausible-looking plan (every base entry REMOTE_DELETED, every local
file LOCAL_ADDED) instead of a test failure. Return an error naming the
version instead, matching DownloadFile's loud-failure contract for
unregistered versions and the real client, whose AllFiles surfaces the
server's non-200 as an error.

The allFilesCalls counter still increments on the error path: it counts
calls received, and a failed lookup is still a call. Pinned by the new
TestFakeAllFiles_UnknownVersionErrors self-test.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…rgence in the rollback-discard test

The tail of TestPhase6SaveConfigFailure_DiscardsRollback_NextRunNoFalseUploads
asserted plan2.IsEmpty() and then called e2.Execute(plan2) directly, with a
comment claiming that executing the empty plan runs Phase 6 and converges
config — the self-healing direction. No production caller reaches Execute
with an empty plan: Run() short-circuits empty plans before Execute, and the
cmd.go preview paths skip Execute entirely, so Phase 6 never runs on an
empty-plan sync and config never converges that way. The test was asserting
an unreachable path as if it were the self-healing mechanism.

Rewrite the next-run half to drive every leg through Run(), the production
entry point, mirroring the honest shape of
TestSaveConfigFailure_ManifestAdvanced_ConfigConvergesOnNextRealSync
(upload_failure_test.go):

- The drifted empty-plan run must fetch AllFiles (no fast path), compute an
  empty plan with no false uploads for the rollback-covered path, and
  return WITHOUT executing: zero stage/upload/apply calls, no new version,
  and config still holding the pre-sync version.
- Convergence is then asserted through the only production path that
  reaches Phase 6: a subsequent sync with real work (a.py modified). b.py,
  the rollback-covered path, is deliberately left untouched so the
  real-work leg also proves it produces no false upload alongside genuine
  work.

The core purpose is unchanged: rollback discarded at Phase 6 entry, no
false uploads on the next run. Mutation-verified both directions:
temporarily removing the empty-plan short-circuit in Run() fails the
"config must still hold the pre-sync version" leg (the exact unreachable
path the old test forced), and making Run() skip Execute for all plans
fails the real-work convergence leg.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
… in the Phase 6 SaveConfig comment

The SaveConfig-failure bullet claimed the next sync's plan is empty and
"the run merely converges config". That is not what production does:
Run() short-circuits empty plans before Execute, so Phase 6 never runs
on that next sync and config.json keeps the stale version. Config
converges only when a later sync has real work.

Reword the bullet to state the verified sequence: drift re-detected on
every later sync, empty plan computed against the truthful advanced
manifest, Run returning before Execute, Phase 6 skipped, config stale
until a sync with real work executes — and why the asymmetry is safe
(drift detection re-fires every run, so the window self-heals; the
reverse direction is silent, permanent poisoning).

Comment-only change; the behavior is already pinned by
TestSaveConfigFailure_ManifestAdvanced_ConfigConvergesOnNextRealSync
and the rollback-discard tests, which are the evidence the new wording
is true.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@ajalon1
ajalon1 force-pushed the aj/raptor-19525-integrity-fixes branch from a901a80 to c6ca636 Compare September 1, 2026 02:44

@chasdr chasdr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👋 chasbot here, chas had me take a pass while he's off doing chas things. compliments first, since they're earned, then the one red standing between this and merge.

the good stuff (genuinely careful work):

  • the three fixes all land where the writeup says. moving overwrite into the multipart body matches the server binding it from the parsed form only, and framing the form fields before the file part so a streaming parser collects them without buffering a multi-GiB body is the right instinct. Content-Length stays byte-exact.
  • hash-while-streaming via TeeReader / MultiWriter, with size taken from the open handle and io.Copy's return instead of the plan-time size. the manifest records the bytes that actually crossed the wire.
  • buildNewBaseManifest refusing outright on a missing Sent entry instead of quietly falling back to the planned hash. resisting that fallback is the whole fix, and you named it in the comment so nobody re-adds it. nice.
  • the parallel-upload rework is deadlock-free: resCh buffered to len(files), errored/panicked workers skip the send, drain only on the success path.

the concern:
you wrote a test to catch schema drift and it caught some. its own. TestManifestSchemaUnchanged is red on ubuntu and windows because the config.json allow-list is missing lastBuiltVersionId, a field already on wapi.Config whose json tag has no omitempty, so it always serializes. one-line fix, left it inline. the approval up top predates the red, so worth a re-glance before this lands.

also it conflicts with main now. main landed the --accept-remote conflict work that overlaps your phase5/phase6 rework (engine.go, phase5_execute.go, phase6_state.go, del/cmd_test.go). rebase, fix the allow-list, re-run CI and this is good.

droid-to-droid nit, non-blocking: the phase6 comment blocks are carrying real ordering/poisoning why, so they stay, but yours and mine could probably land it in half the lines.

require.NoError(t, err)

assertTopLevelKeys(t, rawConfig, "config.json",
[]string{"artifactId", "catalogId", "lastSyncedVersionId", "createdAt", "cliVersion"})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the CI red on both ubuntu and windows. wapi.Config has a 6th field, LastBuiltVersionID, and its json tag has no omitempty (only the validate tag does), so config.json always serializes lastBuiltVersionId (null when nil). the allow-list here is missing it. add it?

Suggested change
[]string{"artifactId", "catalogId", "lastSyncedVersionId", "createdAt", "cliVersion"})
[]string{"artifactId", "catalogId", "lastSyncedVersionId", "lastBuiltVersionId", "createdAt", "cliVersion"})

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

go Pull requests that update go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants