diff --git a/README.md b/README.md index f70498a..075c1f2 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,12 @@ the entire run, which finds more reuse when a checkpoint has been re-sharded. trust its timestamps. Without it, `modelmove` re-hashes the destination, which is both the integrity check and the thing that finds reusable chunks. +`--fast` will skip a file whose size and mtime still match the source even if +the bytes have changed (an in-place edit, `touch -r`, some networked +filesystems). That is the contract, not a bug: the next `verify` exits 2, and +a sync without `--fast` repairs the drifted chunks. Run `verify` after a +`--fast` resync if you need to know the destination still matches. + ## Exit codes | Code | Meaning | diff --git a/internal/protocol/protocol_test.go b/internal/protocol/protocol_test.go index d712748..214f61a 100644 --- a/internal/protocol/protocol_test.go +++ b/internal/protocol/protocol_test.go @@ -557,6 +557,33 @@ func TestResumeOverPipes(t *testing.T) { } } +func TestPlanFailsWhenDestUnhashableOverPipes(t *testing.T) { + src := t.TempDir() + writeFile(t, src, "model.safetensors", randomBytes(64<<10, 21)) + m, err := scan.Build(context.Background(), scan.Options{Root: src, Tool: "test"}) + if err != nil { + t.Fatal(err) + } + dst := filepath.Join(t.TempDir(), "out") + client, done := pipePair(t, ServerOptions{Root: dst, Tool: "modelmove/test"}) + runTransfer(t, client, m, src, defaultRequest()) + done() + + m.Files[0].Chunker.MinSize = 8 << 20 + m.Files[0].Chunker.AvgSize = 1 << 20 + m.Files[0].Chunker.MaxSize = 4 << 20 + + client, done = pipePair(t, ServerOptions{Root: dst, Tool: "modelmove/test"}) + _, err = client.Plan(context.Background(), m, defaultRequest()) + done() + if err == nil { + t.Fatal("Plan succeeded; want the dest hash failure over the wire") + } + if !bytes.Contains([]byte(err.Error()), []byte("cannot hash destination file")) { + t.Fatalf("error = %v, want it to mention cannot hash destination file", err) + } +} + func TestCorruptionRepairOverPipes(t *testing.T) { src := sourceDir(t) dst := filepath.Join(t.TempDir(), "out") diff --git a/internal/receiver/receiver_test.go b/internal/receiver/receiver_test.go index d0d5e7b..799d890 100644 --- a/internal/receiver/receiver_test.go +++ b/internal/receiver/receiver_test.go @@ -381,6 +381,50 @@ func TestFastTrustsSizeAndModTime(t *testing.T) { } } +// TestFastSkipsWhenContentDiffersButSizeAndMtimeMatch documents the +// --fast contract: an in-place edit that keeps size and mtime is skipped +// and only verify (or a non-fast sync) will notice. +func TestFastSkipsWhenContentDiffersButSizeAndMtimeMatch(t *testing.T) { + src := t.TempDir() + payload := randomBytes(100<<10, 31) + write(t, src, "model.safetensors", payload) + dst := filepath.Join(t.TempDir(), "out") + apply(t, src, dst, defaults(dst)) + + path := filepath.Join(dst, "model.safetensors") + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + mtime := info.ModTime() + bad := append([]byte{}, payload...) + bad[len(bad)/2] ^= 0xff + if err := os.WriteFile(path, bad, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, mtime, mtime); err != nil { + t.Fatal(err) + } + + opt := defaults(dst) + opt.Fast = true + plan, _ := apply(t, src, dst, opt) + if plan.NeedBytes != 0 { + t.Errorf("--fast planned %d bytes after a same-size in-place edit; want 0 (trusted skip)", plan.NeedBytes) + } + if plan.SkipFiles != 1 { + t.Errorf("SkipFiles = %d, want 1", plan.SkipFiles) + } + if string(read(t, dst, "model.safetensors")) != string(bad) { + t.Fatal("--fast must not rewrite a file it trusted") + } + + plan, _ = apply(t, src, dst, defaults(dst)) + if plan.NeedBytes == 0 { + t.Fatal("a full rehash should have planned a repair") + } +} + func TestVerificationCatchesBadChunk(t *testing.T) { src := t.TempDir() write(t, src, "model.safetensors", randomBytes(100<<10, 8)) diff --git a/scripts/e2e-live.sh b/scripts/e2e-live.sh index 298a028..09da3f9 100755 --- a/scripts/e2e-live.sh +++ b/scripts/e2e-live.sh @@ -20,7 +20,7 @@ if ! "${SSH[@]}" "${REMOTE_USER}@${REMOTE_HOST}" true >/dev/null 2>&1; then fi WORK=$(mktemp -d) -trap 'rm -rf "$WORK"; "${SSH[@]}" "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /tmp/modelmove-live-$$ /tmp/modelmove-live-resume-$$" >/dev/null 2>&1 || true' EXIT +trap 'rm -rf "$WORK"; "${SSH[@]}" "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /tmp/modelmove-live-$$ /tmp/modelmove-live-resume-$$ /tmp/modelmove-live-fast-$$" >/dev/null 2>&1 || true' EXIT REMOTE_DST="/tmp/modelmove-live-$$" SRC="$WORK/src" @@ -127,5 +127,32 @@ PY "${SSH[@]}" "${REMOTE_USER}@${REMOTE_HOST}" "'$BIN' verify '$RESUME_DST' --no-progress" "${SSH[@]}" "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf '$RESUME_DST'" +echo "==> e2e-live: --fast skips a same-size in-place edit (verify still catches it)" +FAST_DST="/tmp/modelmove-live-fast-$$" +FAST_TARGET="${REMOTE_USER}@${REMOTE_HOST}:${FAST_DST}" +"$BIN" copy "$SRC" "$FAST_TARGET" --remote-bin "$BIN" --no-progress +"${SSH[@]}" "${REMOTE_USER}@${REMOTE_HOST}" "python3 - '$FAST_DST/model-00002-of-00002.safetensors'" <<'PY' +import os, sys +p = sys.argv[1] +st = os.stat(p) +d = bytearray(open(p, "rb").read()) +d[1_000_000] ^= 0xFF +open(p, "wb").write(d) +os.utime(p, ns=(st.st_atime_ns, st.st_mtime_ns)) +PY +"$BIN" sync "$SRC" "$FAST_TARGET" --fast --remote-bin "$BIN" --json --no-progress > "$WORK/fast.json" +python3 - "$WORK/fast.json" <<'PY' +import json, sys +r = json.load(open(sys.argv[1])) +sent = r["summary"]["bytes_received"] +assert sent == 0, f"--fast sent {sent} bytes after a same-size edit; want 0" +PY +set +e +"${SSH[@]}" "${REMOTE_USER}@${REMOTE_HOST}" "'$BIN' verify '$FAST_DST' --no-progress" > "$WORK/fast-verify.out" 2>&1 +code=$? +set -e +[ "$code" -eq 2 ] || fail "verify exited $code after --fast skip, want 2" +"${SSH[@]}" "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf '$FAST_DST'" + echo echo "e2e-live: all checks passed"