diff --git a/pkg/skills/lockfile/contentdigest.go b/pkg/skills/lockfile/contentdigest.go new file mode 100644 index 0000000000..58a00d8af8 --- /dev/null +++ b/pkg/skills/lockfile/contentdigest.go @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package lockfile + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" +) + +// ContentFile is a single file used for contentDigest computation. +type ContentFile struct { + // Path is the relative path within the skill directory, using either + // native or slash separators. + Path string + // Content is the raw file bytes. + Content []byte +} + +// ContentDigest computes a deterministic SHA-256 dirhash over files. +// +// Algorithm (content-only; file modes and timestamps are ignored): +// 1. Normalize each path to slash-separated form and sort files by path. +// 2. For each file, feed path + "\x00" + sha256(content) + "\n" into a +// running SHA-256. +// 3. Return "sha256:" + hex(aggregate). +// +// The algorithm is frozen: lock files in the wild pin its output, so any +// change would report every installed skill as drifted. Golden vectors in +// the package tests guard against accidental changes. +// +// Known limitations (accepted, like go.sum's): the digest covers file +// content only — file modes (e.g. an exec-bit flip) are invisible to it, +// and [ContentDigestFromDir] skips symlinks and other non-regular files +// entirely. Trust in the file set's provenance is the Sigstore +// verification layer's job, not this integrity pin's. +func ContentDigest(files []ContentFile) (string, error) { + if len(files) == 0 { + return "", fmt.Errorf("content digest requires at least one file") + } + + normalized := make([]ContentFile, 0, len(files)) + for _, f := range files { + path := filepath.ToSlash(f.Path) + for strings.HasPrefix(path, "./") { + path = strings.TrimPrefix(path, "./") + } + if path == "" || path == ".." || strings.HasPrefix(path, "../") || strings.Contains(path, "/../") || + strings.HasSuffix(path, "/..") || strings.HasPrefix(path, "/") || hasControlChar(path) { + return "", fmt.Errorf("invalid content file path %q", f.Path) + } + normalized = append(normalized, ContentFile{Path: path, Content: f.Content}) + } + slices.SortFunc(normalized, func(a, b ContentFile) int { return strings.Compare(a.Path, b.Path) }) + + h := sha256.New() + for _, f := range normalized { + fileHash := sha256.Sum256(f.Content) + // Writes to a hash.Hash never fail. + _, _ = io.WriteString(h, f.Path) + _, _ = h.Write([]byte{0}) + _, _ = h.Write(fileHash[:]) + _, _ = h.Write([]byte{'\n'}) + } + return ContentDigestPrefix + hex.EncodeToString(h.Sum(nil)), nil +} + +// hasControlChar reports whether s contains a NUL, newline, or other C0/DEL +// control byte. The serialization format used by [ContentDigest] delimits +// fields with NUL and newline bytes; without this check, a Path containing +// those bytes could reproduce the exact byte stream of a different, +// unrelated multi-file tree and collide with its digest. +func hasControlChar(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < 0x20 || s[i] == 0x7f { + return true + } + } + return false +} + +// ContentDigestFromDir walks skillDir and computes the content digest from +// the on-disk file set. The walk is confined to skillDir via [os.Root], so +// symlinks cannot escape it. +func ContentDigestFromDir(skillDir string) (string, error) { + root, err := os.OpenRoot(skillDir) + if err != nil { + return "", fmt.Errorf("opening skill directory: %w", err) + } + defer func() { _ = root.Close() }() + + var files []ContentFile + err = fs.WalkDir(root.FS(), ".", func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() || !d.Type().IsRegular() { + return nil + } + data, err := fs.ReadFile(root.FS(), path) + if err != nil { + return fmt.Errorf("reading %q: %w", path, err) + } + files = append(files, ContentFile{Path: path, Content: data}) + return nil + }) + if err != nil { + return "", err + } + return ContentDigest(files) +} diff --git a/pkg/skills/lockfile/contentdigest_test.go b/pkg/skills/lockfile/contentdigest_test.go new file mode 100644 index 0000000000..a8a4d36861 --- /dev/null +++ b/pkg/skills/lockfile/contentdigest_test.go @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package lockfile + +import ( + "crypto/sha256" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Golden vectors freeze the contentDigest algorithm. If these ever need to +// change, every lock file in the wild reports drift — treat a change here as +// a breaking change to the lock file format, not a routine test update. +func TestContentDigestGoldenVectors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + files []ContentFile + want string + }{ + { + name: "single file", + files: []ContentFile{ + {Path: "a.txt", Content: []byte("hello world")}, + }, + want: "sha256:79d1b89c9733bc5a3c51bd715612a85ab13add6933cb9aa7cc1ae44fd5d181a3", + }, + { + name: "multiple files sorted by path", + files: []ContentFile{ + {Path: "SKILL.md", Content: []byte("# Skill")}, + {Path: "refs/guide.md", Content: []byte("guide")}, + }, + want: "sha256:9b46342aaebede490f12bf67a5ecb4be507c7684ee5e11a0511b54c701f464bd", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := ContentDigest(tt.files) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestContentDigestDeterministicAndOrderIndependent(t *testing.T) { + t.Parallel() + + a := []ContentFile{ + {Path: "SKILL.md", Content: []byte("# Skill")}, + {Path: "refs/guide.md", Content: []byte("guide")}, + } + // Same files, different input order: the digest must not depend on + // caller-supplied ordering. + b := []ContentFile{ + {Path: "refs/guide.md", Content: []byte("guide")}, + {Path: "SKILL.md", Content: []byte("# Skill")}, + } + + d1, err := ContentDigest(a) + require.NoError(t, err) + d2, err := ContentDigest(b) + require.NoError(t, err) + assert.Equal(t, d1, d2) +} + +func TestContentDigestDiffersOnContentChange(t *testing.T) { + t.Parallel() + + d1, err := ContentDigest([]ContentFile{{Path: "a.txt", Content: []byte("v1")}}) + require.NoError(t, err) + d2, err := ContentDigest([]ContentFile{{Path: "a.txt", Content: []byte("v2")}}) + require.NoError(t, err) + assert.NotEqual(t, d1, d2) +} + +func TestContentDigestRejectsEmptyFileSet(t *testing.T) { + t.Parallel() + _, err := ContentDigest(nil) + require.Error(t, err) +} + +func TestContentDigestRejectsTraversalPaths(t *testing.T) { + t.Parallel() + + tests := []string{"..", "../escape.txt", "a/../../escape.txt", "/abs/path.txt"} + for _, p := range tests { + t.Run(p, func(t *testing.T) { + t.Parallel() + _, err := ContentDigest([]ContentFile{{Path: p, Content: []byte("x")}}) + require.Error(t, err) + }) + } +} + +func TestContentDigestNormalizesDotSlashPrefix(t *testing.T) { + t.Parallel() + + withPrefix, err := ContentDigest([]ContentFile{{Path: "./a.txt", Content: []byte("hello")}}) + require.NoError(t, err) + withoutPrefix, err := ContentDigest([]ContentFile{{Path: "a.txt", Content: []byte("hello")}}) + require.NoError(t, err) + assert.Equal(t, withoutPrefix, withPrefix) +} + +func TestContentDigestRejectsControlCharsInPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + }{ + {"embedded NUL", "a\x00b.txt"}, + {"embedded newline", "a\nb.txt"}, + {"embedded DEL", "a\x7fb.txt"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := ContentDigest([]ContentFile{{Path: tt.path, Content: []byte("x")}}) + require.Error(t, err) + }) + } +} + +// TestContentDigestRejectsForgedPathCollision demonstrates the attack the +// control-char rejection closes: the serialization format delimits fields +// with NUL and newline bytes, so without rejecting those bytes in Path, a +// single crafted entry could reproduce the exact byte stream of two +// legitimate, unrelated entries and collide with their digest. +func TestContentDigestRejectsForgedPathCollision(t *testing.T) { + t.Parallel() + + legit := []ContentFile{ + {Path: "x", Content: []byte("hello world")}, + {Path: "y", Content: []byte("second file content")}, + } + _, err := ContentDigest(legit) + require.NoError(t, err) + + xHash := sha256.Sum256([]byte("hello world")) + forgedPath := "x\x00" + string(xHash[:]) + "\ny" + _, err = ContentDigest([]ContentFile{{Path: forgedPath, Content: []byte("second file content")}}) + require.Error(t, err, "a path forging another entry's serialized bytes must be rejected, not hashed to a collision") +} + +func TestContentDigestNormalizesRepeatedDotSlashPrefix(t *testing.T) { + t.Parallel() + + repeated, err := ContentDigest([]ContentFile{{Path: "././a.txt", Content: []byte("hello")}}) + require.NoError(t, err) + single, err := ContentDigest([]ContentFile{{Path: "a.txt", Content: []byte("hello")}}) + require.NoError(t, err) + assert.Equal(t, single, repeated) +} + +func TestContentDigestFromDir(t *testing.T) { + t.Parallel() + dir, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# Skill"), 0o644)) + require.NoError(t, os.Mkdir(filepath.Join(dir, "refs"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "refs", "guide.md"), []byte("guide"), 0o644)) + + got, err := ContentDigestFromDir(dir) + require.NoError(t, err) + + want, err := ContentDigest([]ContentFile{ + {Path: "SKILL.md", Content: []byte("# Skill")}, + {Path: "refs/guide.md", Content: []byte("guide")}, + }) + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestContentDigestFromDirRejectsMissingDir(t *testing.T) { + t.Parallel() + _, err := ContentDigestFromDir(filepath.Join(t.TempDir(), "does-not-exist")) + require.Error(t, err) +} diff --git a/pkg/skills/lockfile/lockfile.go b/pkg/skills/lockfile/lockfile.go new file mode 100644 index 0000000000..6709491b00 --- /dev/null +++ b/pkg/skills/lockfile/lockfile.go @@ -0,0 +1,332 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package lockfile manages the project-level skills lock file +// (toolhive.lock.yaml). The lock file pins the exact name, version, source, +// and digests of every project-scoped skill install so a team can restore +// ("thv skill sync") or refresh ("thv skill upgrade") the pinned state on any +// machine. See RFC THV-0080. +// +// All filesystem access goes through [Root], a capability type that can only +// be constructed from a validated project root. Root confines every read and +// write to that directory using [os.Root] (OS-enforced containment, not just +// string validation), so no function in this package can be made to open a +// path outside the project root regardless of what name is requested. +package lockfile + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/stacklok/toolhive/pkg/fileutils" + "github.com/stacklok/toolhive/pkg/skills" +) + +// FileName is the name of the project-level skills lock file, written to the +// project root alongside .git. +const FileName = "toolhive.lock.yaml" + +// CurrentVersion is the schema version written to new lock files. Loading a +// lock file with a different version is a hard error, never a silent partial +// parse. +const CurrentVersion = 1 + +// Entry represents a single pinned skill installation in the lock file. +type Entry struct { + // Name is the skill's unique name. + Name string `yaml:"name"` + // Version is the skill's declared version, if any (from SKILL.md frontmatter). + Version string `yaml:"version,omitempty"` + // Source is exactly what the user (or the registry resolver) originally + // requested — a plain registry name, an OCI reference, or a git:// + // reference. It is never rewritten; upgrade re-resolves this value to + // check for newer content. + Source string `yaml:"source"` + // ResolvedReference is the concrete OCI reference or git:// URL that + // Source resolved to at install time. + ResolvedReference string `yaml:"resolvedReference,omitempty"` + // Digest pins the exact content installed: an OCI "sha256:..." manifest + // digest or a full git commit hash. + Digest string `yaml:"digest"` + // ContentDigest is a deterministic SHA-256 dirhash of the materialized + // skill file set, used for on-disk integrity verification. + ContentDigest string `yaml:"contentDigest,omitempty"` + // RequiredBy lists parent skill names for transitively materialized + // dependencies (skills declared via toolhive.requires). + RequiredBy []string `yaml:"requiredBy,omitempty"` + // Explicit is true when the user directly installed this skill; explicit + // entries are exempt from cascade removal when RequiredBy becomes empty. + Explicit bool `yaml:"explicit,omitempty"` + // Extra round-trips fields this binary does not know about (e.g. the + // Sigstore provenance fields a future schema adds under version 1), so a + // Load→modify→Save cycle by an older binary preserves rather than strips + // them. It applies per entry: an entry this binary rewrites (reinstall, + // upgrade) is built fresh, so its unknown fields — which described the + // previous install — are intentionally dropped along with it. + Extra map[string]any `yaml:",inline"` +} + +// Lockfile is the parsed contents of a project's toolhive.lock.yaml. +type Lockfile struct { + // Version is the lock file schema version. + Version int `yaml:"version"` + // Skills is the set of pinned skill installations, sorted by name for + // stable diffs. + Skills []Entry `yaml:"skills,omitempty"` + // Extra round-trips unknown top-level fields, mirroring Entry.Extra. + Extra map[string]any `yaml:",inline"` +} + +// Root is a validated project root directory. It is the only way to address +// a lock file on disk: constructing one runs full project-root validation +// (absolute, NUL-free, no traversal segments, symlink-canonical, git-rooted). +// Every read and write additionally goes through a freshly opened [os.Root] +// scoped to the validated directory, so access is confined at the OS level. +type Root struct { + dir string +} + +// OpenRoot validates projectRoot and returns a Root for it. The zero Root is +// unusable; all lock file operations require a Root produced here. +func OpenRoot(projectRoot string) (Root, error) { + dir, err := skills.ValidateProjectRoot(projectRoot) + if err != nil { + return Root{}, err + } + return Root{dir: dir}, nil +} + +// Dir returns the validated project root directory. +func (r Root) Dir() string { + return r.dir +} + +// Path returns the absolute path of the lock file inside the project root, +// for display purposes (e.g. error messages, CLI output). It is not used by +// this package to open the file; use [Load] and [Save] for that. +func (r Root) Path() (string, error) { + if r.dir == "" { + return "", errors.New("lockfile: uninitialized Root, use OpenRoot") + } + return filepath.Join(r.dir, FileName), nil +} + +// osRoot opens an OS-level containment handle for r. Every name passed to +// its methods is resolved beneath r.dir; a name that would escape it (via +// "..", an absolute path, or a symlink) is rejected by the OS, not by string +// inspection. +func (r Root) osRoot() (*os.Root, error) { + if r.dir == "" { + return nil, errors.New("lockfile: uninitialized Root, use OpenRoot") + } + return os.OpenRoot(r.dir) +} + +// Load reads and parses the lock file for root. A missing lock file is not +// an error — it returns an empty Lockfile ready to be populated. A lock file +// with an unsupported schema version is a hard error. +func Load(root Root) (*Lockfile, error) { + osRoot, err := root.osRoot() + if err != nil { + return nil, err + } + defer func() { _ = osRoot.Close() }() + + data, err := osRoot.ReadFile(FileName) + if errors.Is(err, fs.ErrNotExist) { + return &Lockfile{Version: CurrentVersion}, nil + } + if err != nil { + return nil, fmt.Errorf("reading lock file: %w", err) + } + + var lf Lockfile + if err := yaml.Unmarshal(data, &lf); err != nil { + return nil, fmt.Errorf("parsing lock file: %w", err) + } + if lf.Version == 0 { + // Tolerate a hand-written lock file that omits the version key. + lf.Version = CurrentVersion + } + sortEntries(lf.Skills) + if err := validateLockfile(&lf); err != nil { + return nil, err + } + return &lf, nil +} + +// Get returns the entry for name, if present. +func (l *Lockfile) Get(name string) (Entry, bool) { + for _, e := range l.Skills { + if e.Name == name { + return e, true + } + } + return Entry{}, false +} + +// Upsert inserts or replaces the entry with a matching name, keeping the +// slice sorted by name for stable diffs. +func (l *Lockfile) Upsert(entry Entry) { + for i := range l.Skills { + if l.Skills[i].Name == entry.Name { + l.Skills[i] = entry + return + } + } + l.Skills = append(l.Skills, entry) + sortEntries(l.Skills) +} + +// Remove deletes the entry with the given name, if present. Reports whether +// an entry was removed. +func (l *Lockfile) Remove(name string) bool { + for i, e := range l.Skills { + if e.Name == name { + l.Skills = slices.Delete(l.Skills, i, i+1) + return true + } + } + return false +} + +// RemoveParentFromRequiredBy removes parent from every entry's RequiredBy +// list and returns the names of entries that lost their last parent and are +// not explicit — the candidates for cascade removal. +func (l *Lockfile) RemoveParentFromRequiredBy(parent string) []string { + var cascadeCandidates []string + for i := range l.Skills { + entry := &l.Skills[i] + if !slices.Contains(entry.RequiredBy, parent) { + continue + } + entry.RequiredBy = slices.DeleteFunc(entry.RequiredBy, func(s string) bool { return s == parent }) + if len(entry.RequiredBy) == 0 { + entry.RequiredBy = nil + if !entry.Explicit { + cascadeCandidates = append(cascadeCandidates, entry.Name) + } + } + } + return cascadeCandidates +} + +// tmpFileName is the temporary file Save writes before renaming it into +// tmpFileName returns a per-call temporary file name for Save's atomic +// write. The random suffix means two concurrent direct Save calls (which, +// unlike Update/UpsertEntry/RemoveEntry, hold no file lock) cannot write +// to, rename, or delete each other's temp file — last rename still wins, +// but neither can promote or destroy a half-written file. +func tmpFileName() (string, error) { + var buf [8]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", fmt.Errorf("generating temp file suffix: %w", err) + } + return ".toolhive.lock." + hex.EncodeToString(buf[:]) + ".tmp", nil +} + +// Save writes the lock file into root atomically (temp file + rename), after +// validating it with the same rules [Load] enforces on read. This prevents a +// caller bug from writing a lock file that every subsequent Load/Update call +// would then hard-fail on, with no recovery path through this package. +// Callers that need read-modify-write atomicity across processes must use +// [UpsertEntry], [RemoveEntry], or [Update] instead of Load+Save — Save alone +// is write-atomic but does nothing to serialize concurrent read-modify-write +// cycles. +func (l *Lockfile) Save(root Root) error { + osRoot, err := root.osRoot() + if err != nil { + return err + } + defer func() { _ = osRoot.Close() }() + + if l.Version == 0 { + l.Version = CurrentVersion + } + sortEntries(l.Skills) + + if err := validateLockfile(l); err != nil { + return fmt.Errorf("refusing to save invalid lock file: %w", err) + } + + data, err := yaml.Marshal(l) + if err != nil { + return fmt.Errorf("marshaling lock file: %w", err) + } + + tmpName, err := tmpFileName() + if err != nil { + return err + } + // The lock file is committed to git and not sensitive; 0o644 matches any + // other source file. + if err := osRoot.WriteFile(tmpName, data, 0o644); err != nil { + return fmt.Errorf("writing lock file: %w", err) + } + if err := osRoot.Rename(tmpName, FileName); err != nil { + _ = osRoot.Remove(tmpName) + return fmt.Errorf("saving lock file: %w", err) + } + return nil +} + +// UpsertEntry loads the lock file, upserts entry, and saves it back, all +// under a single file lock so concurrent installs cannot race on +// read-modify-write. +func UpsertEntry(root Root, entry Entry) error { + return Update(root, func(lf *Lockfile) error { + lf.Upsert(entry) + return nil + }) +} + +// RemoveEntry loads the lock file, removes the named entry if present, and +// saves it back, all under a single file lock. Removing an entry that does +// not exist is a no-op, not an error. +func RemoveEntry(root Root, name string) error { + return Update(root, func(lf *Lockfile) error { + if !lf.Remove(name) { + return errSkipSave + } + return nil + }) +} + +// Update loads the lock file, applies fn, and saves the result, all under a +// single file lock. If fn returns an error the lock file is left unchanged. +func Update(root Root, fn func(*Lockfile) error) error { + path, err := root.Path() + if err != nil { + return err + } + return fileutils.WithFileLock(path, func() error { + lf, err := Load(root) + if err != nil { + return err + } + if err := fn(lf); err != nil { + if errors.Is(err, errSkipSave) { + return nil + } + return err + } + return lf.Save(root) + }) +} + +// errSkipSave signals from an Update callback that nothing changed and the +// save should be skipped without reporting an error. +var errSkipSave = errors.New("lockfile: no changes to save") + +func sortEntries(entries []Entry) { + slices.SortFunc(entries, func(a, b Entry) int { return strings.Compare(a.Name, b.Name) }) +} diff --git a/pkg/skills/lockfile/lockfile_test.go b/pkg/skills/lockfile/lockfile_test.go new file mode 100644 index 0000000000..7d270b6ab7 --- /dev/null +++ b/pkg/skills/lockfile/lockfile_test.go @@ -0,0 +1,387 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package lockfile + +import ( + "errors" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// hexDigest returns a valid lowercase hex string of exactly n characters. A +// distinct seed produces a distinct string, so tests needing multiple unique +// fixture digests can pass different seeds. +func hexDigest(n int, seed byte) string { + const alphabet = "0123456789abcdef" + b := make([]byte, n) + for i := range b { + b[i] = alphabet[(i+int(seed))%len(alphabet)] + } + return string(b) +} + +// ociDigest returns a valid fixture value for an Entry.Digest/ContentDigest +// field ("sha256:" + 64 hex characters). +func ociDigest(seed byte) string { + return ContentDigestPrefix + hexDigest(sha256HexLength, seed) +} + +// testRoot creates a resolved temp dir with a .git directory (satisfying +// skills.ValidateProjectRoot) and returns its opened Root. +func testRoot(t *testing.T) Root { + t.Helper() + dir, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + require.NoError(t, os.Mkdir(filepath.Join(dir, ".git"), 0o755)) + root, err := OpenRoot(dir) + require.NoError(t, err) + return root +} + +func TestOpenRootRejectsInvalidProjectRoot(t *testing.T) { + t.Parallel() + + _, err := OpenRoot(filepath.Join(t.TempDir(), "missing")) + require.Error(t, err) +} + +func TestZeroRootPathFails(t *testing.T) { + t.Parallel() + + var r Root + _, err := r.Path() + require.Error(t, err) + assert.Contains(t, err.Error(), "uninitialized Root") +} + +func TestLoadMissingFileReturnsEmptyLockfile(t *testing.T) { + t.Parallel() + root := testRoot(t) + + lf, err := Load(root) + require.NoError(t, err) + assert.Equal(t, CurrentVersion, lf.Version) + assert.Empty(t, lf.Skills) +} + +func TestLoadRejectsMalformedYAML(t *testing.T) { + t.Parallel() + root := testRoot(t) + path, err := root.Path() + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, []byte("not: [valid: yaml"), 0o644)) + + _, err = Load(root) + require.Error(t, err) + assert.Contains(t, err.Error(), "parsing lock file") +} + +func TestLoadRejectsUnsupportedVersion(t *testing.T) { + t.Parallel() + root := testRoot(t) + path, err := root.Path() + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, []byte("version: 99\nskills: []\n"), 0o644)) + + _, err = Load(root) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedVersion) +} + +func TestSaveAndLoadRoundTrip(t *testing.T) { + t.Parallel() + root := testRoot(t) + + lf := &Lockfile{Version: CurrentVersion} + entry := Entry{ + Name: "code-review", + Version: "1.0.0", + Source: "code-review", + ResolvedReference: "ghcr.io/org/code-review:1.0.0", + Digest: ociDigest(1), + ContentDigest: ociDigest(2), + Explicit: true, + } + lf.Upsert(entry) + require.NoError(t, lf.Save(root)) + + loaded, err := Load(root) + require.NoError(t, err) + require.Len(t, loaded.Skills, 1) + assert.Equal(t, entry, loaded.Skills[0]) +} + +// TestUnknownFieldsSurviveLoadModifySave freezes the version-skew contract: +// a lock file written by a newer binary (e.g. with Sigstore provenance +// fields added under schema version 1) must round-trip through this +// binary's Load -> modify -> Save without the unknown fields being +// stripped — otherwise an older thv silently removes a teammate's signing +// metadata on its next write. +func TestUnknownFieldsSurviveLoadModifySave(t *testing.T) { + t.Parallel() + root := testRoot(t) + path, err := root.Path() + require.NoError(t, err) + + futureLock := "" + + "version: 1\n" + + "futureTopLevelField: keep-me\n" + + "skills:\n" + + " - name: signed-skill\n" + + " source: ghcr.io/org/signed-skill:1.0.0\n" + + " digest: " + ociDigest(1) + "\n" + + " provenance:\n" + + " signerIdentity: dev@example.com\n" + + " certIssuer: https://accounts.example.com\n" + require.NoError(t, os.WriteFile(path, []byte(futureLock), 0o644)) + + // Load, touch an unrelated entry, save — the classic older-binary write. + require.NoError(t, UpsertEntry(root, Entry{ + Name: "other-skill", + Source: "other-skill", + Digest: ociDigest(2), + })) + + data, err := os.ReadFile(path) //nolint:gosec // fixed test path + require.NoError(t, err) + saved := string(data) + assert.Contains(t, saved, "signerIdentity: dev@example.com", "unknown entry fields must survive a Load->Save cycle") + assert.Contains(t, saved, "futureTopLevelField: keep-me", "unknown top-level fields must survive a Load->Save cycle") + + loaded, err := Load(root) + require.NoError(t, err) + signed, ok := loaded.Get("signed-skill") + require.True(t, ok) + assert.Contains(t, signed.Extra, "provenance") +} + +func TestSaveRejectsInvalidLockfile(t *testing.T) { + t.Parallel() + root := testRoot(t) + + lf := &Lockfile{Version: CurrentVersion} + lf.Upsert(Entry{Name: "code-review", Source: "code-review"}) // missing required Digest + + err := lf.Save(root) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid lock file") + + _, statErr := os.Stat(filepath.Join(root.Dir(), FileName)) + require.True(t, os.IsNotExist(statErr), "Save must not write an invalid lock file to disk") +} + +func TestUpdateRejectsInvalidLockfile(t *testing.T) { + t.Parallel() + root := testRoot(t) + + err := Update(root, func(lf *Lockfile) error { + lf.Upsert(Entry{Name: "code-review", Source: "code-review"}) // missing required Digest + return nil + }) + require.Error(t, err) + + // A caller bug that upserts an invalid entry must not leave every + // subsequent Load/Update permanently broken. + loaded, loadErr := Load(root) + require.NoError(t, loadErr) + assert.Empty(t, loaded.Skills) +} + +func TestLockfileGetUpsertRemove(t *testing.T) { + t.Parallel() + + lf := &Lockfile{Version: CurrentVersion} + + _, ok := lf.Get("missing") + assert.False(t, ok) + + a := Entry{Name: "b-skill", Source: "b-skill", Digest: ociDigest(3)} + c := Entry{Name: "a-skill", Source: "a-skill", Digest: ociDigest(4)} + lf.Upsert(a) + lf.Upsert(c) + + // Entries are kept sorted by name. + require.Len(t, lf.Skills, 2) + assert.Equal(t, "a-skill", lf.Skills[0].Name) + assert.Equal(t, "b-skill", lf.Skills[1].Name) + + got, ok := lf.Get("b-skill") + require.True(t, ok) + assert.Equal(t, a.Digest, got.Digest) + + // Upsert replaces an existing entry rather than duplicating it. + updated := Entry{Name: "b-skill", Source: "b-skill", Digest: ociDigest(5)} + lf.Upsert(updated) + require.Len(t, lf.Skills, 2) + got, ok = lf.Get("b-skill") + require.True(t, ok) + assert.Equal(t, updated.Digest, got.Digest) + + removed := lf.Remove("b-skill") + assert.True(t, removed) + require.Len(t, lf.Skills, 1) + + removedAgain := lf.Remove("b-skill") + assert.False(t, removedAgain) +} + +func TestRemoveParentFromRequiredBy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + entries []Entry + parent string + wantCascadeCandidates []string + wantRemainingParents map[string][]string + }{ + { + name: "last parent removed and not explicit becomes cascade candidate", + entries: []Entry{ + {Name: "dep", RequiredBy: []string{"parent"}}, + }, + parent: "parent", + wantCascadeCandidates: []string{"dep"}, + wantRemainingParents: map[string][]string{"dep": nil}, + }, + { + name: "explicit entry is never a cascade candidate", + entries: []Entry{ + {Name: "dep", RequiredBy: []string{"parent"}, Explicit: true}, + }, + parent: "parent", + wantCascadeCandidates: nil, + wantRemainingParents: map[string][]string{"dep": nil}, + }, + { + name: "dep with another parent survives", + entries: []Entry{ + {Name: "dep", RequiredBy: []string{"parent", "other-parent"}}, + }, + parent: "parent", + wantCascadeCandidates: nil, + wantRemainingParents: map[string][]string{"dep": {"other-parent"}}, + }, + { + name: "entry without the parent is untouched", + entries: []Entry{ + {Name: "unrelated", RequiredBy: []string{"someone-else"}}, + }, + parent: "parent", + wantCascadeCandidates: nil, + wantRemainingParents: map[string][]string{"unrelated": {"someone-else"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + lf := &Lockfile{Version: CurrentVersion, Skills: tt.entries} + got := lf.RemoveParentFromRequiredBy(tt.parent) + assert.Equal(t, tt.wantCascadeCandidates, got) + for name, want := range tt.wantRemainingParents { + entry, ok := lf.Get(name) + require.True(t, ok) + assert.Equal(t, want, entry.RequiredBy) + } + }) + } +} + +func TestUpsertEntryAndRemoveEntry(t *testing.T) { + t.Parallel() + root := testRoot(t) + + entry := Entry{Name: "my-skill", Source: "my-skill", Digest: ociDigest(3)} + require.NoError(t, UpsertEntry(root, entry)) + + lf, err := Load(root) + require.NoError(t, err) + require.Len(t, lf.Skills, 1) + assert.Equal(t, "my-skill", lf.Skills[0].Name) + + // Upserting a second entry preserves the first. + other := Entry{Name: "other-skill", Source: "other-skill", Digest: ociDigest(6)} + require.NoError(t, UpsertEntry(root, other)) + lf, err = Load(root) + require.NoError(t, err) + assert.Len(t, lf.Skills, 2) + + require.NoError(t, RemoveEntry(root, "my-skill")) + lf, err = Load(root) + require.NoError(t, err) + require.Len(t, lf.Skills, 1) + assert.Equal(t, "other-skill", lf.Skills[0].Name) + + // Removing a name that isn't present is a no-op, not an error. + require.NoError(t, RemoveEntry(root, "does-not-exist")) +} + +func TestUpdateLeavesLockfileUnchangedOnError(t *testing.T) { + t.Parallel() + root := testRoot(t) + require.NoError(t, UpsertEntry(root, Entry{Name: "my-skill", Source: "my-skill", Digest: ociDigest(3)})) + + wantErr := errors.New("boom") + err := Update(root, func(lf *Lockfile) error { + lf.Upsert(Entry{Name: "should-not-persist", Source: "x", Digest: ociDigest(4)}) + return wantErr + }) + require.ErrorIs(t, err, wantErr) + + lf, err := Load(root) + require.NoError(t, err) + require.Len(t, lf.Skills, 1) + assert.Equal(t, "my-skill", lf.Skills[0].Name) +} + +// TestConcurrentUpsertEntryDoesNotLoseUpdates hammers UpsertEntry from many +// goroutines to confirm the read-modify-write cycle is fully serialized by +// the file lock, not just protected at the Save call. +func TestConcurrentUpsertEntryDoesNotLoseUpdates(t *testing.T) { + t.Parallel() + root := testRoot(t) + + const n = 20 + var wg sync.WaitGroup + errCh := make(chan error, n) + for i := range n { + wg.Add(1) + go func(i int) { + defer wg.Done() + name := skillNameForIndex(i) + errCh <- UpsertEntry(root, Entry{ + Name: name, + Source: name, + Digest: ociDigest(3), + }) + }(i) + } + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("timeout waiting for concurrent upserts to finish") + } + close(errCh) + for err := range errCh { + require.NoError(t, err) + } + + lf, err := Load(root) + require.NoError(t, err) + require.Len(t, lf.Skills, n, "a concurrent upsert lost an update") +} + +func skillNameForIndex(i int) string { + return "skill-" + string(rune('a'+i/26)) + string(rune('a'+i%26)) +} diff --git a/pkg/skills/lockfile/validation.go b/pkg/skills/lockfile/validation.go new file mode 100644 index 0000000000..3b4429e936 --- /dev/null +++ b/pkg/skills/lockfile/validation.go @@ -0,0 +1,220 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package lockfile + +import ( + "errors" + "fmt" + "strings" + "unicode" + + nameref "github.com/google/go-containerregistry/pkg/name" + + "github.com/stacklok/toolhive/pkg/skills" + "github.com/stacklok/toolhive/pkg/skills/gitresolver" +) + +// ErrUnsupportedVersion indicates the lock file schema version is not +// supported by this build. +var ErrUnsupportedVersion = errors.New("unsupported lock file version") + +const ( + // ContentDigestPrefix is the prefix for contentDigest values in the lock file. + ContentDigestPrefix = "sha256:" + + sha256HexLength = 64 + sha1HexLength = 40 +) + +// validateLockfile checks the schema version, every entry, and +// cross-references requiredBy links against the set of known entries. +func validateLockfile(lf *Lockfile) error { + if lf.Version != CurrentVersion { + return fmt.Errorf("%w: version %d (upgrade thv to read this lock file)", ErrUnsupportedVersion, lf.Version) + } + + names := make(map[string]struct{}, len(lf.Skills)) + for _, entry := range lf.Skills { + if err := validateEntry(entry); err != nil { + return err + } + if _, dup := names[entry.Name]; dup { + return fmt.Errorf("duplicate entry %q", entry.Name) + } + names[entry.Name] = struct{}{} + } + + for _, entry := range lf.Skills { + for _, parent := range entry.RequiredBy { + if parent == entry.Name { + return fmt.Errorf("entry %q: requiredBy references itself", entry.Name) + } + if _, ok := names[parent]; !ok { + return fmt.Errorf("entry %q: requiredBy references unknown parent %q", entry.Name, parent) + } + } + } + + if cycle := findRequiredByCycle(lf.Skills); len(cycle) > 0 { + return fmt.Errorf("requiredBy cycle: %s", strings.Join(cycle, " -> ")) + } + return nil +} + +// findRequiredByCycle detects a cycle in the requiredBy graph and returns +// one such cycle path, or nil. Normal installs can never produce a cycle +// (the install-time Visited set breaks them), but a hand-edited or badly +// merge-resolved lock file can — and a ring of mutually-required, +// non-explicit entries would then be impossible to ever cascade-remove, so +// it is rejected at validation instead of persisting silently. +func findRequiredByCycle(entries []Entry) []string { + requiredBy := make(map[string][]string, len(entries)) + for _, e := range entries { + requiredBy[e.Name] = e.RequiredBy + } + + const ( + unvisited = 0 + inStack = 1 + done = 2 + ) + state := make(map[string]int, len(entries)) + + var visit func(name string, path []string) []string + visit = func(name string, path []string) []string { + state[name] = inStack + path = append(path, name) + for _, parent := range requiredBy[name] { + switch state[parent] { + case inStack: + // Trim the path to start at the cycle entry point. + for i, n := range path { + if n == parent { + return append(path[i:], parent) + } + } + case unvisited: + if cycle := visit(parent, path); cycle != nil { + return cycle + } + } + } + state[name] = done + return nil + } + + for _, e := range entries { + if state[e.Name] == unvisited { + if cycle := visit(e.Name, nil); cycle != nil { + return cycle + } + } + } + return nil +} + +// maxReferenceLength bounds ResolvedReference: longer than any legitimate +// OCI reference or git URL, short enough to stop megabyte-scale garbage +// from a corrupted or hostile lock file reaching the fetch path. +const maxReferenceLength = 512 + +func validateEntry(entry Entry) error { + if err := skills.ValidateSkillName(entry.Name); err != nil { + return fmt.Errorf("entry name: %w", err) + } + if entry.Source == "" { + return fmt.Errorf("entry %q: source is required", entry.Name) + } + if entry.Digest == "" { + return fmt.Errorf("entry %q: digest is required", entry.Name) + } + if err := validateDigest(entry.Digest); err != nil { + return fmt.Errorf("entry %q: digest: %w", entry.Name, err) + } + if entry.ContentDigest != "" { + if err := validateContentDigest(entry.ContentDigest); err != nil { + return fmt.Errorf("entry %q: contentDigest: %w", entry.Name, err) + } + } + if entry.ResolvedReference != "" { + if err := validateResolvedReference(entry.ResolvedReference); err != nil { + return fmt.Errorf("entry %q: resolvedReference: %w", entry.Name, err) + } + } + return nil +} + +// validateResolvedReference syntactically constrains the resolvedReference +// field. Sync fetches from this value without re-resolving Source, and the +// lock file is hand-editable, so a value that is not a plausible git:// or +// OCI reference must never reach the fetch path. Validation is purely +// syntactic — no network access, no allow-list policy. +func validateResolvedReference(ref string) error { + if len(ref) > maxReferenceLength { + return fmt.Errorf("exceeds %d characters", maxReferenceLength) + } + if strings.TrimSpace(ref) != ref { + return errors.New("has leading or trailing whitespace") + } + for _, r := range ref { + if !unicode.IsGraphic(r) || unicode.IsSpace(r) { + return fmt.Errorf("contains non-graphic or whitespace character %q", r) + } + } + if gitresolver.IsGitReference(ref) { + if _, err := gitresolver.ParseGitReference(ref); err != nil { + return fmt.Errorf("invalid git reference: %w", err) + } + return nil + } + // StrictValidation requires an explicit tag or digest, matching what the + // install path records (qualifiedOCIRef always includes one) — and, + // unlike weak validation, rejects URL-shaped strings such as + // "http://169.254.169.254/…" that must never reach the fetch path. + if _, err := nameref.ParseReference(ref, nameref.StrictValidation); err != nil { + return fmt.Errorf("not a valid git:// or OCI reference: %w", err) + } + return nil +} + +// validateDigest accepts the two pin formats the lock file records: an OCI +// manifest digest ("sha256:" + 64 hex chars) or a full git commit hash +// (40 hex chars for SHA-1 repositories, 64 for SHA-256 repositories). +// Abbreviated digests are rejected: a truncated pin weakens the guarantee +// the lock file exists to provide. +func validateDigest(d string) error { + if hexPart, ok := strings.CutPrefix(d, ContentDigestPrefix); ok { + if err := validateHex(hexPart, sha256HexLength); err != nil { + return fmt.Errorf("OCI digest: %w", err) + } + return nil + } + if len(d) != sha1HexLength && len(d) != sha256HexLength { + return fmt.Errorf("expected %q + 64 hex chars or a full git commit hash, got %q", ContentDigestPrefix, d) + } + if err := validateHex(d, len(d)); err != nil { + return fmt.Errorf("git commit hash: %w", err) + } + return nil +} + +func validateContentDigest(d string) error { + hexPart, ok := strings.CutPrefix(d, ContentDigestPrefix) + if !ok { + return fmt.Errorf("must start with %q", ContentDigestPrefix) + } + return validateHex(hexPart, sha256HexLength) +} + +func validateHex(s string, wantLen int) error { + if len(s) != wantLen { + return fmt.Errorf("expected %d hex characters, got %d", wantLen, len(s)) + } + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return fmt.Errorf("invalid hex character %q", c) + } + } + return nil +} diff --git a/pkg/skills/lockfile/validation_test.go b/pkg/skills/lockfile/validation_test.go new file mode 100644 index 0000000000..548d111bfb --- /dev/null +++ b/pkg/skills/lockfile/validation_test.go @@ -0,0 +1,210 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package lockfile + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + validSHA256Hex = hexDigest(sha256HexLength, 0) + validSHA256Digest = "sha256:" + validSHA256Hex + validGitSHA1 = hexDigest(sha1HexLength, 0) + validContentDigest = ContentDigestPrefix + validSHA256Hex +) + +func TestValidateDigest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + digest string + wantErr string + }{ + {name: "valid OCI digest", digest: validSHA256Digest}, + {name: "valid git SHA-1 commit", digest: validGitSHA1}, + {name: "valid git SHA-256 commit", digest: validSHA256Hex}, + {name: "empty", digest: "", wantErr: "expected"}, + {name: "abbreviated git hash rejected", digest: "0123456", wantErr: "expected"}, + {name: "oci digest wrong length", digest: "sha256:abc", wantErr: "OCI digest"}, + {name: "oci digest bad hex", digest: "sha256:" + strings.Repeat("z", 64), wantErr: "OCI digest"}, + {name: "git hash bad hex", digest: strings.Repeat("z", 40), wantErr: "git commit hash"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateDigest(tt.digest) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestValidateContentDigest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + digest string + wantErr string + }{ + {name: "valid", digest: validContentDigest}, + {name: "missing prefix", digest: validSHA256Hex, wantErr: "must start with"}, + {name: "wrong length", digest: ContentDigestPrefix + "abc", wantErr: "expected 64 hex"}, + {name: "bad hex", digest: ContentDigestPrefix + strings.Repeat("z", 64), wantErr: "invalid hex"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateContentDigest(tt.digest) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestValidateResolvedReference(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ref string + wantErr string + }{ + {name: "valid OCI reference with tag", ref: "ghcr.io/org/skill:1.0.0"}, + {name: "valid OCI reference with digest", ref: "ghcr.io/org/skill@sha256:" + validSHA256Hex}, + {name: "valid git reference", ref: "git://github.com/org/repo@main#skills/my-skill"}, + {name: "too long", ref: "ghcr.io/org/" + strings.Repeat("a", maxReferenceLength), wantErr: "exceeds"}, + {name: "leading whitespace", ref: " ghcr.io/org/skill:1", wantErr: "whitespace"}, + {name: "embedded newline", ref: "ghcr.io/org/\nskill:1", wantErr: "non-graphic"}, + {name: "embedded ANSI escape", ref: "ghcr.io/org/\x1b[31mskill:1", wantErr: "non-graphic"}, + {name: "malformed git reference", ref: "git://", wantErr: "invalid git reference"}, + {name: "not a reference at all", ref: "http://169.254.169.254/latest/meta-data", wantErr: "not a valid"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateResolvedReference(tt.ref) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestValidateLockfile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + lf Lockfile + wantErr string + }{ + { + name: "valid single entry", + lf: Lockfile{Version: CurrentVersion, Skills: []Entry{ + {Name: "my-skill", Source: "my-skill", Digest: validSHA256Digest}, + }}, + }, + { + name: "unsupported version", + lf: Lockfile{Version: 99}, + wantErr: "unsupported lock file version", + }, + { + name: "duplicate entry names", + lf: Lockfile{Version: CurrentVersion, Skills: []Entry{ + {Name: "dup", Source: "a", Digest: validSHA256Digest}, + {Name: "dup", Source: "b", Digest: validSHA256Digest}, + }}, + wantErr: "duplicate entry", + }, + { + name: "requiredBy references unknown parent", + lf: Lockfile{Version: CurrentVersion, Skills: []Entry{ + {Name: "dep", Source: "dep", Digest: validSHA256Digest, RequiredBy: []string{"ghost"}}, + }}, + wantErr: "unknown parent", + }, + { + name: "requiredBy references itself", + lf: Lockfile{Version: CurrentVersion, Skills: []Entry{ + {Name: "dep", Source: "dep", Digest: validSHA256Digest, RequiredBy: []string{"dep"}}, + }}, + wantErr: "references itself", + }, + { + name: "missing source", + lf: Lockfile{Version: CurrentVersion, Skills: []Entry{ + {Name: "my-skill", Digest: validSHA256Digest}, + }}, + wantErr: "source is required", + }, + { + name: "missing digest", + lf: Lockfile{Version: CurrentVersion, Skills: []Entry{ + {Name: "my-skill", Source: "my-skill"}, + }}, + wantErr: "digest is required", + }, + { + name: "invalid skill name", + lf: Lockfile{Version: CurrentVersion, Skills: []Entry{ + {Name: "Not_Valid", Source: "x", Digest: validSHA256Digest}, + }}, + wantErr: "entry name", + }, + { + // A mutual requiredBy ring of non-explicit entries would pass the + // per-edge checks yet be impossible to ever cascade-remove. + name: "requiredBy cycle", + lf: Lockfile{Version: CurrentVersion, Skills: []Entry{ + {Name: "ring-a", Source: "a", Digest: validSHA256Digest, RequiredBy: []string{"ring-b"}}, + {Name: "ring-b", Source: "b", Digest: validSHA256Digest, RequiredBy: []string{"ring-a"}}, + }}, + wantErr: "requiredBy cycle", + }, + { + name: "requiredBy diamond is not a cycle", + lf: Lockfile{Version: CurrentVersion, Skills: []Entry{ + {Name: "shared-dep", Source: "d", Digest: validSHA256Digest, RequiredBy: []string{"parent-a", "parent-b"}}, + {Name: "parent-a", Source: "a", Digest: validSHA256Digest, RequiredBy: []string{"root"}}, + {Name: "parent-b", Source: "b", Digest: validSHA256Digest, RequiredBy: []string{"root"}}, + {Name: "root", Source: "r", Digest: validSHA256Digest, Explicit: true}, + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + lf := tt.lf + err := validateLockfile(&lf) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +}