diff --git a/go.mod b/go.mod index 78d429a..cd7bf16 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module github.com/stacklok/toolhive-core go 1.25.7 require ( + github.com/adrg/xdg v0.5.3 github.com/google/cel-go v0.27.0 + github.com/opencontainers/go-digest v1.0.0 github.com/stretchr/testify v1.11.1 go.uber.org/mock v0.6.0 golang.org/x/net v0.49.0 @@ -15,6 +17,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect + golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect diff --git a/go.sum b/go.sum index f30ac3f..5a2f9b7 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -8,6 +10,8 @@ github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -20,6 +24,8 @@ golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++ golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw= diff --git a/oci/skills/interfaces.go b/oci/skills/interfaces.go new file mode 100644 index 0000000..8acd9cc --- /dev/null +++ b/oci/skills/interfaces.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skills + +//go:generate mockgen -source=interfaces.go -destination=mocks/mock_interfaces.go -package=mocks + +import ( + "context" + "time" + + "github.com/opencontainers/go-digest" +) + +// RegistryClient provides remote OCI registry operations for skills. +type RegistryClient interface { + // Push pushes an artifact from the local store to a remote registry. + Push(ctx context.Context, store *Store, manifestDigest digest.Digest, ref string) error + + // Pull pulls an artifact from a remote registry into the local store. + Pull(ctx context.Context, store *Store, ref string) (digest.Digest, error) +} + +// SkillPackager creates OCI artifacts from skill directories. +type SkillPackager interface { + // Package packages a skill directory into an OCI artifact in the local store. + Package(ctx context.Context, skillDir string, opts PackageOptions) (*PackageResult, error) +} + +// PackageOptions configures skill packaging. +type PackageOptions struct { + // Epoch is the timestamp to use for reproducible builds. + Epoch time.Time + + // Platforms specifies target platforms for the image index. + // If empty, defaults to DefaultPlatforms. + Platforms []Platform +} + +// PackageResult contains the result of packaging a skill. +type PackageResult struct { + IndexDigest digest.Digest + ManifestDigest digest.Digest + ConfigDigest digest.Digest + LayerDigest digest.Digest + Config *SkillConfig + Platforms []Platform +} diff --git a/oci/skills/mocks/mock_interfaces.go b/oci/skills/mocks/mock_interfaces.go new file mode 100644 index 0000000..5d234f8 --- /dev/null +++ b/oci/skills/mocks/mock_interfaces.go @@ -0,0 +1,111 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: interfaces.go +// +// Generated by this command: +// +// mockgen -source=interfaces.go -destination=mocks/mock_interfaces.go -package=mocks +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + digest "github.com/opencontainers/go-digest" + skills "github.com/stacklok/toolhive-core/oci/skills" + gomock "go.uber.org/mock/gomock" +) + +// MockRegistryClient is a mock of RegistryClient interface. +type MockRegistryClient struct { + ctrl *gomock.Controller + recorder *MockRegistryClientMockRecorder + isgomock struct{} +} + +// MockRegistryClientMockRecorder is the mock recorder for MockRegistryClient. +type MockRegistryClientMockRecorder struct { + mock *MockRegistryClient +} + +// NewMockRegistryClient creates a new mock instance. +func NewMockRegistryClient(ctrl *gomock.Controller) *MockRegistryClient { + mock := &MockRegistryClient{ctrl: ctrl} + mock.recorder = &MockRegistryClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRegistryClient) EXPECT() *MockRegistryClientMockRecorder { + return m.recorder +} + +// Pull mocks base method. +func (m *MockRegistryClient) Pull(ctx context.Context, store *skills.Store, ref string) (digest.Digest, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Pull", ctx, store, ref) + ret0, _ := ret[0].(digest.Digest) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Pull indicates an expected call of Pull. +func (mr *MockRegistryClientMockRecorder) Pull(ctx, store, ref any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Pull", reflect.TypeOf((*MockRegistryClient)(nil).Pull), ctx, store, ref) +} + +// Push mocks base method. +func (m *MockRegistryClient) Push(ctx context.Context, store *skills.Store, manifestDigest digest.Digest, ref string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Push", ctx, store, manifestDigest, ref) + ret0, _ := ret[0].(error) + return ret0 +} + +// Push indicates an expected call of Push. +func (mr *MockRegistryClientMockRecorder) Push(ctx, store, manifestDigest, ref any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Push", reflect.TypeOf((*MockRegistryClient)(nil).Push), ctx, store, manifestDigest, ref) +} + +// MockSkillPackager is a mock of SkillPackager interface. +type MockSkillPackager struct { + ctrl *gomock.Controller + recorder *MockSkillPackagerMockRecorder + isgomock struct{} +} + +// MockSkillPackagerMockRecorder is the mock recorder for MockSkillPackager. +type MockSkillPackagerMockRecorder struct { + mock *MockSkillPackager +} + +// NewMockSkillPackager creates a new mock instance. +func NewMockSkillPackager(ctrl *gomock.Controller) *MockSkillPackager { + mock := &MockSkillPackager{ctrl: ctrl} + mock.recorder = &MockSkillPackagerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSkillPackager) EXPECT() *MockSkillPackagerMockRecorder { + return m.recorder +} + +// Package mocks base method. +func (m *MockSkillPackager) Package(ctx context.Context, skillDir string, opts skills.PackageOptions) (*skills.PackageResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Package", ctx, skillDir, opts) + ret0, _ := ret[0].(*skills.PackageResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Package indicates an expected call of Package. +func (mr *MockSkillPackagerMockRecorder) Package(ctx, skillDir, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Package", reflect.TypeOf((*MockSkillPackager)(nil).Package), ctx, skillDir, opts) +} diff --git a/oci/skills/store.go b/oci/skills/store.go new file mode 100644 index 0000000..381f9ba --- /dev/null +++ b/oci/skills/store.go @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skills + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/adrg/xdg" + "github.com/opencontainers/go-digest" +) + +// Store provides local OCI artifact storage. +type Store struct { + root string + mu sync.RWMutex +} + +// Index tracks tags to digests mapping. +type Index struct { + Tags map[string]string `json:"tags"` // tag -> digest string +} + +// NewStore creates a new local OCI store at the given root directory. +func NewStore(root string) (*Store, error) { + // Create directory structure + dirs := []string{ + filepath.Join(root, "blobs", "sha256"), + filepath.Join(root, "manifests"), + } + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0750); err != nil { + return nil, fmt.Errorf("creating store directory %s: %w", dir, err) + } + } + + return &Store{root: root}, nil +} + +// StoreRoot returns the skills store root within the given data home directory. +// This is the injectable, testable form. For the standard XDG location, use DefaultStoreRoot. +func StoreRoot(dataHome string) string { + return filepath.Join(dataHome, "toolhive", "skills") +} + +// DefaultStoreRoot returns the default store root directory using XDG base directory conventions. +func DefaultStoreRoot() string { + return StoreRoot(xdg.DataHome) +} + +// PutBlob stores a blob and returns its digest. +func (s *Store) PutBlob(_ context.Context, content []byte) (digest.Digest, error) { + s.mu.Lock() + defer s.mu.Unlock() + + d := digest.FromBytes(content) + blobPath := s.blobPath(d) + + // Check if blob already exists + if _, err := os.Stat(blobPath); err == nil { + return d, nil + } + + if err := os.WriteFile(blobPath, content, 0600); err != nil { + return "", fmt.Errorf("writing blob: %w", err) + } + + return d, nil +} + +// GetBlob retrieves a blob by digest. +func (s *Store) GetBlob(_ context.Context, d digest.Digest) ([]byte, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + blobPath := s.blobPath(d) + content, err := os.ReadFile(blobPath) //#nosec G304 -- path is constructed from validated digest + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("blob not found: %s", d) + } + return nil, fmt.Errorf("reading blob: %w", err) + } + + return content, nil +} + +// PutManifest stores a manifest and returns its digest. +func (s *Store) PutManifest(_ context.Context, content []byte) (digest.Digest, error) { + s.mu.Lock() + defer s.mu.Unlock() + + d := digest.FromBytes(content) + manifestPath := s.manifestPath(d) + + if err := os.WriteFile(manifestPath, content, 0600); err != nil { + return "", fmt.Errorf("writing manifest: %w", err) + } + + return d, nil +} + +// GetManifest retrieves a manifest by digest. +func (s *Store) GetManifest(_ context.Context, d digest.Digest) ([]byte, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.getManifest(d) +} + +// getManifest reads a manifest without locking. Caller must hold mu. +func (s *Store) getManifest(d digest.Digest) ([]byte, error) { + manifestPath := s.manifestPath(d) + content, err := os.ReadFile(manifestPath) //#nosec G304 -- path is constructed from validated digest + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("manifest not found: %s", d) + } + return nil, fmt.Errorf("reading manifest: %w", err) + } + + return content, nil +} + +// Tag associates a tag with a manifest digest. +func (s *Store) Tag(_ context.Context, d digest.Digest, tag string) error { + s.mu.Lock() + defer s.mu.Unlock() + + index, err := s.loadIndex() + if err != nil { + return err + } + + index.Tags[tag] = d.String() + + return s.saveIndex(index) +} + +// Resolve resolves a tag to a manifest digest. +func (s *Store) Resolve(_ context.Context, tag string) (digest.Digest, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + index, err := s.loadIndex() + if err != nil { + return "", err + } + + digestStr, ok := index.Tags[tag] + if !ok { + return "", fmt.Errorf("tag not found: %s", tag) + } + + return digest.Parse(digestStr) +} + +// ListTags returns all tags in the store. +func (s *Store) ListTags(_ context.Context) ([]string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + index, err := s.loadIndex() + if err != nil { + return nil, err + } + + tags := make([]string, 0, len(index.Tags)) + for tag := range index.Tags { + tags = append(tags, tag) + } + + return tags, nil +} + +// GetIndex retrieves and parses an image index by digest. +func (s *Store) GetIndex(_ context.Context, d digest.Digest) (*ImageIndex, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + data, err := s.getManifest(d) + if err != nil { + return nil, fmt.Errorf("getting index: %w", err) + } + + var index ImageIndex + if err := json.Unmarshal(data, &index); err != nil { + return nil, fmt.Errorf("parsing index: %w", err) + } + + return &index, nil +} + +// IsIndex checks if the content at the given digest is an image index. +func (s *Store) IsIndex(_ context.Context, d digest.Digest) (bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + data, err := s.getManifest(d) + if err != nil { + return false, err + } + + // Check media type field + var header struct { + MediaType string `json:"mediaType"` + } + if err := json.Unmarshal(data, &header); err != nil { + return false, fmt.Errorf("parsing media type: %w", err) + } + + return header.MediaType == MediaTypeImageIndex, nil +} + +// Root returns the store root directory. +func (s *Store) Root() string { + return s.root +} + +// blobPath returns the path for a blob with the given digest. +func (s *Store) blobPath(d digest.Digest) string { + return filepath.Join(s.root, "blobs", d.Algorithm().String(), d.Encoded()) +} + +// manifestPath returns the path for a manifest with the given digest. +func (s *Store) manifestPath(d digest.Digest) string { + return filepath.Join(s.root, "manifests", d.Encoded()) +} + +// indexPath returns the path to the index file. +func (s *Store) indexPath() string { + return filepath.Join(s.root, "index.json") +} + +// loadIndex loads the tag index from disk. +func (s *Store) loadIndex() (*Index, error) { + indexPath := s.indexPath() + + data, err := os.ReadFile(indexPath) //#nosec G304 -- path is constructed from store root + if err != nil { + if os.IsNotExist(err) { + return &Index{Tags: make(map[string]string)}, nil + } + return nil, fmt.Errorf("reading index: %w", err) + } + + var index Index + if err := json.Unmarshal(data, &index); err != nil { + return nil, fmt.Errorf("parsing index: %w", err) + } + + if index.Tags == nil { + index.Tags = make(map[string]string) + } + + return &index, nil +} + +// saveIndex saves the tag index to disk. +func (s *Store) saveIndex(index *Index) error { + indexPath := s.indexPath() + + data, err := json.MarshalIndent(index, "", " ") + if err != nil { + return fmt.Errorf("marshaling index: %w", err) + } + + if err := os.WriteFile(indexPath, data, 0600); err != nil { + return fmt.Errorf("writing index: %w", err) + } + + return nil +} diff --git a/oci/skills/store_test.go b/oci/skills/store_test.go new file mode 100644 index 0000000..d481b49 --- /dev/null +++ b/oci/skills/store_test.go @@ -0,0 +1,305 @@ +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package skills + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/opencontainers/go-digest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewStore(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + storePath := filepath.Join(tmpDir, "store") + + store, err := NewStore(storePath) + require.NoError(t, err) + assert.Equal(t, storePath, store.Root()) + + // Check directories were created + blobsDir := filepath.Join(storePath, "blobs", "sha256") + _, err = os.Stat(blobsDir) + assert.NoError(t, err, "blobs directory should exist") + + manifestsDir := filepath.Join(storePath, "manifests") + _, err = os.Stat(manifestsDir) + assert.NoError(t, err, "manifests directory should exist") +} + +func TestStore_PutGetBlob(t *testing.T) { + t.Parallel() + + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + ctx := context.Background() + content := []byte("test blob content") + + d, err := store.PutBlob(ctx, content) + require.NoError(t, err) + assert.Equal(t, digest.FromBytes(content), d) + + retrieved, err := store.GetBlob(ctx, d) + require.NoError(t, err) + assert.Equal(t, content, retrieved) +} + +func TestStore_PutBlob_Idempotent(t *testing.T) { + t.Parallel() + + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + ctx := context.Background() + content := []byte("test blob content") + + d1, err := store.PutBlob(ctx, content) + require.NoError(t, err) + + d2, err := store.PutBlob(ctx, content) + require.NoError(t, err) + + assert.Equal(t, d1, d2, "putting the same content twice should return the same digest") +} + +func TestStore_GetBlob_NotFound(t *testing.T) { + t.Parallel() + + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + ctx := context.Background() + fakeDigest := digest.FromString("nonexistent") + + _, err = store.GetBlob(ctx, fakeDigest) + require.Error(t, err) + assert.Contains(t, err.Error(), "blob not found") +} + +func TestStore_PutGetManifest(t *testing.T) { + t.Parallel() + + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + ctx := context.Background() + manifest := []byte(`{"schemaVersion": 2}`) + + d, err := store.PutManifest(ctx, manifest) + require.NoError(t, err) + + retrieved, err := store.GetManifest(ctx, d) + require.NoError(t, err) + assert.Equal(t, manifest, retrieved) +} + +func TestStore_TagResolve(t *testing.T) { + t.Parallel() + + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + ctx := context.Background() + manifest := []byte(`{"schemaVersion": 2}`) + + d, err := store.PutManifest(ctx, manifest) + require.NoError(t, err) + + tag := "ghcr.io/myorg/my-skill:v1.0.0" + err = store.Tag(ctx, d, tag) + require.NoError(t, err) + + resolved, err := store.Resolve(ctx, tag) + require.NoError(t, err) + assert.Equal(t, d, resolved) +} + +func TestStore_Resolve_NotFound(t *testing.T) { + t.Parallel() + + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + ctx := context.Background() + + _, err = store.Resolve(ctx, "nonexistent:tag") + require.Error(t, err) + assert.Contains(t, err.Error(), "tag not found") +} + +func TestStore_ListTags(t *testing.T) { + t.Parallel() + + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + ctx := context.Background() + + // Initially empty + tags, err := store.ListTags(ctx) + require.NoError(t, err) + assert.Empty(t, tags) + + // Add some tags + manifest := []byte(`{"schemaVersion": 2}`) + d, err := store.PutManifest(ctx, manifest) + require.NoError(t, err) + + expectedTags := []string{"tag1", "tag2", "tag3"} + for _, tag := range expectedTags { + err = store.Tag(ctx, d, tag) + require.NoError(t, err) + } + + tags, err = store.ListTags(ctx) + require.NoError(t, err) + assert.Len(t, tags, len(expectedTags)) + for _, expected := range expectedTags { + assert.Contains(t, tags, expected) + } +} + +func TestStore_TagOverwrite(t *testing.T) { + t.Parallel() + + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + ctx := context.Background() + + manifest1 := []byte(`{"schemaVersion": 2, "version": 1}`) + manifest2 := []byte(`{"schemaVersion": 2, "version": 2}`) + + d1, err := store.PutManifest(ctx, manifest1) + require.NoError(t, err) + + d2, err := store.PutManifest(ctx, manifest2) + require.NoError(t, err) + + tag := "my-skill:latest" + err = store.Tag(ctx, d1, tag) + require.NoError(t, err) + + // Overwrite with second manifest + err = store.Tag(ctx, d2, tag) + require.NoError(t, err) + + resolved, err := store.Resolve(ctx, tag) + require.NoError(t, err) + assert.Equal(t, d2, resolved, "tag should resolve to the second manifest after overwrite") +} + +func TestStore_GetIndex(t *testing.T) { + t.Parallel() + + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + ctx := context.Background() + + idx := &ImageIndex{ + SchemaVersion: 2, + MediaType: MediaTypeImageIndex, + Manifests: []IndexDescriptor{ + { + MediaType: MediaTypeImageManifest, + Digest: "sha256:abc123", + Size: 100, + Platform: &Platform{OS: "linux", Architecture: "amd64"}, + }, + }, + } + + data, err := json.Marshal(idx) + require.NoError(t, err) + + d, err := store.PutManifest(ctx, data) + require.NoError(t, err) + + got, err := store.GetIndex(ctx, d) + require.NoError(t, err) + assert.Equal(t, 2, got.SchemaVersion) + assert.Equal(t, MediaTypeImageIndex, got.MediaType) + require.Len(t, got.Manifests, 1) + assert.Equal(t, "linux", got.Manifests[0].Platform.OS) + assert.Equal(t, "amd64", got.Manifests[0].Platform.Architecture) +} + +func TestStore_IsIndex(t *testing.T) { + t.Parallel() + + store, err := NewStore(t.TempDir()) + require.NoError(t, err) + + ctx := context.Background() + + // Store an image index + indexData, err := json.Marshal(ImageIndex{ + SchemaVersion: 2, + MediaType: MediaTypeImageIndex, + }) + require.NoError(t, err) + + indexDigest, err := store.PutManifest(ctx, indexData) + require.NoError(t, err) + + isIdx, err := store.IsIndex(ctx, indexDigest) + require.NoError(t, err) + assert.True(t, isIdx, "should detect image index") + + // Store a regular manifest + manifestData := []byte(`{"schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json"}`) + manifestDigest, err := store.PutManifest(ctx, manifestData) + require.NoError(t, err) + + isIdx, err = store.IsIndex(ctx, manifestDigest) + require.NoError(t, err) + assert.False(t, isIdx, "should not detect regular manifest as index") +} + +func TestStoreRoot(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dataHome string + want string + }{ + { + name: "custom path", + dataHome: "/tmp/test-data", + want: filepath.Join("/tmp/test-data", "toolhive", "skills"), + }, + { + name: "xdg default", + dataHome: "/home/user/.local/share", + want: filepath.Join("/home/user/.local/share", "toolhive", "skills"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, StoreRoot(tt.dataHome)) + }) + } +} + +func TestDefaultStoreRoot(t *testing.T) { + t.Parallel() + + root := DefaultStoreRoot() + assert.True(t, filepath.IsAbs(root), "default store root should be an absolute path") + assert.True(t, strings.HasSuffix(root, filepath.Join("toolhive", "skills")), + "default store root should end with toolhive/skills, got: %s", root) +}