Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions pkg/skills/lockfile/contentdigest.go
Original file line number Diff line number Diff line change
@@ -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)
}
188 changes: 188 additions & 0 deletions pkg/skills/lockfile/contentdigest_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading