fix(nwsync): declare Frame_Content_Size on every blob, and verify what is published (#87)
build-binaries / build-binaries (push) Successful in 2m40s
build-binaries / build-binaries (push) Successful in 2m40s
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>
This commit was merged in pull request #87.
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