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:
@@ -33,7 +33,9 @@ var skippedTypes = resTypes("nss", "ndb", "gic")
|
||||
// manifest quietly disagrees with. Bump it only when emitted bytes change — it
|
||||
// is deliberately not the build revision, which would invalidate every
|
||||
// published index on every unrelated commit.
|
||||
const emitterVersion = "1"
|
||||
// Version 2 declares Frame_Content_Size on every blob (#86); version 1 omitted
|
||||
// it below 256 bytes and no client could sync past such a blob.
|
||||
const emitterVersion = "2"
|
||||
|
||||
// serverTypes are loaded only server-side; a manifest holding nothing else
|
||||
// has no client contents. Mirrors upstream's GlobalResTypeServerList, whose
|
||||
@@ -78,6 +80,7 @@ type EmitOptions struct {
|
||||
As string // name override, for a TLK whose filename is not its published name
|
||||
OutDir string // write locally instead of uploading — the conformance path
|
||||
Jobs int // resources in flight at once; 0 means defaultEmitJobs
|
||||
Verify bool // hash what would be skipped instead of trusting presence
|
||||
Sink sink // test seam; nil means OutDir or the zone
|
||||
}
|
||||
|
||||
@@ -129,7 +132,7 @@ func Emit(options EmitOptions) (EmitResult, error) {
|
||||
if jobs < 1 {
|
||||
jobs = defaultEmitJobs
|
||||
}
|
||||
entries, blobs, onDiskBytes, err := emitResources(artifact, index, target, jobs)
|
||||
entries, blobs, onDiskBytes, err := emitResources(artifact, index, target, jobs, options.Verify)
|
||||
if err != nil {
|
||||
return EmitResult{}, err
|
||||
}
|
||||
@@ -199,7 +202,7 @@ func readArtifactIndex(path string, artifact io.ReaderAt, size int64, name strin
|
||||
//
|
||||
// The returned entries are in artifact order whatever order the workers finish
|
||||
// in, because a manifest's bytes are promised deterministic by emitterVersion.
|
||||
func emitResources(artifact io.ReaderAt, index []erf.IndexEntry, target sink, jobs int) ([]Entry, int, int64, error) {
|
||||
func emitResources(artifact io.ReaderAt, index []erf.IndexEntry, target sink, jobs int, verify bool) ([]Entry, int, int64, error) {
|
||||
// A resref appearing twice inside one artifact resolves to the last one,
|
||||
// the way resman lets the last container added win.
|
||||
order := make([]Identity, 0, len(index))
|
||||
@@ -271,7 +274,7 @@ func emitResources(artifact io.ReaderAt, index []erf.IndexEntry, target sink, jo
|
||||
if duplicate {
|
||||
return
|
||||
}
|
||||
written, err := target.putBlob(fmt.Sprintf("%x", sum), func() []byte { return compressBlob(payload) })
|
||||
written, err := target.putBlob(fmt.Sprintf("%x", sum), verify, func() []byte { return compressBlob(payload) })
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user