Files
archvillainette 7cc53aeb68
build-binaries / build-binaries (push) Successful in 2m40s
fix(nwsync): declare Frame_Content_Size on every blob, and verify what is published (#87)
Closes #86. Closes #85.

These land together on purpose. Fixing the encoder alone changes nothing for the blobs already in the zone, because `emit` skips whatever is already present.

## #86 — the framing fix

`klauspost/compress` omits the zstd `Frame_Content_Size` field for inputs under 256 bytes, which the format permits. Reference libzstd never does, so the NWN client — which sizes its output buffer from `ZSTD_getFrameContentSize` and has therefore never met a frame without one — rejected roughly 6% of our blobs outright. Any single one stops a sync dead, so no client could complete a sync of the live manifest.

No encoder option changes this, so `compressBlob` re-headers the affected frames into the shape libzstd itself emits: `Single_Segment_flag` set, `Window_Descriptor` dropped, and the freed byte spent on a one-byte `Frame_Content_Size`. Same length in, same length out, and the same descriptor byte (`0x24`) the issue recorded from libzstd.

`compressBlob` then asserts its own output. An encoder upgrade that finds another way to omit the field would otherwise reproduce #86 in silence, and a blob is skipped by every later emit once written.

`emitter_version` goes to `2`, so `assemble` refuses to merge an index written by the encoder that omitted the field.

**Proved against the reference decoder, not just a round trip.** A real emitted 175-byte blob:

```
Frames  Skips  Compressed  Uncompressed  Ratio  Check  Filename
     1      0      48   B       175   B  3.646  XXH64  frame.zst
c59d6620d4ffd4bf3fe73df43b19b7afcfe8fea4  -            <- zstd -dc | sha1sum
c59d6620d4ffd4bf3fe73df43b19b7afcfe8fea4               <- the blob's own name
```

Before the fix that `Uncompressed` column was blank.

## #85 — `nwsync verify`

`crucible nwsync verify <manifest-sha1>` reads a manifest and its blobs back through the **public pull zone**, with no credential, because what matters is the bytes a client is served, edge behaviour included. Every distinct blob is decompressed and hashed; failures are reported per blob as missing / malformed framing / size mismatch / hash mismatch, and the exit code is 1.

- `--sample N` makes a routine check cheap against a manifest that is ~69,000 blobs and 15 GB; the default is a full sweep.
- `--base URL` / `NWSYNC_PULL_BASE` overrides the public host.
- The manifest is checked against its own sha1 before a single blob is fetched.
- `emit --verify` applies the same check where `emit` would otherwise trust presence, and replaces a stored blob that is not what its name claims. This is what makes the #86 blobs repairable.

## Why the existing checks missed this

Both new checks assert the **frame property**, not just a round trip. The conformance suite (#59) compares decompressed bytes, so a frame that decodes correctly passes regardless of its header; and the earlier zone audit decompressed 68 blobs with the `zstd` CLI, a *more* capable decoder than the client's, which certified exactly the blobs the client rejects.

## Checks

`make check` and `make smoke` green. Second commit is the fixes from a two-axis review of the first.

## Not in this PR

Three follow-ups, filed separately: the backfill has not been run, replacing a blob does not purge the pull-zone edge cache, and #85's runbook line belongs to `sow-platform`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #87

Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
2026-07-31 22:33:12 +00:00

258 lines
7.3 KiB
Go

package nwsync
import (
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// fakeZone is a Bunny-shaped object store: PUT stores, GET reads, and the
// Checksum header is verified the way Bunny verifies it.
type fakeZone struct {
mu sync.Mutex
objects map[string][]byte
puts []string
failOn func(key string) bool // when true, the PUT fails
url string // base the same objects are readable at
}
// pullZone reads the fake zone the way the public pull zone is read: plain
// unauthenticated GETs, no storage API.
func (z *fakeZone) pullZone() blobSource {
return newPullZone(z.url)
}
func newFakeZone(t *testing.T) (*fakeZone, func(string) string) {
t.Helper()
zone := &fakeZone{objects: map[string][]byte{}}
server := httptest.NewServer(zone)
t.Cleanup(server.Close)
zone.url = server.URL + "/sow-nwsync"
getenv := func(name string) string {
switch name {
case "NWSYNC_STORAGE_ZONE":
return "sow-nwsync"
case "NWSYNC_STORAGE_PASSWORD":
return "write-key"
case "BUNNY_STORAGE_HOST":
return server.URL
}
return ""
}
return zone, getenv
}
func (z *fakeZone) ServeHTTP(w http.ResponseWriter, r *http.Request) {
key := strings.TrimPrefix(r.URL.Path, "/sow-nwsync/")
switch r.Method {
case http.MethodPut:
if z.failOn != nil && z.failOn(key) {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
sum := sha256.Sum256(body)
if want := strings.ToUpper(hex.EncodeToString(sum[:])); r.Header.Get("Checksum") != want {
http.Error(w, "checksum mismatch", http.StatusBadRequest)
return
}
z.mu.Lock()
z.objects[key] = body
z.puts = append(z.puts, key)
z.mu.Unlock()
w.WriteHeader(http.StatusCreated)
case http.MethodGet:
z.mu.Lock()
body, ok := z.objects[key]
z.mu.Unlock()
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
_, _ = w.Write(body)
default:
http.Error(w, "unsupported", http.StatusMethodNotAllowed)
}
}
func (z *zoneSinkFixture) emit(t *testing.T, path string) EmitResult {
t.Helper()
result, err := Emit(EmitOptions{
ArtifactKey: artifactKey(t, path),
ArtifactPath: path,
Sink: z.sink,
})
if err != nil {
t.Fatalf("emit %s: %v", path, err)
}
return result
}
type zoneSinkFixture struct {
zone *fakeZone
sink sink
}
func newZoneFixture(t *testing.T) *zoneSinkFixture {
t.Helper()
zone, getenv := newFakeZone(t)
target, err := newZoneSink(t.Context(), getenv)
if err != nil {
t.Fatalf("zone sink: %v", err)
}
return &zoneSinkFixture{zone: zone, sink: target}
}
func sha1Of(body []byte) [20]byte { return sha1.Sum(body) }
func TestEmitUploadsBlobsThenIndex(t *testing.T) {
fixture := newZoneFixture(t)
dir := t.TempDir()
hak := filepath.Join(dir, "sow_test_01.hak")
body := []byte("texture bytes")
writeHak(t, hak, map[string][]byte{"bloodstain1.tga": body, "copy1.txi": body})
key := artifactKey(t, hak)
result := fixture.emit(t, hak)
index, err := indexKey(key)
if err != nil {
t.Fatal(err)
}
if _, ok := fixture.zone.objects[index]; !ok {
t.Fatalf("no index at %s; zone holds %v", index, fixture.zone.puts)
}
if result.BlobsWritten != 1 {
t.Errorf("uploaded %d blobs, want 1 (identical content shares a blob)", result.BlobsWritten)
}
// The index is the publication marker, so it must land after every blob it
// names — including its own sidecar.
last := fixture.zone.puts[len(fixture.zone.puts)-1]
if last != index {
t.Errorf("index landed at position %d of %d; it must be last", len(fixture.zone.puts), len(fixture.zone.puts))
}
for _, key := range fixture.zone.puts[:len(fixture.zone.puts)-1] {
if strings.HasPrefix(key, "data/sha1/") || key == index+".json" {
continue
}
t.Errorf("unexpected object uploaded before the index: %s", key)
}
}
func TestEmitSkipsBlobsAlreadyInTheZone(t *testing.T) {
fixture := newZoneFixture(t)
dir := t.TempDir()
hak := filepath.Join(dir, "sow_test_01.hak")
writeHak(t, hak, map[string][]byte{"bloodstain1.tga": []byte("blood")})
first := fixture.emit(t, hak)
if first.BlobsWritten != 1 {
t.Fatalf("first emit uploaded %d blobs, want 1", first.BlobsWritten)
}
second := fixture.emit(t, hak)
if second.BlobsWritten != 0 {
t.Errorf("re-emit uploaded %d blobs, want 0 (a blob name is its content)", second.BlobsWritten)
}
}
func TestEmitLeavesNoIndexWhenAnUploadFails(t *testing.T) {
fixture := newZoneFixture(t)
fixture.zone.failOn = func(key string) bool { return strings.HasPrefix(key, "data/sha1/") }
dir := t.TempDir()
hak := filepath.Join(dir, "sow_test_01.hak")
writeHak(t, hak, map[string][]byte{"bloodstain1.tga": []byte("blood")})
_, err := Emit(EmitOptions{
ArtifactKey: artifactKey(t, hak),
ArtifactPath: hak,
Sink: fixture.sink,
})
if err == nil {
t.Fatal("emit reported success after an upload failed")
}
for key := range fixture.zone.objects {
if strings.HasSuffix(key, ".nsym") {
t.Errorf("a half-emitted artifact published an index: %s", key)
}
}
}
func TestEmitRejectsAKeyThatDoesNotMatchTheFile(t *testing.T) {
fixture := newZoneFixture(t)
dir := t.TempDir()
hak := filepath.Join(dir, "sow_test_01.hak")
writeHak(t, hak, map[string][]byte{"bloodstain1.tga": []byte("blood")})
wrong := "artifacts/haks/sha256/00/11/" + strings.Repeat("0", 64) + ".hak"
_, err := Emit(EmitOptions{ArtifactKey: wrong, ArtifactPath: hak, Sink: fixture.sink})
if err == nil || !strings.Contains(err.Error(), "hashes to") {
t.Fatalf("emit published under a key that names another artifact: %v", err)
}
}
func TestAssembleReadsIndexesFromTheZone(t *testing.T) {
fixture := newZoneFixture(t)
dir := t.TempDir()
topBody := []byte("2da from sow_top")
assetBody := []byte("2da from the asset hak")
top := filepath.Join(dir, "sow_top.hak")
core := filepath.Join(dir, "sow_core_01.hak")
writeHak(t, top, map[string][]byte{"appearance.2da": topBody})
writeHak(t, core, map[string][]byte{"appearance.2da": assetBody, "bloodstain1.tga": []byte("blood")})
tlkPath := filepath.Join(dir, "sow_tlk.tlk")
if err := os.WriteFile(tlkPath, []byte("TLK V3.0 payload"), 0o644); err != nil {
t.Fatal(err)
}
fixture.emit(t, top)
fixture.emit(t, core)
if _, err := Emit(EmitOptions{
ArtifactKey: artifactKey(t, tlkPath),
ArtifactPath: tlkPath,
As: "sow_tlk.tlk",
Sink: fixture.sink,
}); err != nil {
t.Fatalf("emit tlk: %v", err)
}
result, err := Assemble(AssembleOptions{
ArtifactKeys: []string{artifactKey(t, top), artifactKey(t, core)},
TLKKey: artifactKey(t, tlkPath),
GroupID: 2,
Sink: fixture.sink,
})
if err != nil {
t.Fatalf("assemble: %v", err)
}
if result.Entries != 3 {
t.Fatalf("merged %d entries, want 3 (appearance.2da is shadowed, the TLK adds one)", result.Entries)
}
manifest, ok := fixture.zone.objects["manifests/"+result.SHA1]
if !ok {
t.Fatalf("no merged manifest in the zone; it holds %v", fixture.zone.puts)
}
entries, err := readManifest(manifest)
if err != nil {
t.Fatalf("parse merged manifest: %v", err)
}
for _, entry := range entries {
if entry.ResRef == "appearance" && entry.SHA1 != sha1Of(topBody) {
t.Errorf("appearance.2da resolved to the shadowed hak, not the first one given")
}
}
}