From 1f2e06deb6d8c808e5e7b6d13294f7a970378fda Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Fri, 6 Feb 2026 21:30:33 +0200 Subject: [PATCH] Add OCI skill packager using ocispec types Implement the SkillPackager interface with deterministic OCI artifact creation from skill directories. Uses canonical ocispec types from github.com/opencontainers/image-spec for OCI 1.1 compliance. Reads SKILL.md YAML frontmatter for metadata, creates per-platform OCI configs (ocispec.Image) with skill metadata in labels, manifests (ocispec.Manifest) with annotations, and multi-platform image indexes (ocispec.Index). Uses digest.FromBytes for diff IDs and specs.Versioned for schema version. Security: rejects symlinks (files and directories), hardlinks, device entries, and path traversal in filesystem reads. Nil store panics at construction. Frontmatter capped at 64KB. New dependencies: github.com/opencontainers/image-spec, gopkg.in/yaml.v3 Resolves: #16 Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 2 +- oci/skills/packager.go | 573 +++++++++++++++++++++++++++++++++++ oci/skills/packager_test.go | 588 ++++++++++++++++++++++++++++++++++++ 3 files changed, 1162 insertions(+), 1 deletion(-) create mode 100644 oci/skills/packager.go create mode 100644 oci/skills/packager_test.go diff --git a/go.mod b/go.mod index a15b725..c0cefb5 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/stretchr/testify v1.11.1 go.uber.org/mock v0.6.0 golang.org/x/net v0.49.0 + gopkg.in/yaml.v3 v3.0.1 oras.land/oras-go/v2 v2.6.0 ) @@ -25,5 +26,4 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect google.golang.org/protobuf v1.36.10 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/oci/skills/packager.go b/oci/skills/packager.go new file mode 100644 index 0000000..58e2bdd --- /dev/null +++ b/oci/skills/packager.go @@ -0,0 +1,573 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skills + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + + "github.com/opencontainers/go-digest" + specs "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "gopkg.in/yaml.v3" +) + +// Packager creates reproducible OCI artifacts from skill directories. +type Packager struct { + store *Store +} + +// manifestInfo holds a manifest digest along with its size. +type manifestInfo struct { + digest digest.Digest + size int64 +} + +// frontmatter represents the YAML frontmatter in a SKILL.md file. +type frontmatter struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Version string `yaml:"version,omitempty"` + AllowedTools stringOrSlice `yaml:"allowed-tools,omitempty"` + License string `yaml:"license,omitempty"` + Compatibility string `yaml:"compatibility,omitempty"` + Metadata map[string]string `yaml:"metadata,omitempty"` +} + +// stringOrSlice is a YAML type that can unmarshal from a string or a sequence. +type stringOrSlice []string + +// UnmarshalYAML implements yaml.Unmarshaler. +func (s *stringOrSlice) UnmarshalYAML(value *yaml.Node) error { + switch value.Kind { + case yaml.ScalarNode: + str := value.Value + if str == "" { + *s = nil + return nil + } + var parts []string + if strings.Contains(str, ",") { + parts = strings.Split(str, ",") + } else { + parts = strings.Fields(str) + } + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + *s = result + return nil + case yaml.SequenceNode: + var arr []string + if err := value.Decode(&arr); err != nil { + return fmt.Errorf("decoding allowed-tools array: %w", err) + } + *s = arr + return nil + case yaml.DocumentNode, yaml.MappingNode, yaml.AliasNode: + return fmt.Errorf("allowed-tools: expected string or array, got unsupported YAML node type") + } + return fmt.Errorf("allowed-tools: unexpected YAML node kind %d", value.Kind) +} + +// skillDirContent holds the raw files and parsed metadata from a skill directory. +type skillDirContent struct { + skillMD []byte + // files maps relative paths (e.g., "scripts/run.sh") to content. + files map[string][]byte + // fm is the parsed frontmatter. + fm *frontmatter +} + +// maxFrontmatterSize limits frontmatter to prevent YAML parsing attacks. +const maxFrontmatterSize = 64 * 1024 + +// Compile-time assertion that Packager implements SkillPackager. +var _ SkillPackager = (*Packager)(nil) + +// NewPackager creates a new packager with the given store. +// Panics if store is nil. +func NewPackager(store *Store) *Packager { + if store == nil { + panic("skills: NewPackager called with nil store") + } + return &Packager{store: store} +} + +// DefaultPackageOptions returns default packaging options. +// Respects SOURCE_DATE_EPOCH for reproducible builds. +func DefaultPackageOptions() PackageOptions { + epoch := time.Unix(0, 0).UTC() + + if sde := os.Getenv("SOURCE_DATE_EPOCH"); sde != "" { + if ts, err := strconv.ParseInt(sde, 10, 64); err == nil { + epoch = time.Unix(ts, 0).UTC() + } + } + + return PackageOptions{ + Epoch: epoch, + Platforms: DefaultPlatforms, + } +} + +// Package packages a skill directory into an OCI artifact in the local store. +func (p *Packager) Package(ctx context.Context, skillDir string, opts PackageOptions) (*PackageResult, error) { + if len(opts.Platforms) == 0 { + opts.Platforms = DefaultPlatforms + } + + // Read and validate skill directory + content, err := readSkillDirectory(skillDir) + if err != nil { + return nil, fmt.Errorf("reading skill directory: %w", err) + } + + // Create content layer (tar.gz) — shared across all platforms + layerBytes, uncompressedTar, err := createContentLayer(content, opts) + if err != nil { + return nil, fmt.Errorf("creating content layer: %w", err) + } + + layerDigest, err := p.store.PutBlob(ctx, layerBytes) + if err != nil { + return nil, fmt.Errorf("storing layer blob: %w", err) + } + + // Create per-platform config and manifest + platformManifests := make(map[string]manifestInfo, len(opts.Platforms)) + var primaryManifestDigest, primaryConfigDigest digest.Digest + var skillConfig *SkillConfig + var manifestAnnotations map[string]string + + for i, platform := range opts.Platforms { + platformStr := platform.String() + + ociConfig, cfg := createOCIConfig(content, uncompressedTar, platform, opts) + configBytes, err := json.Marshal(ociConfig) + if err != nil { + return nil, fmt.Errorf("marshaling config for platform %s: %w", platformStr, err) + } + + configDigest, err := p.store.PutBlob(ctx, configBytes) + if err != nil { + return nil, fmt.Errorf("storing config blob for platform %s: %w", platformStr, err) + } + + manifest := createManifest(configBytes, configDigest, layerBytes, layerDigest, content.fm, opts) + manifestBytes, err := json.Marshal(manifest) + if err != nil { + return nil, fmt.Errorf("marshaling manifest for platform %s: %w", platformStr, err) + } + + manifestDigest, err := p.store.PutManifest(ctx, manifestBytes) + if err != nil { + return nil, fmt.Errorf("storing manifest for platform %s: %w", platformStr, err) + } + + platformManifests[platformStr] = manifestInfo{ + digest: manifestDigest, + size: int64(len(manifestBytes)), + } + + if i == 0 { + primaryManifestDigest = manifestDigest + primaryConfigDigest = configDigest + skillConfig = cfg + manifestAnnotations = manifest.Annotations + } + } + + indexDigest, err := p.createIndex(ctx, platformManifests, manifestAnnotations, opts) + if err != nil { + return nil, fmt.Errorf("creating index: %w", err) + } + + return &PackageResult{ + IndexDigest: indexDigest, + ManifestDigest: primaryManifestDigest, + ConfigDigest: primaryConfigDigest, + LayerDigest: layerDigest, + Config: skillConfig, + Platforms: opts.Platforms, + }, nil +} + +// readSkillDirectory reads a skill directory, validates its contents, and parses the SKILL.md frontmatter. +func readSkillDirectory(dir string) (*skillDirContent, error) { + if err := validateSkillDir(dir); err != nil { + return nil, err + } + + // Read SKILL.md (required) + skillMDPath := filepath.Join(dir, "SKILL.md") + skillMD, err := os.ReadFile(skillMDPath) //#nosec G304 -- path constructed from user-provided skill directory + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("SKILL.md not found in skill directory") + } + return nil, fmt.Errorf("reading SKILL.md: %w", err) + } + + fm, err := parseFrontmatter(skillMD) + if err != nil { + return nil, fmt.Errorf("parsing SKILL.md: %w", err) + } + + if fm.Name == "" { + return nil, fmt.Errorf("skill name is required in SKILL.md frontmatter") + } + + files, err := collectSkillFiles(dir) + if err != nil { + return nil, err + } + + return &skillDirContent{ + skillMD: skillMD, + files: files, + fm: fm, + }, nil +} + +// validateSkillDir checks that the directory exists and is safe to read. +func validateSkillDir(dir string) error { + info, err := os.Stat(dir) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("skill directory not found: %s", dir) + } + return fmt.Errorf("accessing skill directory: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("path is not a directory: %s", dir) + } + + cleanDir := filepath.Clean(dir) + if strings.Contains(cleanDir, "..") { + return fmt.Errorf("invalid path: contains path traversal") + } + + return nil +} + +// collectSkillFiles walks a skill directory and returns all regular files (excluding SKILL.md and hidden files). +func collectSkillFiles(dir string) (map[string][]byte, error) { + files := make(map[string][]byte) + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + if path == dir { + return nil + } + + relPath, err := filepath.Rel(dir, path) + if err != nil { + return fmt.Errorf("getting relative path: %w", err) + } + relPath = filepath.ToSlash(relPath) + + // Skip hidden files/directories + if strings.HasPrefix(filepath.Base(relPath), ".") { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + + // Security: reject symlinked directories (WalkDir follows them silently) + if d.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("symlinks not allowed in skill directory: %s", relPath) + } + + if d.IsDir() { + return nil + } + + if err := validateSkillFile(path, relPath); err != nil { + return err + } + + // Skip SKILL.md since we handle it separately + if relPath == "SKILL.md" { + return nil + } + + content, err := os.ReadFile(path) //#nosec G304 -- path from WalkDir, symlink-checked + if err != nil { + return fmt.Errorf("reading %s: %w", relPath, err) + } + + files[relPath] = content + return nil + }) + if err != nil { + return nil, fmt.Errorf("walking skill directory: %w", err) + } + return files, nil +} + +// validateSkillFile checks that a file in the skill directory is safe to include. +func validateSkillFile(absPath, relPath string) error { + fileInfo, err := os.Lstat(absPath) + if err != nil { + return fmt.Errorf("checking file type for %s: %w", relPath, err) + } + if fileInfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("symlinks not allowed in skill directory: %s", relPath) + } + if !fileInfo.Mode().IsRegular() { + return fmt.Errorf("non-regular file not allowed in skill directory: %s", relPath) + } + return nil +} + +// parseFrontmatter extracts and parses YAML frontmatter from SKILL.md content. +func parseFrontmatter(content []byte) (*frontmatter, error) { + content = bytes.TrimSpace(content) + + delimiter := []byte("---") + if !bytes.HasPrefix(content, delimiter) { + return nil, fmt.Errorf("SKILL.md must start with YAML frontmatter (---)") + } + + rest := content[len(delimiter):] + rest = bytes.TrimPrefix(rest, []byte("\n")) + + endIdx := bytes.Index(rest, delimiter) + if endIdx == -1 { + return nil, fmt.Errorf("SKILL.md frontmatter missing closing delimiter (---)") + } + + fmBytes := rest[:endIdx] + + if len(fmBytes) > maxFrontmatterSize { + return nil, fmt.Errorf("frontmatter exceeds maximum size of %d bytes", maxFrontmatterSize) + } + + var fm frontmatter + if err := yaml.Unmarshal(fmBytes, &fm); err != nil { + return nil, fmt.Errorf("parsing frontmatter YAML: %w", err) + } + + return &fm, nil +} + +// createContentLayer creates a reproducible tar.gz of the skill content. +// Returns both compressed and uncompressed bytes (uncompressed needed for diff_id). +func createContentLayer(content *skillDirContent, opts PackageOptions) (compressed, uncompressed []byte, err error) { + var files []FileEntry + + // Add SKILL.md first + files = append(files, FileEntry{ + Path: "SKILL.md", + Content: content.skillMD, + }) + + // Add remaining files sorted by path + sortedPaths := make([]string, 0, len(content.files)) + for p := range content.files { + sortedPaths = append(sortedPaths, p) + } + slices.Sort(sortedPaths) + + for _, p := range sortedPaths { + files = append(files, FileEntry{ + Path: p, + Content: content.files[p], + }) + } + + tarOpts := TarOptions{Epoch: opts.Epoch} + gzipOpts := DefaultGzipOptions() + + uncompressed, err = CreateTar(files, tarOpts) + if err != nil { + return nil, nil, fmt.Errorf("creating tar: %w", err) + } + + compressed, err = Compress(uncompressed, gzipOpts) + if err != nil { + return nil, nil, fmt.Errorf("compressing tar: %w", err) + } + + return compressed, uncompressed, nil +} + +// createOCIConfig creates the OCI image config with skill metadata in labels. +func createOCIConfig( + content *skillDirContent, + uncompressedTar []byte, + platform Platform, + opts PackageOptions, +) (*ocispec.Image, *SkillConfig) { + // Collect all file paths + allFiles := []string{"SKILL.md"} + for p := range content.files { + allFiles = append(allFiles, p) + } + slices.Sort(allFiles) + + skillConfig := &SkillConfig{ + Name: content.fm.Name, + Description: content.fm.Description, + Version: content.fm.Version, + AllowedTools: content.fm.AllowedTools, + License: content.fm.License, + Compatibility: content.fm.Compatibility, + Metadata: content.fm.Metadata, + Files: allFiles, + } + + // Encode arrays as JSON for labels + allowedToolsJSON, _ := json.Marshal(skillConfig.AllowedTools) + filesJSON, _ := json.Marshal(skillConfig.Files) + + epoch := opts.Epoch + ociConfig := &ocispec.Image{ + Created: &epoch, + Platform: ocispec.Platform{ + Architecture: platform.Architecture, + OS: platform.OS, + }, + Config: ocispec.ImageConfig{ + Labels: map[string]string{ + LabelSkillName: skillConfig.Name, + LabelSkillDescription: skillConfig.Description, + LabelSkillVersion: skillConfig.Version, + LabelSkillAllowedTools: string(allowedToolsJSON), + LabelSkillLicense: skillConfig.License, + LabelSkillFiles: string(filesJSON), + }, + }, + RootFS: ocispec.RootFS{ + Type: "layers", + DiffIDs: []digest.Digest{digest.FromBytes(uncompressedTar)}, + }, + History: []ocispec.History{ + { + Created: &epoch, + CreatedBy: "toolhive package", + }, + }, + } + + return ociConfig, skillConfig +} + +// createManifest creates the OCI manifest. +func createManifest( + configBytes []byte, + configDigest digest.Digest, + layerBytes []byte, + layerDigest digest.Digest, + fm *frontmatter, + opts PackageOptions, +) *ocispec.Manifest { + annotations := map[string]string{ + ocispec.AnnotationCreated: opts.Epoch.Format(time.RFC3339), + AnnotationSkillName: fm.Name, + AnnotationSkillDescription: fm.Description, + AnnotationSkillVersion: fm.Version, + } + + // Add requires annotation if present in metadata + if reqStr, ok := fm.Metadata["toolhive.requires"]; ok && reqStr != "" { + lines := strings.Split(reqStr, "\n") + refs := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + refs = append(refs, line) + } + } + if len(refs) > 0 { + requiresJSON, err := json.Marshal(refs) + if err == nil { + annotations[AnnotationSkillRequires] = string(requiresJSON) + } + } + } + + return &ocispec.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageManifest, + ArtifactType: ArtifactTypeSkill, + Config: ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageConfig, + Digest: configDigest, + Size: int64(len(configBytes)), + }, + Layers: []ocispec.Descriptor{ + { + MediaType: ocispec.MediaTypeImageLayerGzip, + Digest: layerDigest, + Size: int64(len(layerBytes)), + }, + }, + Annotations: annotations, + } +} + +// createIndex creates an OCI image index with per-platform manifests. +func (p *Packager) createIndex( + ctx context.Context, + platformManifests map[string]manifestInfo, + annotations map[string]string, + opts PackageOptions, +) (digest.Digest, error) { + manifests := make([]ocispec.Descriptor, 0, len(opts.Platforms)) + for _, platform := range opts.Platforms { + platformStr := platform.String() + info, ok := platformManifests[platformStr] + if !ok { + return "", fmt.Errorf("missing manifest for platform %s", platformStr) + } + + manifests = append(manifests, ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageManifest, + Digest: info.digest, + Size: info.size, + Platform: &ocispec.Platform{ + Architecture: platform.Architecture, + OS: platform.OS, + }, + }) + } + + index := ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageIndex, + ArtifactType: ArtifactTypeSkill, + Manifests: manifests, + Annotations: annotations, + } + + indexBytes, err := json.Marshal(index) + if err != nil { + return "", fmt.Errorf("marshaling index: %w", err) + } + + indexDigest, err := p.store.PutManifest(ctx, indexBytes) + if err != nil { + return "", fmt.Errorf("storing index: %w", err) + } + + return indexDigest, nil +} diff --git a/oci/skills/packager_test.go b/oci/skills/packager_test.go new file mode 100644 index 0000000..fd860cd --- /dev/null +++ b/oci/skills/packager_test.go @@ -0,0 +1,588 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skills + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testSkillName = "test-skill" + +func TestPackager_Package(t *testing.T) { + t.Parallel() + + skillDir := createTestSkillDir(t) + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + packager := NewPackager(store) + opts := PackageOptions{Epoch: time.Unix(0, 0).UTC()} + + result, err := packager.Package(context.Background(), skillDir, opts) + require.NoError(t, err) + + assert.NotEmpty(t, result.ManifestDigest.String()) + assert.NotEmpty(t, result.ConfigDigest.String()) + assert.NotEmpty(t, result.LayerDigest.String()) + assert.NotEmpty(t, result.IndexDigest.String()) + + assert.Equal(t, testSkillName, result.Config.Name) + assert.Equal(t, "A test skill for packaging", result.Config.Description) + assert.Equal(t, "1.0.0", result.Config.Version) + assert.NotEmpty(t, result.Config.Files) +} + +func TestPackager_Package_Reproducible(t *testing.T) { + t.Parallel() + + skillDir := createTestSkillDir(t) + opts := PackageOptions{Epoch: time.Unix(0, 0).UTC()} + + store1, err := NewStore(t.TempDir()) + require.NoError(t, err) + + store2, err := NewStore(t.TempDir()) + require.NoError(t, err) + + ctx := context.Background() + + result1, err := NewPackager(store1).Package(ctx, skillDir, opts) + require.NoError(t, err) + + result2, err := NewPackager(store2).Package(ctx, skillDir, opts) + require.NoError(t, err) + + assert.Equal(t, result1.IndexDigest, result2.IndexDigest, "IndexDigest not reproducible") + assert.Equal(t, result1.ManifestDigest, result2.ManifestDigest, "ManifestDigest not reproducible") + assert.Equal(t, result1.ConfigDigest, result2.ConfigDigest, "ConfigDigest not reproducible") + assert.Equal(t, result1.LayerDigest, result2.LayerDigest, "LayerDigest not reproducible") +} + +func TestPackager_Package_VerifyManifest(t *testing.T) { + t.Parallel() + + skillDir := createTestSkillDir(t) + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + packager := NewPackager(store) + opts := PackageOptions{Epoch: time.Unix(0, 0).UTC()} + + ctx := context.Background() + result, err := packager.Package(ctx, skillDir, opts) + require.NoError(t, err) + + manifestBytes, err := store.GetManifest(ctx, result.ManifestDigest) + require.NoError(t, err) + + var manifest ocispec.Manifest + require.NoError(t, json.Unmarshal(manifestBytes, &manifest)) + + assert.Equal(t, 2, manifest.SchemaVersion) + assert.Equal(t, ocispec.MediaTypeImageManifest, manifest.MediaType) + assert.Equal(t, ArtifactTypeSkill, manifest.ArtifactType) + assert.Equal(t, ocispec.MediaTypeImageConfig, manifest.Config.MediaType) + require.Len(t, manifest.Layers, 1) + assert.Equal(t, ocispec.MediaTypeImageLayerGzip, manifest.Layers[0].MediaType) + assert.Equal(t, testSkillName, manifest.Annotations[AnnotationSkillName]) +} + +func TestPackager_Package_VerifyLayer(t *testing.T) { + t.Parallel() + + skillDir := createTestSkillDir(t) + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + packager := NewPackager(store) + opts := PackageOptions{Epoch: time.Unix(0, 0).UTC()} + + ctx := context.Background() + result, err := packager.Package(ctx, skillDir, opts) + require.NoError(t, err) + + layerBytes, err := store.GetBlob(ctx, result.LayerDigest) + require.NoError(t, err) + + files, err := DecompressTar(layerBytes) + require.NoError(t, err) + + found := false + for _, f := range files { + if f.Path == "SKILL.md" { + found = true + break + } + } + assert.True(t, found, "SKILL.md not found in layer") +} + +func TestPackager_Package_WithScripts(t *testing.T) { + t.Parallel() + + skillDir := createTestSkillDirWithScripts(t) + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + packager := NewPackager(store) + opts := PackageOptions{Epoch: time.Unix(0, 0).UTC()} + + ctx := context.Background() + result, err := packager.Package(ctx, skillDir, opts) + require.NoError(t, err) + + assert.Contains(t, result.Config.Files, "scripts/run.sh") + + layerBytes, err := store.GetBlob(ctx, result.LayerDigest) + require.NoError(t, err) + + files, err := DecompressTar(layerBytes) + require.NoError(t, err) + + hasScript := false + for _, f := range files { + if f.Path == "scripts/run.sh" { + hasScript = true + break + } + } + assert.True(t, hasScript, "scripts/run.sh not found in layer") +} + +func TestPackager_Package_VerifyOCIConfig(t *testing.T) { + t.Parallel() + + skillDir := createTestSkillDir(t) + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + packager := NewPackager(store) + opts := PackageOptions{Epoch: time.Unix(0, 0).UTC()} + + ctx := context.Background() + result, err := packager.Package(ctx, skillDir, opts) + require.NoError(t, err) + + configBytes, err := store.GetBlob(ctx, result.ConfigDigest) + require.NoError(t, err) + + var ociConfig ocispec.Image + require.NoError(t, json.Unmarshal(configBytes, &ociConfig)) + + assert.Equal(t, "amd64", ociConfig.Architecture) + assert.Equal(t, "linux", ociConfig.OS) + assert.NotNil(t, ociConfig.Created, "top-level created field should be set") + assert.Equal(t, "layers", ociConfig.RootFS.Type) + require.Len(t, ociConfig.RootFS.DiffIDs, 1) + assert.Contains(t, ociConfig.RootFS.DiffIDs[0].String(), "sha256:") + + labels := ociConfig.Config.Labels + require.NotNil(t, labels) + assert.Equal(t, testSkillName, labels[LabelSkillName]) + assert.Equal(t, "A test skill for packaging", labels[LabelSkillDescription]) + assert.Equal(t, "1.0.0", labels[LabelSkillVersion]) + + var allowedTools []string + require.NoError(t, json.Unmarshal([]byte(labels[LabelSkillAllowedTools]), &allowedTools)) + assert.Equal(t, []string{"Read", "Grep"}, allowedTools) + + require.Len(t, ociConfig.History, 1) + assert.Equal(t, "toolhive package", ociConfig.History[0].CreatedBy) +} + +func TestPackager_Package_MultiPlatformConfigMatch(t *testing.T) { + t.Parallel() + + skillDir := createTestSkillDir(t) + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + packager := NewPackager(store) + platforms := []Platform{ + {OS: "linux", Architecture: "amd64"}, + {OS: "linux", Architecture: "arm64"}, + } + opts := PackageOptions{ + Epoch: time.Unix(0, 0).UTC(), + Platforms: platforms, + } + + ctx := context.Background() + result, err := packager.Package(ctx, skillDir, opts) + require.NoError(t, err) + + assert.Equal(t, platforms, result.Platforms) + + // Get the index + indexBytes, err := store.GetManifest(ctx, result.IndexDigest) + require.NoError(t, err) + + var index ocispec.Index + require.NoError(t, json.Unmarshal(indexBytes, &index)) + + require.Len(t, index.Manifests, 2) + + for _, descriptor := range index.Manifests { + require.NotNil(t, descriptor.Platform) + platformStr := descriptor.Platform.OS + "/" + descriptor.Platform.Architecture + + manifestBytes, err := store.GetManifest(ctx, descriptor.Digest) + require.NoError(t, err) + + var manifest ocispec.Manifest + require.NoError(t, json.Unmarshal(manifestBytes, &manifest)) + + configBytes, err := store.GetBlob(ctx, manifest.Config.Digest) + require.NoError(t, err) + + var ociConfig ocispec.Image + require.NoError(t, json.Unmarshal(configBytes, &ociConfig)) + + assert.Equal(t, descriptor.Platform.OS, ociConfig.OS, + "Config OS for platform %s", platformStr) + assert.Equal(t, descriptor.Platform.Architecture, ociConfig.Architecture, + "Config Architecture for platform %s", platformStr) + } +} + +func TestDefaultPackageOptions(t *testing.T) { + t.Parallel() + + opts := DefaultPackageOptions() + assert.False(t, opts.Epoch.IsZero()) + assert.Equal(t, DefaultPlatforms, opts.Platforms) +} + +func TestDefaultPackageOptions_WithSourceDateEpoch(t *testing.T) { + t.Setenv("SOURCE_DATE_EPOCH", "1234567890") + + opts := DefaultPackageOptions() + expected := time.Unix(1234567890, 0).UTC() + assert.True(t, opts.Epoch.Equal(expected)) +} + +func TestPackager_Package_MissingSkillMD(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + packager := NewPackager(store) + opts := PackageOptions{Epoch: time.Unix(0, 0).UTC()} + + _, err = packager.Package(context.Background(), dir, opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "SKILL.md not found") +} + +func TestPackager_Package_MissingName(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + skillMD := `--- +description: A skill without a name +version: 1.0.0 +--- +# No Name Skill +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(skillMD), 0600)) + + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + packager := NewPackager(store) + opts := PackageOptions{Epoch: time.Unix(0, 0).UTC()} + + _, err = packager.Package(context.Background(), dir, opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "skill name is required") +} + +func TestPackager_Package_DefaultPlatforms(t *testing.T) { + t.Parallel() + + skillDir := createTestSkillDir(t) + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + packager := NewPackager(store) + opts := PackageOptions{Epoch: time.Unix(0, 0).UTC()} + + result, err := packager.Package(context.Background(), skillDir, opts) + require.NoError(t, err) + + assert.Equal(t, DefaultPlatforms, result.Platforms) +} + +func TestPackager_Package_RejectsSymlinks(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + skillMD := `--- +name: test-skill +description: A test skill +version: 1.0.0 +--- +# Test Skill +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(skillMD), 0600)) + require.NoError(t, os.Symlink("/etc/passwd", filepath.Join(dir, "evil_link"))) + + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + packager := NewPackager(store) + opts := PackageOptions{Epoch: time.Unix(0, 0).UTC()} + + _, err = packager.Package(context.Background(), dir, opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "symlinks not allowed") +} + +func TestPackager_Package_RejectsSymlinkedDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + skillMD := `--- +name: test-skill +description: A test skill +version: 1.0.0 +--- +# Test Skill +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(skillMD), 0600)) + require.NoError(t, os.Symlink("/etc", filepath.Join(dir, "evil_dir"))) + + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + packager := NewPackager(store) + opts := PackageOptions{Epoch: time.Unix(0, 0).UTC()} + + _, err = packager.Package(context.Background(), dir, opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "symlinks not allowed") +} + +func TestNewPackager_NilStore(t *testing.T) { + t.Parallel() + + assert.Panics(t, func() { + NewPackager(nil) + }) +} + +func TestPackager_Package_InvalidFrontmatter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + wantErr string + }{ + { + name: "no frontmatter", + content: "# Just markdown\nNo frontmatter here.", + wantErr: "must start with YAML frontmatter", + }, + { + name: "unclosed frontmatter", + content: "---\nname: test\n# Never closed", + wantErr: "missing closing delimiter", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(tt.content), 0600)) + + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + packager := NewPackager(store) + opts := PackageOptions{Epoch: time.Unix(0, 0).UTC()} + + _, err = packager.Package(context.Background(), dir, opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestPackager_Package_NonexistentDir(t *testing.T) { + t.Parallel() + + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + packager := NewPackager(store) + opts := PackageOptions{Epoch: time.Unix(0, 0).UTC()} + + _, err = packager.Package(context.Background(), "/nonexistent/path", opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "skill directory not found") +} + +func TestPackager_Package_IndexStructure(t *testing.T) { + t.Parallel() + + skillDir := createTestSkillDir(t) + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + packager := NewPackager(store) + opts := PackageOptions{Epoch: time.Unix(0, 0).UTC()} + + ctx := context.Background() + result, err := packager.Package(ctx, skillDir, opts) + require.NoError(t, err) + + indexBytes, err := store.GetManifest(ctx, result.IndexDigest) + require.NoError(t, err) + + var index ocispec.Index + require.NoError(t, json.Unmarshal(indexBytes, &index)) + + assert.Equal(t, 2, index.SchemaVersion) + assert.Equal(t, ocispec.MediaTypeImageIndex, index.MediaType) + assert.Equal(t, ArtifactTypeSkill, index.ArtifactType) + assert.NotEmpty(t, index.Annotations) + assert.Equal(t, testSkillName, index.Annotations[AnnotationSkillName]) +} + +func TestParseFrontmatter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want *frontmatter + wantErr bool + }{ + { + name: "full frontmatter", + content: `--- +name: my-skill +description: A great skill +version: 2.0.0 +allowed-tools: + - Read + - Write +license: MIT +--- +# Body`, + want: &frontmatter{ + Name: "my-skill", + Description: "A great skill", + Version: "2.0.0", + AllowedTools: stringOrSlice{"Read", "Write"}, + License: "MIT", + }, + }, + { + name: "allowed-tools as space-delimited string", + content: `--- +name: my-skill +description: A skill +allowed-tools: Read Grep Glob +--- +# Body`, + want: &frontmatter{ + Name: "my-skill", + Description: "A skill", + AllowedTools: stringOrSlice{"Read", "Grep", "Glob"}, + }, + }, + { + name: "allowed-tools as comma-delimited string", + content: `--- +name: my-skill +description: A skill +allowed-tools: Read, Grep, Glob +--- +# Body`, + want: &frontmatter{ + Name: "my-skill", + Description: "A skill", + AllowedTools: stringOrSlice{"Read", "Grep", "Glob"}, + }, + }, + { + name: "no frontmatter delimiters", + content: "just markdown", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fm, err := parseFrontmatter([]byte(tt.content)) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want.Name, fm.Name) + assert.Equal(t, tt.want.Description, fm.Description) + assert.Equal(t, tt.want.Version, fm.Version) + assert.Equal(t, []string(tt.want.AllowedTools), []string(fm.AllowedTools)) + assert.Equal(t, tt.want.License, fm.License) + }) + } +} + +// Helper functions + +func createTestSkillDir(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + + skillMD := `--- +name: test-skill +description: A test skill for packaging +version: 1.0.0 +allowed-tools: + - Read + - Grep +--- +# Test Skill + +This is a test skill. +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(skillMD), 0600)) + + return dir +} + +func createTestSkillDirWithScripts(t *testing.T) string { + t.Helper() + + dir := createTestSkillDir(t) + + scriptsDir := filepath.Join(dir, "scripts") + require.NoError(t, os.MkdirAll(scriptsDir, 0750)) + + script := `#!/bin/bash +echo "Hello from test skill" +` + require.NoError(t, os.WriteFile(filepath.Join(scriptsDir, "run.sh"), []byte(script), 0600)) + + return dir +}