fix(nwsync): declare Frame_Content_Size on every blob, and verify what is published #87

Merged
archvillainette merged 2 commits from fix/nwsync-declare-frame-content-size-and-verify into main 2026-07-31 22:33:12 +00:00
6 changed files with 79 additions and 30 deletions
Showing only changes of commit 9b7be2c76e - Show all commits
+1 -1
View File
@@ -19,7 +19,7 @@ Crucible is how the artifact repos turn source into artifacts.
| `crucible-depot` | `crucible depot` | content-addressed depot blob verify/move |
| `crucible-hak` | `crucible hak` | ERF/HAK pack/unpack + hak manifests |
| `crucible-module` | `crucible module` | build/extract/validate/compare the `.mod` |
| `crucible-nwsync` | `crucible nwsync` | NWSync blob emit + manifest assemble |
| `crucible-nwsync` | `crucible nwsync` | NWSync blob emit + manifest assemble + verify |
| `crucible-topdata` | `crucible topdata` | compile 2da/tlk topdata + packages |
| `crucible-wiki` | `crucible wiki` | render + deploy mechanical wiki pages |
+15 -6
View File
@@ -39,9 +39,10 @@ const (
zstdFrameMagic = "\x28\xb5\x2f\xfd"
frameSingleSegment = 1 << 5
frameDictionaryMask = 0x03
// The size below which klauspost/compress writes no Frame_Content_Size,
// matching the field's own encoding threshold.
frameSizeThreshold = 256
// oneByteContentSizeCeiling is the size above which a Frame_Content_Size no
// longer fits in one byte. Below it the field's size flag is 0, which is
// what lets klauspost/compress leave the field out entirely.
oneByteContentSizeCeiling = 256
)
// compressBlob wraps data in NWCompressedBuffer framing.
@@ -51,7 +52,15 @@ func compressBlob(data []byte) []byte {
for _, field := range header {
_ = binary.Write(&out, binary.LittleEndian, field)
}
out.Write(declareFrameContentSize(blobEncoder.EncodeAll(data, nil), len(data)))
frame := declareFrameContentSize(blobEncoder.EncodeAll(data, nil), len(data))
// Fail closed rather than publish a blob no client can decode. An encoder
// upgrade that finds a new way to omit the field would otherwise reproduce
// #86 in silence, and a blob is skipped by every later emit once written.
if !frameDeclaresContentSize(frame) {
panic(fmt.Sprintf("nwsync: refusing to emit a %d-byte blob whose zstd frame declares no content size (descriptor %#x)",
len(data), frame[4]))
}
out.Write(frame)
return out.Bytes()
}
@@ -68,7 +77,7 @@ func inspectBlob(blob []byte) ([]byte, error) {
if err != nil {
return nil, fmt.Errorf("malformed framing: %w", err)
}
if len(data) > 0 && !frameDeclaresContentSize(blob[blobHeaderBytes:]) {
if len(blob) > blobHeaderBytes && !frameDeclaresContentSize(blob[blobHeaderBytes:]) {
return nil, fmt.Errorf("malformed framing: the zstd frame declares no content size, which the game client cannot decode")
}
return data, nil
@@ -115,7 +124,7 @@ func frameDeclaresContentSize(frame []byte) bool {
// which is sound because the content is under 256 bytes and every match in it
// therefore falls inside that window. Same length in, same length out.
func declareFrameContentSize(frame []byte, size int) []byte {
if size <= 0 || size >= frameSizeThreshold || len(frame) < 6 || string(frame[:4]) != zstdFrameMagic {
if size <= 0 || size >= oneByteContentSizeCeiling || len(frame) < 6 || string(frame[:4]) != zstdFrameMagic {
return frame
}
descriptor := frame[4]
+10 -2
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"io"
"path"
"path/filepath"
"sort"
"strings"
@@ -194,7 +195,14 @@ func marshalSidecar(sidecar Sidecar) ([]byte, error) {
return append(body, '\r', '\n'), nil
}
// blobPath is the data store path for a blob, hash tree depth 2.
// blobKey is where a blob lives in a zone, hash tree depth 2. emit writes it,
// verify reads it and the game client requests it, so the rule lives here and
// nowhere else.
func blobKey(sha1Hex string) string {
return path.Join("data", "sha1", sha1Hex[0:2], sha1Hex[2:4], sha1Hex)
}
// blobPath is the same location inside a local repository tree.
func blobPath(root, sha1Hex string) string {
return filepath.Join(root, "data", "sha1", sha1Hex[0:2], sha1Hex[2:4], sha1Hex)
return filepath.Join(root, filepath.FromSlash(blobKey(sha1Hex)))
}
+14 -5
View File
@@ -45,10 +45,14 @@ type dirSink struct{ root string }
func (s dirSink) putBlob(sha1Hex string, verify bool, body func() []byte) (int64, error) {
blob := blobPath(s.root, sha1Hex)
if stored, err := os.ReadFile(blob); err == nil {
if !verify || blobMatchesName(stored, sha1Hex) == nil {
if !verify {
// Stat, not read: the common path must not pay to open every blob that
// is already there.
if _, err := os.Stat(blob); err == nil {
return 0, nil
}
} else if stored, err := os.ReadFile(blob); err == nil && blobMatchesName(stored, sha1Hex) == nil {
return 0, nil
}
if err := os.MkdirAll(filepath.Dir(blob), 0o755); err != nil {
return 0, fmt.Errorf("create blob directory: %w", err)
@@ -100,7 +104,7 @@ type zoneSink struct {
}
func (s zoneSink) putBlob(sha1Hex string, verify bool, body func() []byte) (int64, error) {
key := path.Join("data", "sha1", sha1Hex[0:2], sha1Hex[2:4], sha1Hex)
key := blobKey(sha1Hex)
// A throttled probe must never be read as "missing, re-upload" or as
// "present, skip", so only a confirmed Present skips the upload.
state, _, err := s.store.ProbeKey(s.ctx, key)
@@ -112,8 +116,13 @@ func (s zoneSink) putBlob(sha1Hex string, verify bool, body func() []byte) (int6
return 0, nil
}
// The probe only proved the object exists. Read it back and hold it to
// its own name; a failure here means re-upload, not abort, because
// repairing what is there is the whole point of verifying.
// its own name.
//
// This reads the storage API rather than the pull zone: emit holds the
// write credential, and a repair decision has to be made against the
// copy it is about to overwrite, not against an edge cache of it. A
// read that fails outright is a fault, not a verdict — treating it as
// "bad, re-upload" would turn a throttled zone into a full backfill.
stored, err := s.store.GetKey(s.ctx, key)
if err != nil {
return 0, fmt.Errorf("read back blob %s: %w", sha1Hex, err)
+4 -3
View File
@@ -103,7 +103,9 @@ type VerifyResult struct {
// are wrong — and a round-trip check alone would pass a frame that omits its
// content size, because Go's decoder is more capable than the client's (#86).
func Verify(options VerifyOptions) (VerifyResult, error) {
if len(options.ManifestSHA1) != 40 {
// The argument is interpolated straight into a URL path, so it is checked
// rather than trusted: exactly 20 bytes of hex, nothing else.
if sum, err := hex.DecodeString(options.ManifestSHA1); err != nil || len(sum) != sha1.Size {
return VerifyResult{}, fmt.Errorf("%q is not a manifest sha1", options.ManifestSHA1)
}
source := options.Source
@@ -198,7 +200,6 @@ func Verify(options VerifyOptions) (VerifyResult, error) {
// checkEntry fetches one blob and returns a one-line fault, or "" if it is
// exactly what the manifest entry says it is.
func checkEntry(source blobSource, entry Entry) string {
key := path.Join("data", "sha1", entry.sha1Hex()[0:2], entry.sha1Hex()[2:4], entry.sha1Hex())
// Name the resource, not just the hash: an operator has to find the thing
// in a hak, and a bare sha1 says nothing about where to look.
extension, ok := erf.ExtensionForResourceType(entry.ResType)
@@ -206,7 +207,7 @@ func checkEntry(source blobSource, entry Entry) string {
extension = strconv.Itoa(int(entry.ResType))
}
where := fmt.Sprintf("%s (%s.%s)", entry.sha1Hex(), entry.ResRef, extension)
blob, err := source.get(key)
blob, err := source.get(blobKey(entry.sha1Hex()))
if err != nil {
if errors.Is(err, errBlobMissing) {
return where + ": missing"
+35 -13
View File
@@ -60,12 +60,11 @@ func (f *verifyFixture) verify(t *testing.T, sample int) (VerifyResult, string,
return result, log.String(), err
}
// blobKey is where a resource's blob lives, addressed by the sha1 of its
// keyOf is where a resource's blob lives, addressed by the sha1 of its
// uncompressed bytes — the same path the client requests.
func blobKey(body []byte) string {
func keyOf(body []byte) string {
sum := sha1.Sum(body)
hexed := hex.EncodeToString(sum[:])
return "data/sha1/" + hexed[0:2] + "/" + hexed[2:4] + "/" + hexed
return blobKey(hex.EncodeToString(sum[:]))
}
func TestVerifyPassesACleanZone(t *testing.T) {
@@ -77,14 +76,16 @@ func TestVerifyPassesACleanZone(t *testing.T) {
if result.Failures != 0 {
t.Errorf("verify reported %d failures on a clean zone: %s", result.Failures, log)
}
if result.Checked != 3 {
t.Errorf("checked %d blobs, want 3", result.Checked)
// A default run is a full sweep, so it must reach every blob the manifest
// names — not some of them.
if result.Checked != result.Blobs || result.Blobs == 0 {
t.Errorf("checked %d of %d blobs; a full sweep must check all of them", result.Checked, result.Blobs)
}
}
func TestVerifyReportsAMissingBlob(t *testing.T) {
fixture := newVerifyFixture(t)
key := blobKey([]byte("small"))
key := keyOf([]byte("small"))
fixture.zone.mu.Lock()
delete(fixture.zone.objects, key)
fixture.zone.mu.Unlock()
@@ -103,7 +104,7 @@ func TestVerifyReportsAMissingBlob(t *testing.T) {
func TestVerifyReportsATruncatedBlob(t *testing.T) {
fixture := newVerifyFixture(t)
key := blobKey([]byte("small"))
key := keyOf([]byte("small"))
fixture.zone.mu.Lock()
fixture.zone.objects[key] = fixture.zone.objects[key][:blobHeaderBytes+4]
fixture.zone.mu.Unlock()
@@ -126,7 +127,7 @@ func TestVerifyReportsATruncatedBlob(t *testing.T) {
func TestVerifyRejectsABlobWithNoDeclaredFrameContentSize(t *testing.T) {
fixture := newVerifyFixture(t)
body := []byte("small")
key := blobKey(body)
key := keyOf(body)
fixture.zone.mu.Lock()
good := fixture.zone.objects[key]
@@ -159,7 +160,7 @@ func TestVerifyRejectsABlobWithNoDeclaredFrameContentSize(t *testing.T) {
func TestVerifyReportsWrongContents(t *testing.T) {
fixture := newVerifyFixture(t)
key := blobKey([]byte("small"))
key := keyOf([]byte("small"))
fixture.zone.mu.Lock()
// Valid framing, valid zstd, wrong bytes: only decompressing and hashing
// can see this, which is why Content-Length is not enough.
@@ -178,6 +179,27 @@ func TestVerifyReportsWrongContents(t *testing.T) {
}
}
func TestVerifyReportsAShortBlob(t *testing.T) {
fixture := newVerifyFixture(t)
key := keyOf([]byte("small"))
fixture.zone.mu.Lock()
// Well-formed all the way down and simply too short — the shape a killed
// upload leaves behind, and the one a Content-Length check would pass.
fixture.zone.objects[key] = compressBlob([]byte("sma"))
fixture.zone.mu.Unlock()
result, log, err := fixture.verify(t, 0)
if err != nil {
t.Fatalf("verify: %v", err)
}
if result.Failures != 1 {
t.Fatalf("reported %d failures, want 1: %s", result.Failures, log)
}
if !strings.Contains(log, "size mismatch") {
t.Errorf("a short blob was not reported as a size mismatch: %s", log)
}
}
func TestVerifyFailsWhenTheManifestIsNotTheOneAsked(t *testing.T) {
fixture := newVerifyFixture(t)
fixture.zone.mu.Lock()
@@ -198,8 +220,8 @@ func TestVerifySampleChecksFewerBlobs(t *testing.T) {
if result.Checked != 1 {
t.Errorf("--sample 1 checked %d blobs, want 1: %s", result.Checked, log)
}
if result.Blobs != 3 {
t.Errorf("reported %d distinct blobs, want 3", result.Blobs)
if result.Blobs <= result.Checked {
t.Errorf("sampling %d of %d blobs is not a sample", result.Checked, result.Blobs)
}
}
@@ -229,7 +251,7 @@ func TestEmitVerifyReplacesABlobThatIsNotItsName(t *testing.T) {
}
emit(false)
key := blobKey(body)
key := keyOf(body)
encoder, err := zstd.NewWriter(nil, zstd.WithEncoderConcurrency(1))
if err != nil {
t.Fatal(err)