Files
sow-tools/internal/pipeline/source_manifest_test.go
T
archvillainette f1fd03ee83
test-image / build-image (push) Successful in 47s
test / test (push) Successful in 1m27s
build-binaries / build-binaries (push) Successful in 2m14s
build-image / publish (push) Successful in 13s
pipeline: reject resref collisions when chunking; document erf post-write hashing (#8)
## What

Two correctness fixes surfaced in review of the direct-depot HAK artifact work.

### Reject resref+type collisions in `chunksFromManifest`

A manifest can name two distinct source paths that collapse to the same
`resref+type` (e.g. `creature/foo.tga` and `placeable/foo.tga` — resref is the
lowercase basename minus extension). The ERF writer keys resources on
`Name:Type`, so the second silently shadowed the first: an asset would vanish
from the packed HAK with no error.

`chunksFromManifest` now runs `ensureUniqueChunkResources` per chunk and fails
the build on a duplicate. This guards **both** build paths — the legacy
`--source-manifest` flow and the direct content-addressed flow
(`chunksFromSourceManifest` → `chunksFromManifest`).

### Document erf post-write hash coupling

`writeResourceData` streams the source into the output while hashing, so
size/SHA mismatches are only detected *after* the bytes are written. A non-nil
return therefore means the writer holds partial, unverified output and the
caller must discard it. Added a comment making that contract explicit;
`writeHAKArchive` already honours it (writes to a temp file, removes on any
Write error, never renames a bad archive into place).

## Tests

- `TestChunksFromManifestRejectsResrefCollision`: collision → error, distinct
  resrefs → clean. Asserts only error presence/absence — silent asset loss is
  the contract, not any specific wording.
- `go vet ./internal/erf/ ./internal/pipeline/` clean.
- `go test ./internal/erf/ ./internal/pipeline/` green.

## Follow-up

A new crucible release must be cut after this merges so the guard ships in the
binary `sow-assets-manifest` pins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Reviewed-on: #8
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
2026-06-20 07:53:04 +00:00

188 lines
6.1 KiB
Go

package pipeline
import (
"encoding/json"
"path/filepath"
"testing"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/buildinfo"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
)
const validAsset = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
// directManifestFixture returns a minimal valid direct source manifest using the
// running builder identity, so validation does not reject it on builder mismatch.
func directManifestFixture() *SourceBuildManifest {
return &SourceBuildManifest{
Schema: 1,
BuilderID: buildinfo.String(),
ModuleHAKs: []string{"core_01"},
HAKs: []SourceManifestHAK{
{
Name: "core_01",
Group: "core",
Priority: 1,
MaxBytes: 1024,
Assets: []string{"core/a.tga"},
},
},
AssetSources: map[string]SourceAsset{
"core/a.tga": {SHA256: validAsset, SizeBytes: 4},
},
}
}
func writeManifestFixture(t *testing.T, manifest *SourceBuildManifest) string {
t.Helper()
raw, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("marshal fixture: %v", err)
}
path := filepath.Join(t.TempDir(), "build-source-manifest.json")
mustWriteFile(t, path, string(raw))
return path
}
func TestLoadSourceBuildManifestDirect(t *testing.T) {
path := writeManifestFixture(t, directManifestFixture())
manifest, err := loadSourceBuildManifest(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if manifest.Schema != 1 {
t.Fatalf("schema = %d, want 1", manifest.Schema)
}
if manifest.BuilderID != buildinfo.String() {
t.Fatalf("builder_id = %q, want %q", manifest.BuilderID, buildinfo.String())
}
if len(manifest.HAKs) != 1 || manifest.HAKs[0].Name != "core_01" {
t.Fatalf("unexpected haks: %#v", manifest.HAKs)
}
source, ok := manifest.AssetSources["core/a.tga"]
if !ok || source.SHA256 != validAsset || source.SizeBytes != 4 {
t.Fatalf("unexpected asset source: %#v", manifest.AssetSources)
}
if err := validateDirectSourceManifest(manifest); err != nil {
t.Fatalf("validate valid manifest: %v", err)
}
}
func TestValidateDirectSourceManifestRejectsUnsupportedSchema(t *testing.T) {
manifest := directManifestFixture()
manifest.Schema = 2
if err := validateDirectSourceManifest(manifest); err == nil {
t.Fatal("expected schema rejection")
}
}
func TestValidateDirectSourceManifestRejectsBadSHA(t *testing.T) {
manifest := directManifestFixture()
manifest.AssetSources["core/a.tga"] = SourceAsset{SHA256: "NOTHEX", SizeBytes: 4}
if err := validateDirectSourceManifest(manifest); err == nil {
t.Fatal("expected bad sha rejection")
}
}
func TestValidateDirectSourceManifestRejectsNegativeSize(t *testing.T) {
manifest := directManifestFixture()
manifest.AssetSources["core/a.tga"] = SourceAsset{SHA256: validAsset, SizeBytes: -1}
if err := validateDirectSourceManifest(manifest); err == nil {
t.Fatal("expected negative size rejection")
}
}
func TestValidateDirectSourceManifestRejectsMissingSource(t *testing.T) {
manifest := directManifestFixture()
delete(manifest.AssetSources, "core/a.tga")
if err := validateDirectSourceManifest(manifest); err == nil {
t.Fatal("expected missing source rejection")
}
}
func TestValidateDirectSourceManifestRejectsUnusedSource(t *testing.T) {
manifest := directManifestFixture()
manifest.AssetSources["core/orphan.tga"] = SourceAsset{SHA256: validAsset, SizeBytes: 4}
if err := validateDirectSourceManifest(manifest); err == nil {
t.Fatal("expected unused source rejection")
}
}
func TestValidateDirectSourceManifestRejectsDuplicateAssetAcrossHAKs(t *testing.T) {
manifest := directManifestFixture()
manifest.HAKs = append(manifest.HAKs, SourceManifestHAK{
Name: "core_02",
Group: "core",
Priority: 1,
MaxBytes: 1024,
Assets: []string{"core/a.tga"},
})
if err := validateDirectSourceManifest(manifest); err == nil {
t.Fatal("expected duplicate-asset rejection")
}
}
func TestValidateDirectSourceManifestRejectsTraversalPath(t *testing.T) {
manifest := directManifestFixture()
manifest.HAKs[0].Assets = []string{"../escape.tga"}
manifest.AssetSources = map[string]SourceAsset{
"../escape.tga": {SHA256: validAsset, SizeBytes: 4},
}
if err := validateDirectSourceManifest(manifest); err == nil {
t.Fatal("expected traversal rejection")
}
}
func TestValidateDirectSourceManifestRejectsBuilderMismatch(t *testing.T) {
manifest := directManifestFixture()
manifest.BuilderID = "crucible deadbeef"
if err := validateDirectSourceManifest(manifest); err == nil {
t.Fatal("expected builder mismatch rejection")
}
}
// Two distinct source paths can collapse to the same resref+type; the chunker
// must reject that rather than let one silently shadow the other in the ERF.
func TestChunksFromManifestRejectsResrefCollision(t *testing.T) {
tga, ok := erf.HAKResourceTypeForExtension("tga")
if !ok {
t.Fatal("tga is not a hak resource type")
}
mk := func(rel, resref string) assetResource {
r := erf.Resource{Name: resref, Type: tga}
return assetResource{Rel: rel, Resource: r, Size: erf.ArchiveSize([]erf.Resource{r})}
}
entry := BuildManifestHAK{Name: "core_01", Group: "core"}
collide := []assetResource{mk("creature/foo.tga", "foo"), mk("placeable/foo.tga", "foo")}
entry.Assets = []string{"creature/foo.tga", "placeable/foo.tga"}
if _, err := chunksFromManifest(collide, []BuildManifestHAK{entry}); err == nil {
t.Fatal("expected resref+type collision rejection")
}
distinct := []assetResource{mk("creature/foo.tga", "foo"), mk("placeable/bar.tga", "bar")}
entry.Assets = []string{"creature/foo.tga", "placeable/bar.tga"}
if _, err := chunksFromManifest(distinct, []BuildManifestHAK{entry}); err != nil {
t.Fatalf("distinct resrefs should pack cleanly: %v", err)
}
}
func TestContentAddressedBlobPath(t *testing.T) {
root := "/var/cache/blobs"
got, err := contentAddressedBlobPath(root, validAsset)
if err != nil {
t.Fatalf("blob path: %v", err)
}
want := filepath.Join(root, "sha256", "aa", "aa", validAsset)
if got != want {
t.Fatalf("blob path = %q, want %q", got, want)
}
if _, err := contentAddressedBlobPath(root, "NOTHEX"); err == nil {
t.Fatal("expected invalid sha rejection")
}
if _, err := contentAddressedBlobPath("", validAsset); err == nil {
t.Fatal("expected empty root rejection")
}
}