[RAPTOR-19525] fix(workload): sync upload integrity — hash-while-streaming, Phase 6 write order, zip overwrite form field - #867
Conversation
|
🎫 Jira: |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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 { |
There was a problem hiding this comment.
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)
Triggered by project rule: Bugbot Rules for DataRobot CLI
Reviewed by Cursor Bugbot for commit 630e2f4. Configure here.
630e2f4 to
440767c
Compare
wojtekwdr
left a comment
There was a problem hiding this comment.
LGTM with some comments
| // 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 |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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). |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[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. |
There was a problem hiding this comment.
[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 { |
There was a problem hiding this comment.
[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" { |
There was a problem hiding this comment.
[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).
…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.
…ame the specific error
… 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>
a901a80 to
c6ca636
Compare
chasdr
left a comment
There was a problem hiding this comment.
👋 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
overwriteinto 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.
buildNewBaseManifestrefusing outright on a missingSententry 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"}) |
There was a problem hiding this comment.
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?
| []string{"artifactId", "catalogId", "lastSyncedVersionId", "createdAt", "cliVersion"}) | |
| []string{"artifactId", "catalogId", "lastSyncedVersionId", "lastBuiltVersionId", "createdAt", "cliVersion"}) |


RATIONALE
RAPTOR-19525 reported that
dr artifact code syncsilently skips changed files when size and mtime are unchanged. Investigation proved that mechanism has never existed in this codebase (detection is content-hash only;Diffreceives only hashes,hashEntriesrehashes every file every run,fileops.Entrycarries 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--verifyflag and symlink surfacing on top.Three distinct defects are fixed here:
Zip-path re-uploads silently stored as
name (2).ext(the reporter's most likely symptom). The CLI sentoverwrite=REPLACEas a URL query parameter (internal/drapi/filesapi/fromfile.go), but the server's/files/<catalogID>/fromFile/route readsoverwriteonly from the multipart form body and silently defaults to RENAME when the form field is absent (monorepoMLData/files/public_api/files/validators.py). Every re-uploaded file was therefore stored asname (2).ext: the original path kept the old bytes while the manifest recorded the new hash, so a following plain--dry-runfalsely printedUp 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 sendsoverwriteas a JSON body there, which the server does read. Fixed by movingoverwriteinto the multipart form body (fields framed before the file part, Content-Length stays byte-exact).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.TeeReaderon the stage path,io.MultiWriteron the zip path, so the manifest records exactly the bytes that went over the wire.Phase 6 wrote config before manifest, so a crash between the two individually-atomic writes left
config.jsonadvanced past a stalemanifest.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.Chtimesrestoration IS detected).CHANGES
398e2e57test: rework fakeFilesClient into a self-consistent server model8da0d086fix: hash uploads while streaming so the manifest records what was sent95de8b9btest: cover zip uploader hash-while-streaming0b2723d0test: prove failed/partial uploads never advance persisted statead7b90c2fix: write manifest before config in Phase 64204464dtest: guard sync surroundings against regression7d8ebb5etest: lock in content-hash-only change detection630e2f40fix: send zip-path overwrite as a multipart form fieldTESTING
task test(full suite,-race -shuffle=on) andtask lint(GOOS=linux/darwin/windows) green.shasum -a 256, manifest, and serverfileChecksum; zeroname (2).extartifacts; a following--dry-runtruthfully printsUp to date.).NOTES
overwriterather than silently defaulting to RENAME) is being filed independently.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).UploadFromZipExistingno longer sendsoverwritein 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 correctContent-Length.Manifest matches bytes on the wire. Stage and zip upload paths hash while streaming (
TeeReader/MultiWriter). Phase 5 stores anUploadOutcomewith per-pathSenthashes; Phase 6 buildsmanifest.jsonfrom those streamed hashes and errors if any upload path lacks aSententry (no fallback to plan-time hashes).Safer Phase 6 persistence.
manifest.jsonis written beforeconfig.jsonso 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.