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>
264 lines
7.9 KiB
Go
264 lines
7.9 KiB
Go
package nwsync
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/klauspost/compress/zstd"
|
|
)
|
|
|
|
// verifyFixture emits two haks and a TLK into a fake zone, assembles them, and
|
|
// hands back a verifier reading that zone the way a client would.
|
|
type verifyFixture struct {
|
|
*zoneSinkFixture
|
|
manifestSHA1 string
|
|
}
|
|
|
|
func newVerifyFixture(t *testing.T) *verifyFixture {
|
|
t.Helper()
|
|
zone := newZoneFixture(t)
|
|
dir := t.TempDir()
|
|
hak := filepath.Join(dir, "sow_test_01.hak")
|
|
// A payload under 256 bytes is the one the frame-header check exists for.
|
|
writeHak(t, hak, map[string][]byte{
|
|
"bloodstain1.tga": []byte("small"),
|
|
"appearance.2da": bytes.Repeat([]byte("2DA V2.0\n"), 200),
|
|
})
|
|
tlk := filepath.Join(dir, "sow_tlk.tlk")
|
|
if err := os.WriteFile(tlk, []byte("TLK V3.0 payload"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zone.emit(t, hak)
|
|
if _, err := Emit(EmitOptions{
|
|
ArtifactKey: artifactKey(t, tlk), ArtifactPath: tlk, As: "sow_tlk.tlk", Sink: zone.sink,
|
|
}); err != nil {
|
|
t.Fatalf("emit tlk: %v", err)
|
|
}
|
|
assembled, err := Assemble(AssembleOptions{
|
|
ArtifactKeys: []string{artifactKey(t, hak)}, TLKKey: artifactKey(t, tlk), Sink: zone.sink,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("assemble: %v", err)
|
|
}
|
|
return &verifyFixture{zoneSinkFixture: zone, manifestSHA1: assembled.SHA1}
|
|
}
|
|
|
|
func (f *verifyFixture) verify(t *testing.T, sample int) (VerifyResult, string, error) {
|
|
t.Helper()
|
|
var log bytes.Buffer
|
|
result, err := Verify(VerifyOptions{
|
|
ManifestSHA1: f.manifestSHA1,
|
|
Sample: sample,
|
|
Source: f.zone.pullZone(),
|
|
Log: &log,
|
|
})
|
|
return result, log.String(), err
|
|
}
|
|
|
|
// blobKey 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 {
|
|
sum := sha1.Sum(body)
|
|
hexed := hex.EncodeToString(sum[:])
|
|
return "data/sha1/" + hexed[0:2] + "/" + hexed[2:4] + "/" + hexed
|
|
}
|
|
|
|
func TestVerifyPassesACleanZone(t *testing.T) {
|
|
fixture := newVerifyFixture(t)
|
|
result, log, err := fixture.verify(t, 0)
|
|
if err != nil {
|
|
t.Fatalf("verify: %v", err)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestVerifyReportsAMissingBlob(t *testing.T) {
|
|
fixture := newVerifyFixture(t)
|
|
key := blobKey([]byte("small"))
|
|
fixture.zone.mu.Lock()
|
|
delete(fixture.zone.objects, key)
|
|
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, "missing") {
|
|
t.Errorf("a deleted blob was not reported as missing: %s", log)
|
|
}
|
|
}
|
|
|
|
func TestVerifyReportsATruncatedBlob(t *testing.T) {
|
|
fixture := newVerifyFixture(t)
|
|
key := blobKey([]byte("small"))
|
|
fixture.zone.mu.Lock()
|
|
fixture.zone.objects[key] = fixture.zone.objects[key][:blobHeaderBytes+4]
|
|
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, "framing") {
|
|
t.Errorf("a truncated blob was not reported as malformed framing: %s", log)
|
|
}
|
|
}
|
|
|
|
// TestVerifyRejectsABlobWithNoDeclaredFrameContentSize is the check that #86
|
|
// slipped past: the blob decompresses to exactly the right bytes, so a verifier
|
|
// that only round-trips certifies it, yet the client cannot decode it.
|
|
func TestVerifyRejectsABlobWithNoDeclaredFrameContentSize(t *testing.T) {
|
|
fixture := newVerifyFixture(t)
|
|
body := []byte("small")
|
|
key := blobKey(body)
|
|
|
|
fixture.zone.mu.Lock()
|
|
good := fixture.zone.objects[key]
|
|
encoder, err := zstd.NewWriter(nil, zstd.WithEncoderConcurrency(1))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bad := append(append([]byte{}, good[:blobHeaderBytes]...), encoder.EncodeAll(body, nil)...)
|
|
fixture.zone.objects[key] = bad
|
|
fixture.zone.mu.Unlock()
|
|
|
|
if frameDeclaresContentSize(bad[blobHeaderBytes:]) {
|
|
t.Fatal("the fixture blob declares a content size; it cannot exercise the check")
|
|
}
|
|
if got, err := decompressBlob(bad); err != nil || !bytes.Equal(got, body) {
|
|
t.Fatalf("the fixture blob must round trip, or it proves nothing: %v", err)
|
|
}
|
|
|
|
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, "content size") {
|
|
t.Errorf("undeclared frame content size was not the reported reason: %s", log)
|
|
}
|
|
}
|
|
|
|
func TestVerifyReportsWrongContents(t *testing.T) {
|
|
fixture := newVerifyFixture(t)
|
|
key := blobKey([]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.
|
|
fixture.zone.objects[key] = compressBlob([]byte("wrong"))
|
|
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, "hash mismatch") {
|
|
t.Errorf("wrong contents were not reported as a hash mismatch: %s", log)
|
|
}
|
|
}
|
|
|
|
func TestVerifyFailsWhenTheManifestIsNotTheOneAsked(t *testing.T) {
|
|
fixture := newVerifyFixture(t)
|
|
fixture.zone.mu.Lock()
|
|
fixture.zone.objects["manifests/"+fixture.manifestSHA1] = []byte("NSYM garbage")
|
|
fixture.zone.mu.Unlock()
|
|
|
|
if _, _, err := fixture.verify(t, 0); err == nil {
|
|
t.Fatal("verify accepted a manifest that is not the one requested")
|
|
}
|
|
}
|
|
|
|
func TestVerifySampleChecksFewerBlobs(t *testing.T) {
|
|
fixture := newVerifyFixture(t)
|
|
result, log, err := fixture.verify(t, 1)
|
|
if err != nil {
|
|
t.Fatalf("verify: %v", err)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestEmitVerifyReplacesABlobThatIsNotItsName covers the reason #86 could not be
|
|
// fixed by the encoder alone: emit skips whatever is already present, so every
|
|
// blob published by the broken encoder stays broken until emit stops trusting
|
|
// presence.
|
|
func TestEmitVerifyReplacesABlobThatIsNotItsName(t *testing.T) {
|
|
fixture := newZoneFixture(t)
|
|
dir := t.TempDir()
|
|
hak := filepath.Join(dir, "sow_test_01.hak")
|
|
body := []byte("blood")
|
|
writeHak(t, hak, map[string][]byte{"bloodstain1.tga": body})
|
|
|
|
emit := func(verify bool) EmitResult {
|
|
t.Helper()
|
|
result, err := Emit(EmitOptions{
|
|
ArtifactKey: artifactKey(t, hak),
|
|
ArtifactPath: hak,
|
|
Sink: fixture.sink,
|
|
Verify: verify,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("emit (verify=%v): %v", verify, err)
|
|
}
|
|
return result
|
|
}
|
|
|
|
emit(false)
|
|
key := blobKey(body)
|
|
encoder, err := zstd.NewWriter(nil, zstd.WithEncoderConcurrency(1))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fixture.zone.mu.Lock()
|
|
good := fixture.zone.objects[key]
|
|
fixture.zone.objects[key] = append(append([]byte{}, good[:blobHeaderBytes]...), encoder.EncodeAll(body, nil)...)
|
|
fixture.zone.mu.Unlock()
|
|
|
|
if plain := emit(false); plain.BlobsWritten != 0 {
|
|
t.Fatalf("a plain re-emit wrote %d blobs; it is supposed to trust presence", plain.BlobsWritten)
|
|
}
|
|
if verified := emit(true); verified.BlobsWritten != 1 {
|
|
t.Fatalf("--verify wrote %d blobs, want 1 (the bad copy must be replaced)", verified.BlobsWritten)
|
|
}
|
|
|
|
fixture.zone.mu.Lock()
|
|
repaired := fixture.zone.objects[key]
|
|
fixture.zone.mu.Unlock()
|
|
if !bytes.Equal(repaired, good) {
|
|
t.Error("the replaced blob is not what the current encoder produces")
|
|
}
|
|
if _, err := inspectBlob(repaired); err != nil {
|
|
t.Errorf("the replaced blob still fails inspection: %v", err)
|
|
}
|
|
|
|
// A second verifying run has nothing left to repair.
|
|
if again := emit(true); again.BlobsWritten != 0 {
|
|
t.Errorf("--verify rewrote %d good blobs", again.BlobsWritten)
|
|
}
|
|
}
|