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:
+54
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user