fix(nwsync): declare Frame_Content_Size, and verify what is published (#86, #85)

These two 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 — 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 emit 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. Verified against the zstd CLI end to end: a real emitted 230-byte
blob now reports its decompressed size where it previously reported none.
emitter_version goes to 2, so assemble refuses to merge an index written by the
encoder that omitted the field.

#85 — a published blob was verified by exactly one thing, a player's client, at
download time. `nwsync verify <manifest-sha1>` now reads a manifest and its
blobs back through the public pull zone with no credential, decompresses and
hashes every one, and reports missing / malformed framing / size mismatch / hash
mismatch per blob. `--sample N` makes a routine check cheap against a manifest
that is ~69,000 blobs and 15 GB. `emit --verify` applies the same check where
emit would otherwise trust presence, and replaces a stored blob that is not what
its name claims — which is what makes the #86 blobs repairable.

Both new checks assert the frame property, not just a round trip. Round-tripping
cannot see this class of fault: both the zstd CLI and Go's decoder stream such a
frame happily, being more capable decoders than the client's, which is how the
defect reached production and survived an audit.

Refs #54, #55, #59, #75, sow-platform#79.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 00:20:15 +02:00
co-authored by Claude Opus 5
parent 682f920114
commit 56d4054118
11 changed files with 752 additions and 19 deletions
+54 -1
View File
@@ -12,12 +12,17 @@ import (
"flag"
"fmt"
"io"
"os"
)
const (
exitOK = 0
exitUsage = 64
exitInternal = 70
// exitDrift says the command worked and the zone is wrong, which is a
// different thing for CI to act on than the command failing. It matches
// depot's code for the same meaning.
exitDrift = 1
)
// Run executes an nwsync subcommand. args[0] is the subcommand (emit|assemble);
@@ -32,6 +37,8 @@ func Run(args []string, stdout, stderr io.Writer) int {
return runEmit(args[1:], stderr)
case "assemble":
return runAssemble(args[1:], stderr)
case "verify":
return runVerify(args[1:], stdout, stderr, os.Getenv)
case "-h", "--help", "help":
printRunUsage(stdout)
return exitOK
@@ -44,17 +51,27 @@ func Run(args []string, stdout, stderr io.Writer) int {
func printRunUsage(w io.Writer) {
fmt.Fprint(w, `usage:
nwsync emit [--as NAME] [--out DIR] <artifact-key> <file>
nwsync emit [--as NAME] [--out DIR] [--verify] <artifact-key> <file>
nwsync assemble --group-id N [--tlk-key KEY] [--out DIR] <artifact-key>...
nwsync verify [--sample N] [--base URL] <manifest-sha1>
emit explodes one .hak/.erf or one loose file (the TLK) into NWSync blobs plus
a NSYM index covering only that artifact, and uploads both. assemble merges
those indexes into one manifest, reading no bulk data. Artifact keys are depot
keys; an index lives beside its artifact, with the extension replaced.
verify reads a published manifest and its blobs back through the public pull
zone, with no credential, and decompresses and hashes every one. It is the only
check on a published blob upstream of a player's client.
--verify makes emit hash what it would otherwise skip. emit normally treats a
blob's presence as proof of its contents, so without this an object written
truncated, or written by an emitter since found broken, is skipped forever.
--out DIR writes to a local repository tree instead of uploading, which is the
conformance path against upstream nwn_nwsync_write. Without it, the zone comes
from NWSYNC_STORAGE_ZONE, NWSYNC_STORAGE_PASSWORD and BUNNY_STORAGE_HOST.
verify needs none of those; its base comes from --base or NWSYNC_PULL_BASE.
`)
}
@@ -82,6 +99,7 @@ func runEmit(args []string, stderr io.Writer) int {
as := fs.String("as", "", "published name of the artifact, when it differs from the key")
out := fs.String("out", "", "write to a local repository tree instead of uploading")
jobs := fs.Int("jobs", defaultEmitJobs, "resources to hash, compress and store at once")
verify := fs.Bool("verify", false, "read back and hash blobs that already exist instead of trusting their presence")
positional, err := parseArgs(fs, args)
if err != nil {
return exitUsage
@@ -101,6 +119,7 @@ func runEmit(args []string, stderr io.Writer) int {
As: *as,
OutDir: *out,
Jobs: *jobs,
Verify: *verify,
})
if err != nil {
fmt.Fprintf(stderr, "nwsync emit: %v\n", err)
@@ -111,6 +130,40 @@ func runEmit(args []string, stderr io.Writer) int {
return exitOK
}
func runVerify(args []string, stdout, stderr io.Writer, getenv func(string) string) int {
fs := flag.NewFlagSet("verify", flag.ContinueOnError)
fs.SetOutput(stderr)
base := fs.String("base", getenv("NWSYNC_PULL_BASE"), "pull zone base URL to read through")
sample := fs.Int("sample", 0, "check this many random blobs instead of all of them")
jobs := fs.Int("jobs", defaultEmitJobs, "blobs to fetch and hash at once")
positional, err := parseArgs(fs, args)
if err != nil {
return exitUsage
}
if len(positional) != 1 {
fmt.Fprintf(stderr, "nwsync verify: exactly one <manifest-sha1> is required\n")
return exitUsage
}
result, err := Verify(VerifyOptions{
ManifestSHA1: positional[0],
Base: *base,
Sample: *sample,
Jobs: *jobs,
Log: stderr,
})
if err != nil {
fmt.Fprintf(stderr, "nwsync verify: %v\n", err)
return exitInternal
}
fmt.Fprintf(stdout, "verified %d of %d blobs behind %d resources: %d failures, %d bytes checked\n",
result.Checked, result.Blobs, result.Entries, result.Failures, result.Bytes)
if result.Failures > 0 {
return exitDrift
}
return exitOK
}
func runAssemble(args []string, stderr io.Writer) int {
fs := flag.NewFlagSet("assemble", flag.ContinueOnError)
fs.SetOutput(stderr)