## 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>
188 lines
6.1 KiB
Go
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")
|
|
}
|
|
}
|