From ec9166cb883d9a082fe6839900a51889a448d28e Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sat, 15 Aug 2026 19:46:54 +0100 Subject: [PATCH 1/4] Use shared integrity module --- Makefile | 2 - go.mod | 1 + go.sum | 2 + integrity/bench_test.go | 21 ------- integrity/fuzz_test.go | 58 ------------------- integrity/sri.go | 87 ++++++++++++++++------------ integrity/sri_test.go | 14 +++++ lock/cyclonedx.go | 125 +++++++++++++++++++++++++++++++--------- lock/cyclonedx_test.go | 87 ++++++++++++++++++++++++++++ 9 files changed, 251 insertions(+), 146 deletions(-) delete mode 100644 integrity/bench_test.go delete mode 100644 integrity/fuzz_test.go diff --git a/Makefile b/Makefile index 7811eac..7751725 100644 --- a/Makefile +++ b/Makefile @@ -28,8 +28,6 @@ fuzz: go test -fuzz=FuzzRemoveEntry -fuzztime=$(FUZZTIME) ./manifest/ go test -fuzz=FuzzRead -fuzztime=$(FUZZTIME) ./lock/ go test -fuzz=FuzzFormat -fuzztime=$(FUZZTIME) ./sniff/ - go test -fuzz=FuzzParseSRI -fuzztime=$(FUZZTIME) ./integrity/ - go test -fuzz=FuzzRoundTrip -fuzztime=$(FUZZTIME) ./integrity/ go test -fuzz=FuzzIsSticky -fuzztime=$(FUZZTIME) ./source/npm/ go test -fuzz=FuzzFindSignature -fuzztime=$(FUZZTIME) ./source/npm/ go test -fuzz=FuzzSafeOut -fuzztime=$(FUZZTIME) . diff --git a/go.mod b/go.mod index d5a8e5d..686cdb5 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/git-pkgs/archives v0.5.0 github.com/git-pkgs/attestation v0.1.0 github.com/git-pkgs/cooldown v0.1.1 + github.com/git-pkgs/integrity v0.1.0 github.com/git-pkgs/purl v0.1.15 github.com/git-pkgs/registries v0.6.4 github.com/git-pkgs/sbom v0.1.3 diff --git a/go.sum b/go.sum index 0a6db74..5320e07 100644 --- a/go.sum +++ b/go.sum @@ -232,6 +232,8 @@ github.com/git-pkgs/attestation v0.1.0 h1:aj10Ls05zHWPL7114r0Se8RKC/w8tJgfnzh3dU github.com/git-pkgs/attestation v0.1.0/go.mod h1:NSctZpcwmxCiTQ6z3b4X6SCT2N7fQtuPXvizkRgPgBU= github.com/git-pkgs/cooldown v0.1.1 h1:9OqqzCB8gANz/y44SmqGD0Jp8Qtu81D1sCbKl6Ehg7w= github.com/git-pkgs/cooldown v0.1.1/go.mod h1:v7APuK/UouTiu8mWQZbdDmj7DfxxkGUeuhjaRB5gv9E= +github.com/git-pkgs/integrity v0.1.0 h1:ZLY5CmTVPQW4fMY4JDUdPFyevZc62xtkVPq71uw17Tw= +github.com/git-pkgs/integrity v0.1.0/go.mod h1:hxu24lcd230377hCF28JQW7sGcCbuNLqo/0ULeb+F1Q= github.com/git-pkgs/magic v0.1.0 h1:xLrqq7CMXB9g5bJnmJyKw17Rvlh0GFiEmO6e5RFsoeY= github.com/git-pkgs/magic v0.1.0/go.mod h1:3ndidt+yvFaI1M0aEkkzkOlFnLPkeVQASIUojazcxCI= github.com/git-pkgs/pom v0.1.5 h1:TGT8Az2OMxGWsXnSagtUMGzZm7Oax8HrSCteA+mi0qY= diff --git a/integrity/bench_test.go b/integrity/bench_test.go deleted file mode 100644 index 7f430c5..0000000 --- a/integrity/bench_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package integrity - -import "testing" - -const benchSRI = "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC" - -func BenchmarkParseSRI(b *testing.B) { - b.ReportAllocs() - for b.Loop() { - _, _, _ = ParseSRI(benchSRI) - } -} - -func BenchmarkFormatSRI(b *testing.B) { - _, hex, _ := ParseSRI(benchSRI) - b.ReportAllocs() - b.ResetTimer() - for b.Loop() { - _, _ = FormatSRI("SHA-384", hex) - } -} diff --git a/integrity/fuzz_test.go b/integrity/fuzz_test.go deleted file mode 100644 index b85af5c..0000000 --- a/integrity/fuzz_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package integrity - -import ( - "strings" - "testing" -) - -// FuzzParseSRI exercises the SRI string parser. ParseSRI runs strings.Cut -// then a map lookup then base64.StdEncoding.DecodeString — each step -// could panic or misbehave on malformed input. Contract: never panics, -// and when err is nil the returned alg is one of the three CycloneDX -// algorithm names. -func FuzzParseSRI(f *testing.F) { - f.Add("") - f.Add("-") - f.Add("sha384-") - f.Add("sha384") - f.Add("sha384-aGVsbG8=") // valid: "hello" - f.Add("SHA384-aGVsbG8=") // mixed case prefix - f.Add("sha384-!!!!") // bad base64 - f.Add("md5-aGVsbG8=") // unsupported alg - f.Add(strings.Repeat("sha384-", 100) + "aGVsbG8=") - - validAlgs := map[string]bool{CDXSHA256: true, CDXSHA384: true, CDXSHA512: true} - f.Fuzz(func(t *testing.T, s string) { - alg, _, err := ParseSRI(s) - if err == nil && !validAlgs[alg] { - t.Errorf("ParseSRI(%q) returned alg %q with nil error; want one of SHA-256/384/512", s, alg) - } - }) -} - -// FuzzRoundTrip covers FormatSRI ∘ ParseSRI. Anything ParseSRI accepts -// should round-trip through FormatSRI and parse again identically. -func FuzzRoundTrip(f *testing.F) { - f.Add("sha384-aGVsbG8=") - f.Add("sha256-aGVsbG8=") - f.Add("sha512-AAAA") - f.Add("SHA384-aGVsbG8=") - - f.Fuzz(func(t *testing.T, s string) { - alg, digest, err := ParseSRI(s) - if err != nil { - return - } - out, err := FormatSRI(alg, digest) - if err != nil { - t.Fatalf("FormatSRI failed after ParseSRI(%q) accepted: %v", s, err) - } - alg2, digest2, err := ParseSRI(out) - if err != nil { - t.Fatalf("re-parse of round-tripped %q failed: %v", out, err) - } - if alg2 != alg || digest2 != digest { - t.Errorf("round-trip drifted: in=(%q,%q) out=(%q,%q)", alg, digest, alg2, digest2) - } - }) -} diff --git a/integrity/sri.go b/integrity/sri.go index 12a4e3c..60e8bae 100644 --- a/integrity/sri.go +++ b/integrity/sri.go @@ -1,60 +1,71 @@ -// Package integrity provides Subresource Integrity helpers. +// Package integrity provides deprecated compatibility wrappers for +// github.com/git-pkgs/integrity. package integrity import ( - "encoding/base64" - "encoding/hex" "fmt" - "strings" + + shared "github.com/git-pkgs/integrity" ) -// CycloneDX hash-algorithm names, the mirror of the SRI algorithm -// prefixes (sha256/384/512). +// CycloneDX hash-algorithm names retained for compatibility. const ( CDXSHA256 = "SHA-256" CDXSHA384 = "SHA-384" CDXSHA512 = "SHA-512" ) -var sriAlgToCDX = map[string]string{ - "sha256": CDXSHA256, - "sha384": CDXSHA384, - "sha512": CDXSHA512, +// ParseSRI decodes the first digest in a Subresource Integrity metadata list +// into a CycloneDX hash algorithm name and hex-encoded digest. +// +// Deprecated: use github.com/git-pkgs/integrity.ParseSRI. +func ParseSRI(value string) (algorithm, hexDigest string, err error) { + digests, err := shared.ParseSRI(value) + if err != nil { + return "", "", err + } + digest := digests[0] + return cdxAlgorithm(digest.Algorithm()), digest.Hex(), nil } -// ParseSRI decodes a Subresource Integrity string ("sha384-") into a -// CycloneDX hash algorithm name ("SHA-384") and hex-encoded digest. -func ParseSRI(sri string) (alg, hexDigest string, err error) { - prefix, b64, ok := strings.Cut(sri, "-") - if !ok { - return "", "", fmt.Errorf("integrity %q: missing algorithm prefix", sri) - } - cdxAlg, ok := sriAlgToCDX[strings.ToLower(prefix)] - if !ok { - return "", "", fmt.Errorf("integrity %q: unsupported algorithm %q", sri, prefix) +// FormatSRI encodes a CycloneDX algorithm name and hex digest as an SRI +// string. +// +// Deprecated: use github.com/git-pkgs/integrity.ParseHex and Digest.SRI. +func FormatSRI(cdxAlgorithm, hexDigest string) (string, error) { + algorithm, err := sriAlgorithm(cdxAlgorithm) + if err != nil { + return "", err } - raw, err := base64.StdEncoding.DecodeString(b64) + digest, err := shared.ParseHex(algorithm, hexDigest) if err != nil { - return "", "", fmt.Errorf("integrity %q: %w", sri, err) + return "", err } - return cdxAlg, hex.EncodeToString(raw), nil + return digest.SRI(), nil } -// FormatSRI encodes a CycloneDX algorithm name and hex digest as an SRI string. -func FormatSRI(cdxAlg, hexDigest string) (string, error) { - var prefix string - for k, v := range sriAlgToCDX { - if v == cdxAlg { - prefix = k - break - } - } - if prefix == "" { - return "", fmt.Errorf("unsupported CycloneDX algorithm %q", cdxAlg) +func cdxAlgorithm(algorithm shared.Algorithm) string { + switch algorithm { + case shared.SHA256: + return CDXSHA256 + case shared.SHA384: + return CDXSHA384 + case shared.SHA512: + return CDXSHA512 + default: + return "" } - raw, err := hex.DecodeString(hexDigest) - if err != nil { - return "", err +} + +func sriAlgorithm(algorithm string) (shared.Algorithm, error) { + switch algorithm { + case CDXSHA256: + return shared.SHA256, nil + case CDXSHA384: + return shared.SHA384, nil + case CDXSHA512: + return shared.SHA512, nil + default: + return 0, fmt.Errorf("unsupported CycloneDX algorithm %q", algorithm) } - return prefix + "-" + base64.StdEncoding.EncodeToString(raw), nil } diff --git a/integrity/sri_test.go b/integrity/sri_test.go index 673fb17..1496ec5 100644 --- a/integrity/sri_test.go +++ b/integrity/sri_test.go @@ -50,3 +50,17 @@ func TestSRIRoundTrip(t *testing.T) { t.Errorf("round-trip: %q != %q", out, in) } } + +func TestParseSRIMultipleReturnsFirstDigest(t *testing.T) { + input := "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU= sha512-z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg==" + algorithm, digest, err := ParseSRI(input) + if err != nil { + t.Fatal(err) + } + if algorithm != CDXSHA256 { + t.Errorf("algorithm = %q, want %q", algorithm, CDXSHA256) + } + if digest != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" { + t.Errorf("digest = %q", digest) + } +} diff --git a/lock/cyclonedx.go b/lock/cyclonedx.go index 3d4bee2..7285a34 100644 --- a/lock/cyclonedx.go +++ b/lock/cyclonedx.go @@ -2,10 +2,11 @@ package lock import ( "cmp" + "fmt" "slices" "strconv" - "github.com/git-pkgs/pin/integrity" + "github.com/git-pkgs/integrity" ) const ( @@ -152,8 +153,8 @@ func packageComponent(purl string, assets []Asset) cdxComponent { Version: first.Version, PURL: purl, } - if h := encodePackageHash(first.PackageIntegrity); h != nil { - c.Hashes = []cdxHash{*h} + if hashes := encodePackageHashes(first.PackageIntegrity); len(hashes) > 0 { + c.Hashes = hashes } if first.License != "" { c.Licenses = []cdxLicense{{License: cdxLicenseID{ID: first.License}}} @@ -224,8 +225,8 @@ func fileComponent(parentPURL string, a Asset) cdxComponent { Name: a.Path, } if a.Integrity != "" { - if alg, hex, err := integrity.ParseSRI(a.Integrity); err == nil { - c.Hashes = []cdxHash{{Alg: alg, Content: hex}} + if hashes := encodeSRIHashes(a.Integrity); len(hashes) > 0 { + c.Hashes = hashes } } if a.URL != "" { @@ -252,9 +253,9 @@ func fromCDX(bom *cdxBOM) (*Lock, error) { OutDir: findProp(bom.Metadata.Properties, propOutDir), } for _, pkg := range bom.Components { - var pkgIntegrity string - if len(pkg.Hashes) > 0 { - pkgIntegrity = decodePackageHash(pkg.Hashes[0]) + pkgIntegrity, err := decodePackageHashes(pkg.Hashes) + if err != nil { + return nil, fmt.Errorf("package %q integrity: %w", pkg.Name, err) } var license string if len(pkg.Licenses) > 0 { @@ -277,11 +278,11 @@ func fromCDX(bom *cdxBOM) (*Lock, error) { Format: findProp(file.Properties, propFormat), URL: findExtRef(file.ExternalReferences, "distribution"), } - if len(file.Hashes) > 0 { - if sri, err := integrity.FormatSRI(file.Hashes[0].Alg, file.Hashes[0].Content); err == nil { - a.Integrity = sri - } + fileIntegrity, err := decodeSRIHashes(file.Hashes) + if err != nil { + return nil, fmt.Errorf("file %q integrity: %w", file.Name, err) } + a.Integrity = fileIntegrity if s := findProp(file.Properties, propSize); s != "" { a.Size, _ = strconv.ParseInt(s, 10, 64) } @@ -291,32 +292,102 @@ func fromCDX(bom *cdxBOM) (*Lock, error) { return l, nil } -// encodePackageHash converts an Asset.PackageIntegrity (either an SRI string -// from npm or a bare commit SHA from a forge source) into a CycloneDX hash -// entry. Forge SHAs are emitted as SHA-1; npm SRI is decoded to hex. -func encodePackageHash(pkgIntegrity string) *cdxHash { +func encodeSRIHashes(value string) []cdxHash { + digests, err := integrity.ParseSRI(value) + if err != nil { + return nil + } + hashes := make([]cdxHash, 0, len(digests)) + for _, digest := range digests { + hashes = append(hashes, cdxHash{ + Alg: cdxAlgorithm(digest.Algorithm()), + Content: digest.Hex(), + }) + } + return hashes +} + +func decodeSRIHashes(hashes []cdxHash) (string, error) { + digests := make(integrity.SRI, 0, len(hashes)) + for _, hash := range hashes { + algorithm, ok := sriAlgorithm(hash.Alg) + if !ok { + continue + } + digest, err := integrity.ParseHex(algorithm, hash.Content) + if err != nil { + return "", fmt.Errorf("invalid %s digest: %w", hash.Alg, err) + } + digests = append(digests, digest) + } + return integrity.FormatSRI(digests), nil +} + +func cdxAlgorithm(algorithm integrity.Algorithm) string { + switch algorithm { + case integrity.SHA256: + return "SHA-256" + case integrity.SHA384: + return "SHA-384" + case integrity.SHA512: + return "SHA-512" + default: + return "" + } +} + +func sriAlgorithm(algorithm string) (integrity.Algorithm, bool) { + switch algorithm { + case "SHA-256": + return integrity.SHA256, true + case "SHA-384": + return integrity.SHA384, true + case "SHA-512": + return integrity.SHA512, true + default: + return 0, false + } +} + +// encodePackageHashes converts Asset.PackageIntegrity, either an SRI metadata +// list from npm or a bare commit SHA from a forge source, into CycloneDX hash +// entries. Forge SHAs remain SHA-1 values local to pin. +func encodePackageHashes(pkgIntegrity string) []cdxHash { if pkgIntegrity == "" { return nil } - if alg, hex, err := integrity.ParseSRI(pkgIntegrity); err == nil { - return &cdxHash{Alg: alg, Content: hex} + if hashes := encodeSRIHashes(pkgIntegrity); len(hashes) > 0 { + return hashes } if isCommitSHA(pkgIntegrity) { - return &cdxHash{Alg: "SHA-1", Content: pkgIntegrity} + return []cdxHash{{Alg: "SHA-1", Content: pkgIntegrity}} } return nil } -// decodePackageHash is the inverse of encodePackageHash. SHA-1 entries pass -// through as bare hex (forge commit SHAs); everything else round-trips via SRI. -func decodePackageHash(h cdxHash) string { - if h.Alg == "SHA-1" && isCommitSHA(h.Content) { - return h.Content +// decodePackageHashes is the inverse of encodePackageHashes. SHA-1 entries +// remain bare commit values, while supported SRI digests stay in list order. +func decodePackageHashes(hashes []cdxHash) (string, error) { + var commitSHA string + var sriHashes []cdxHash + for _, hash := range hashes { + if hash.Alg == "SHA-1" { + if !isCommitSHA(hash.Content) { + return "", fmt.Errorf("invalid SHA-1 commit digest %q", hash.Content) + } + if commitSHA == "" { + commitSHA = hash.Content + } + continue + } + if _, ok := sriAlgorithm(hash.Alg); ok { + sriHashes = append(sriHashes, hash) + } } - if sri, err := integrity.FormatSRI(h.Alg, h.Content); err == nil { - return sri + if len(sriHashes) > 0 { + return decodeSRIHashes(sriHashes) } - return "" + return commitSHA, nil } func isCommitSHA(s string) bool { diff --git a/lock/cyclonedx_test.go b/lock/cyclonedx_test.go index 165b915..1a098bc 100644 --- a/lock/cyclonedx_test.go +++ b/lock/cyclonedx_test.go @@ -1,6 +1,7 @@ package lock import ( + "strings" "testing" "github.com/git-pkgs/sbom" @@ -30,3 +31,89 @@ func TestOutputIsValidCycloneDX(t *testing.T) { } } } + +func TestCycloneDXMultiHashRoundTrip(t *testing.T) { + const sri256 = "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=" + fileIntegrity := strings.Join([]string{sri256, sri384a, sri384b}, " ") + packageIntegrity := strings.Join([]string{sri256, sri512}, " ") + want := &Lock{Assets: []Asset{{ + Name: "multi-hash", + Version: "1.0.0", + PURL: "pkg:npm/multi-hash@1.0.0", + Path: "dist/index.js", + Out: "multi-hash/index.js", + Integrity: fileIntegrity, + PackageIntegrity: packageIntegrity, + }}} + + encoded := write(t, want) + got, err := Read(strings.NewReader(encoded)) + if err != nil { + t.Fatalf("Read: %v", err) + } + if len(got.Assets) != 1 { + t.Fatalf("Assets = %d, want 1", len(got.Assets)) + } + if got.Assets[0].Integrity != fileIntegrity { + t.Errorf("Integrity = %q, want %q", got.Assets[0].Integrity, fileIntegrity) + } + if got.Assets[0].PackageIntegrity != packageIntegrity { + t.Errorf("PackageIntegrity = %q, want %q", got.Assets[0].PackageIntegrity, packageIntegrity) + } +} + +func TestCycloneDXRejectsMalformedSupportedHashes(t *testing.T) { + tests := []struct { + name string + bom cdxBOM + }{ + { + name: "package hash", + bom: cdxBOM{Components: []cdxComponent{{ + Name: "bad-package", + Hashes: []cdxHash{{Alg: "SHA-512", Content: "abcd"}}, + }}}, + }, + { + name: "file hash", + bom: cdxBOM{Components: []cdxComponent{{ + Name: "bad-file", + Components: []cdxComponent{{ + Name: "index.js", + Hashes: []cdxHash{{Alg: "SHA-384", Content: "not-hex"}}, + }}, + }}}, + }, + { + name: "forge commit hash", + bom: cdxBOM{Components: []cdxComponent{{ + Name: "bad-commit", + Hashes: []cdxHash{{Alg: "SHA-1", Content: "abc123"}}, + }}}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := fromCDX(&test.bom); err == nil { + t.Fatal("fromCDX returned nil error") + } + }) + } +} + +func TestCycloneDXIgnoresUnsupportedHashes(t *testing.T) { + bom := cdxBOM{Components: []cdxComponent{{ + Name: "other-hash", + Components: []cdxComponent{{ + Name: "index.js", + Hashes: []cdxHash{{Alg: "BLAKE3", Content: "abcd"}}, + }}, + }}} + lock, err := fromCDX(&bom) + if err != nil { + t.Fatalf("fromCDX: %v", err) + } + if len(lock.Assets) != 1 || lock.Assets[0].Integrity != "" { + t.Errorf("Assets = %+v", lock.Assets) + } +} From 04dcc7eff90ea54c6bc7ebe465e9e2820f0511b3 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sat, 15 Aug 2026 22:33:08 +0100 Subject: [PATCH 2/4] Finish integrity migration --- go.mod | 2 +- go.sum | 4 +-- integrity/sri.go | 71 ------------------------------------------- integrity/sri_test.go | 66 ---------------------------------------- 4 files changed, 3 insertions(+), 140 deletions(-) delete mode 100644 integrity/sri.go delete mode 100644 integrity/sri_test.go diff --git a/go.mod b/go.mod index 686cdb5..64693c0 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/git-pkgs/archives v0.5.0 github.com/git-pkgs/attestation v0.1.0 github.com/git-pkgs/cooldown v0.1.1 - github.com/git-pkgs/integrity v0.1.0 + github.com/git-pkgs/integrity v0.1.1 github.com/git-pkgs/purl v0.1.15 github.com/git-pkgs/registries v0.6.4 github.com/git-pkgs/sbom v0.1.3 diff --git a/go.sum b/go.sum index 5320e07..9146a38 100644 --- a/go.sum +++ b/go.sum @@ -232,8 +232,8 @@ github.com/git-pkgs/attestation v0.1.0 h1:aj10Ls05zHWPL7114r0Se8RKC/w8tJgfnzh3dU github.com/git-pkgs/attestation v0.1.0/go.mod h1:NSctZpcwmxCiTQ6z3b4X6SCT2N7fQtuPXvizkRgPgBU= github.com/git-pkgs/cooldown v0.1.1 h1:9OqqzCB8gANz/y44SmqGD0Jp8Qtu81D1sCbKl6Ehg7w= github.com/git-pkgs/cooldown v0.1.1/go.mod h1:v7APuK/UouTiu8mWQZbdDmj7DfxxkGUeuhjaRB5gv9E= -github.com/git-pkgs/integrity v0.1.0 h1:ZLY5CmTVPQW4fMY4JDUdPFyevZc62xtkVPq71uw17Tw= -github.com/git-pkgs/integrity v0.1.0/go.mod h1:hxu24lcd230377hCF28JQW7sGcCbuNLqo/0ULeb+F1Q= +github.com/git-pkgs/integrity v0.1.1 h1:nHQ7SktOiGM1dOb5BFnkdtttG/6FCgE6r5ru6QnsGts= +github.com/git-pkgs/integrity v0.1.1/go.mod h1:hxu24lcd230377hCF28JQW7sGcCbuNLqo/0ULeb+F1Q= github.com/git-pkgs/magic v0.1.0 h1:xLrqq7CMXB9g5bJnmJyKw17Rvlh0GFiEmO6e5RFsoeY= github.com/git-pkgs/magic v0.1.0/go.mod h1:3ndidt+yvFaI1M0aEkkzkOlFnLPkeVQASIUojazcxCI= github.com/git-pkgs/pom v0.1.5 h1:TGT8Az2OMxGWsXnSagtUMGzZm7Oax8HrSCteA+mi0qY= diff --git a/integrity/sri.go b/integrity/sri.go deleted file mode 100644 index 60e8bae..0000000 --- a/integrity/sri.go +++ /dev/null @@ -1,71 +0,0 @@ -// Package integrity provides deprecated compatibility wrappers for -// github.com/git-pkgs/integrity. -package integrity - -import ( - "fmt" - - shared "github.com/git-pkgs/integrity" -) - -// CycloneDX hash-algorithm names retained for compatibility. -const ( - CDXSHA256 = "SHA-256" - CDXSHA384 = "SHA-384" - CDXSHA512 = "SHA-512" -) - -// ParseSRI decodes the first digest in a Subresource Integrity metadata list -// into a CycloneDX hash algorithm name and hex-encoded digest. -// -// Deprecated: use github.com/git-pkgs/integrity.ParseSRI. -func ParseSRI(value string) (algorithm, hexDigest string, err error) { - digests, err := shared.ParseSRI(value) - if err != nil { - return "", "", err - } - digest := digests[0] - return cdxAlgorithm(digest.Algorithm()), digest.Hex(), nil -} - -// FormatSRI encodes a CycloneDX algorithm name and hex digest as an SRI -// string. -// -// Deprecated: use github.com/git-pkgs/integrity.ParseHex and Digest.SRI. -func FormatSRI(cdxAlgorithm, hexDigest string) (string, error) { - algorithm, err := sriAlgorithm(cdxAlgorithm) - if err != nil { - return "", err - } - digest, err := shared.ParseHex(algorithm, hexDigest) - if err != nil { - return "", err - } - return digest.SRI(), nil -} - -func cdxAlgorithm(algorithm shared.Algorithm) string { - switch algorithm { - case shared.SHA256: - return CDXSHA256 - case shared.SHA384: - return CDXSHA384 - case shared.SHA512: - return CDXSHA512 - default: - return "" - } -} - -func sriAlgorithm(algorithm string) (shared.Algorithm, error) { - switch algorithm { - case CDXSHA256: - return shared.SHA256, nil - case CDXSHA384: - return shared.SHA384, nil - case CDXSHA512: - return shared.SHA512, nil - default: - return 0, fmt.Errorf("unsupported CycloneDX algorithm %q", algorithm) - } -} diff --git a/integrity/sri_test.go b/integrity/sri_test.go deleted file mode 100644 index 1496ec5..0000000 --- a/integrity/sri_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package integrity - -import "testing" - -func TestParseSRI(t *testing.T) { - cases := []struct { - in string - wantAlg string - wantHex string - wantErr bool - }{ - {"sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC", "SHA-384", "a2a56e01f5d129aa7b7dd81c098e6eca433af91f46a90f0afeec72f6bc7b1cd42519897590fcd0868d70c7827063cc02", false}, - {"sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", "SHA-256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", false}, - {"sha512-z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg==", "SHA-512", "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", false}, - {"", "", "", true}, - {"sha384-not!base64", "", "", true}, - {"md5-abc", "", "", true}, - {"sha384", "", "", true}, - } - for _, tc := range cases { - t.Run(tc.in, func(t *testing.T) { - alg, hex, err := ParseSRI(tc.in) - if (err != nil) != tc.wantErr { - t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr) - } - if tc.wantErr { - return - } - if alg != tc.wantAlg { - t.Errorf("alg = %q, want %q", alg, tc.wantAlg) - } - if hex != tc.wantHex { - t.Errorf("hex = %q, want %q", hex, tc.wantHex) - } - }) - } -} - -func TestSRIRoundTrip(t *testing.T) { - in := "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC" - alg, hex, err := ParseSRI(in) - if err != nil { - t.Fatal(err) - } - out, err := FormatSRI(alg, hex) - if err != nil { - t.Fatal(err) - } - if out != in { - t.Errorf("round-trip: %q != %q", out, in) - } -} - -func TestParseSRIMultipleReturnsFirstDigest(t *testing.T) { - input := "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU= sha512-z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg==" - algorithm, digest, err := ParseSRI(input) - if err != nil { - t.Fatal(err) - } - if algorithm != CDXSHA256 { - t.Errorf("algorithm = %q, want %q", algorithm, CDXSHA256) - } - if digest != "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" { - t.Errorf("digest = %q", digest) - } -} From 85742d05765a423d9cb4cfca0c857e235e28e68e Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sat, 15 Aug 2026 22:41:15 +0100 Subject: [PATCH 3/4] Document multi-hash lockfiles --- README.md | 4 ++-- docs/SPEC.md | 20 +++++++++++--------- docs/schema/pin.lock.schema.json | 4 ++-- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index a408f44..d5e7282 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ assets: ## Lockfile -`pin.lock` is a valid CycloneDX 1.6 SBOM. Each package becomes a `library` component with the registry tarball hash; each vendored file becomes a nested `file` component with its own SHA-384, the CDN URL, and pin-specific metadata under a `pin:` property namespace. Any CycloneDX consumer (Dependency-Track, GUAC, OSV-scanner, `git-pkgs sbom`) reads it directly. `serialNumber` and `metadata.timestamp` are deliberately omitted so re-runs are byte-stable and parallel branches don't conflict on the file. +`pin.lock` is a valid CycloneDX 1.6 SBOM. Each package becomes a `library` component with the registry tarball hashes; each vendored file becomes a nested `file` component with its integrity hashes, the CDN URL, and pin-specific metadata under a `pin:` property namespace. New file entries use SHA-384. Any CycloneDX consumer (Dependency-Track, GUAC, OSV-scanner, `git-pkgs sbom`) reads it directly. `serialNumber` and `metadata.timestamp` are deliberately omitted so re-runs are byte-stable and parallel branches don't conflict on the file. The schema is in [docs/SPEC.md](docs/SPEC.md), the defences in [docs/SECURITY.md](docs/SECURITY.md), and the adversary-by-asset model in [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md). @@ -215,7 +215,7 @@ Source resolvers are pluggable by purl type. Register a new resolver for any pre c.RegisterResolver("ipfs", myIPFSResolver{}) ``` -The full Client surface: `Sync`, `Verify`, `Outdated`, `Add`, `Remove`, plus the package-level `List`, `Path`, `Init`, `SBOM`, `EncodeLock`. The `manifest`, `lock`, `pinfs`, `integrity`, `cdn`, `sniff`, `source` (with `source/npm`, `source/forge`, `source/rawurl`), and `assets` sub-packages are all public. +The full Client surface: `Sync`, `Verify`, `Outdated`, `Add`, `Remove`, plus the package-level `List`, `Path`, `Init`, `SBOM`, `EncodeLock`. The `manifest`, `lock`, `pinfs`, `cdn`, `sniff`, `source` (with `source/npm`, `source/forge`, `source/rawurl`), and `assets` sub-packages are all public. `SyncOptions.FS` redirects pin's outputs (vendored files + `pin.lock`) into anything that implements `pinfs.Writer`. The default writes to local paths under `SyncOptions.Dir`; `pinfs.NewMemory()` keeps everything in process, and a custom implementation can pipe writes into a tarball, an archive, or an in-memory build artefact. diff --git a/docs/SPEC.md b/docs/SPEC.md index ce4221e..f68066f 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -96,12 +96,14 @@ order is stable and a writer MUST produce it; a reader MAY rely on it. - `bom-ref` equals `purl`. Both fields exist for CycloneDX consumers that key by either. -- `hashes[0]` is the package-level integrity anchor. Encoding depends - on the source kind, summarised below: +- `hashes[]` contains the package-level integrity anchors. npm metadata may + contain several supported SRI digests; pin preserves each SHA-256, SHA-384, + and SHA-512 entry in source order. Other source kinds use one entry. Encoding + depends on the source kind, summarised below: - | Source | purl prefix | `hashes[0].alg` | `hashes[0].content` | + | Source | purl prefix | `hashes[].alg` | `hashes[].content` | |-------------------|-----------------|--------------------------|------------------------------------------------| - | npm | `pkg:npm/` | `SHA-512` (or the algorithm in `dist.integrity`) | hex of the registry tarball | + | npm | `pkg:npm/` | each supported algorithm in `dist.integrity` | hex of the registry tarball | | github | `pkg:github/` | `SHA-1` | hex of the resolved commit SHA | | url (TOFU) | `pkg:generic/` | `SHA-384` | hex of the single fetched file | @@ -145,11 +147,11 @@ commit SHA; writers MUST write both. identifying the file inside the package. - `name` is the file's path inside the package (the source-of-record path, not the on-disk output path). -- `hashes[0]` is the Subresource Integrity hash of the file's bytes, - encoded as hex per CycloneDX convention. SHA-384 is the default - algorithm chosen because it's what browsers accept in - `