Skip to content
Open
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ task license-fix # Add missing license headers
| `env` | Environment variable abstraction with `Reader` interface for testable code |
| `httperr` | Wrap errors with HTTP status codes; use `WithCode()`, `Code()`, `New()` |
| `logging` | Pre-configured `*slog.Logger` factory with consistent ToolHive defaults (Alpha) |
| `oci/artifact` | Artifact-agnostic OCI tar/gzip/extraction/platform primitives shared by oci/skills and oci/plugins (Alpha) |
| `oci/skills` | OCI artifact types, media types, and registry operations for ToolHive skills (Alpha) |
| `postgres` | PostgreSQL connection pool with optional AWS RDS IAM dynamic auth (Alpha) |
| `recovery` | HTTP panic recovery middleware (Beta) |
Expand Down
36 changes: 36 additions & 0 deletions oci/artifact/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

/*
Package artifact provides artifact-agnostic OCI primitives shared by the
ToolHive ecosystem: reproducible tar archive creation and extraction,
reproducible gzip compression, OCI platform helpers, and pull-hardening
(size/count/digest validation) for registry operations.

These primitives are independent of any particular artifact type (skills,
plugins, etc.). Artifact-specific media types, labels, and annotations live in
the packages that define those artifacts (for example oci/skills).

# Reproducible Archives

CreateTar and Compress produce byte-stable output for identical input, which is
what makes artifact digests deterministic:

data, err := artifact.CompressTar(files, artifact.DefaultTarOptions(), artifact.DefaultGzipOptions())

# Platform Helpers

PlatformString and ParsePlatform convert between OCI platform values and their
"os/arch" or "os/arch/variant" string form.

# Pull Hardening

ValidatingTarget wraps an oras.Target and enforces size and structure limits on
pushed content, defending against OOM and resource exhaustion from malicious
registries during pull operations.

# Stability

This package is Alpha. Breaking changes are possible between minor versions.
*/
package artifact
2 changes: 1 addition & 1 deletion oci/skills/gzip.go → oci/artifact/gzip.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package skills
package artifact

import (
"bytes"
Expand Down
190 changes: 190 additions & 0 deletions oci/artifact/gzip_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package artifact

import (
"bytes"
"compress/gzip"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCompress_Reproducible(t *testing.T) {
t.Parallel()

data := []byte("test data for compression")
opts := DefaultGzipOptions()

gz1, err := Compress(data, opts)
require.NoError(t, err)

gz2, err := Compress(data, opts)
require.NoError(t, err)

assert.Equal(t, gz1, gz2, "Compress should produce identical output for same input")
}

func TestCompress_HeaderFieldsForReproducibility(t *testing.T) {
t.Parallel()

data := []byte("test data")
epoch := time.Unix(1234567890, 0).UTC()
opts := GzipOptions{
Level: gzip.BestCompression,
Epoch: epoch,
}

compressed, err := Compress(data, opts)
require.NoError(t, err)

gr, err := gzip.NewReader(bytes.NewReader(compressed))
require.NoError(t, err)
defer gr.Close()

assert.True(t, gr.ModTime.Equal(epoch), "ModTime should match epoch")
assert.Empty(t, gr.Name, "Name should be empty")
assert.Empty(t, gr.Comment, "Comment should be empty")
assert.Equal(t, byte(gzipOSUnknown), gr.OS, "OS should be 255 (unknown)")
}

func TestCompress_DifferentEpochs(t *testing.T) {
t.Parallel()

data := []byte("test data")

tests := []struct {
name string
epoch1 time.Time
epoch2 time.Time
wantEqual bool
}{
{
name: "same epoch produces same output",
epoch1: time.Unix(1609459200, 0).UTC(),
epoch2: time.Unix(1609459200, 0).UTC(),
wantEqual: true,
},
{
name: "different epochs produce different output",
epoch1: time.Unix(0, 0).UTC(),
epoch2: time.Unix(1000000, 0).UTC(),
wantEqual: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

opts1 := GzipOptions{Level: gzip.BestCompression, Epoch: tt.epoch1}
opts2 := GzipOptions{Level: gzip.BestCompression, Epoch: tt.epoch2}

gz1, err := Compress(data, opts1)
require.NoError(t, err)

gz2, err := Compress(data, opts2)
require.NoError(t, err)

if tt.wantEqual {
assert.Equal(t, gz1, gz2)
} else {
assert.NotEqual(t, gz1, gz2)
}
})
}
}

func TestCompress_SameEpochAlwaysReproducible(t *testing.T) {
t.Parallel()

data := []byte("test data for reproducibility check")
epoch := time.Unix(1609459200, 0).UTC()
opts := GzipOptions{Level: gzip.BestCompression, Epoch: epoch}

results := make([][]byte, 5)
for i := range results {
var err error
results[i], err = Compress(data, opts)
require.NoError(t, err)
}

for i := 1; i < len(results); i++ {
assert.Equal(t, results[0], results[i], "iteration %d should match", i)
}
}

func TestCompressDecompress_RoundTrip(t *testing.T) {
t.Parallel()

original := []byte("test data for round trip")
opts := DefaultGzipOptions()

compressed, err := Compress(original, opts)
require.NoError(t, err)

decompressed, err := Decompress(compressed)
require.NoError(t, err)

assert.Equal(t, original, decompressed)
}

func TestDecompressWithLimit_RejectsOversized(t *testing.T) {
t.Parallel()

// Create compressed data that exceeds the limit when decompressed
data := bytes.Repeat([]byte("x"), 1024)
compressed, err := Compress(data, DefaultGzipOptions())
require.NoError(t, err)

_, err = DecompressWithLimit(compressed, 100)
assert.Error(t, err)
assert.Contains(t, err.Error(), "exceeds maximum size")
}

func TestCompressTar_Reproducible(t *testing.T) {
t.Parallel()

files := []FileEntry{
{Path: testFileB, Content: []byte("content b")},
{Path: testFileA, Content: []byte("content a")},
}

tarOpts := DefaultTarOptions()
gzipOpts := DefaultGzipOptions()

gz1, err := CompressTar(files, tarOpts, gzipOpts)
require.NoError(t, err)

gz2, err := CompressTar(files, tarOpts, gzipOpts)
require.NoError(t, err)

assert.Equal(t, gz1, gz2, "CompressTar should produce identical output")
}

func TestCompressTar_RoundTrip(t *testing.T) {
t.Parallel()

originalFiles := []FileEntry{
{Path: testFileA, Content: []byte("content a")},
{Path: "dir/" + testFileB, Content: []byte("content b")},
}

tarOpts := DefaultTarOptions()
gzipOpts := DefaultGzipOptions()

compressed, err := CompressTar(originalFiles, tarOpts, gzipOpts)
require.NoError(t, err)

extractedFiles, err := DecompressTar(compressed)
require.NoError(t, err)

require.Len(t, extractedFiles, len(originalFiles))
for i, f := range extractedFiles {
assert.Equal(t, originalFiles[i].Path, f.Path)
assert.Equal(t, originalFiles[i].Content, f.Content)
}
}
59 changes: 59 additions & 0 deletions oci/artifact/platform.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package artifact

import (
"fmt"
"strings"

ocispec "github.com/opencontainers/image-spec/specs-go/v1"
)

// PlatformString returns the platform in "os/arch" or "os/arch/variant" format.
func PlatformString(p ocispec.Platform) string {
s := p.OS + "/" + p.Architecture
if p.Variant != "" {
s += "/" + p.Variant
}
return s
}

// ParsePlatform parses a platform string in "os/arch" or "os/arch/variant" format.
func ParsePlatform(s string) (ocispec.Platform, error) {
parts := strings.Split(s, "/")
if len(parts) < 2 || len(parts) > 3 {
return ocispec.Platform{}, fmt.Errorf("invalid platform format: %q (expected os/arch or os/arch/variant)", s)
}
osName := strings.TrimSpace(parts[0])
arch := strings.TrimSpace(parts[1])
if osName == "" || arch == "" {
return ocispec.Platform{}, fmt.Errorf("invalid platform format: %q (os and arch cannot be empty)", s)
}
p := ocispec.Platform{OS: osName, Architecture: arch}
if len(parts) == 3 {
variant := strings.TrimSpace(parts[2])
if variant == "" {
return ocispec.Platform{}, fmt.Errorf("invalid platform format: %q (variant cannot be empty)", s)
}
p.Variant = variant
}
return p, nil
}

// OS and architecture constants for OCI platform specifications.
const (
// OSLinux is the Linux OS identifier used in OCI platform specs.
OSLinux = "linux"
// ArchAMD64 is the x86-64 architecture identifier used in OCI platform specs.
ArchAMD64 = "amd64"
// ArchARM64 is the 64-bit ARM architecture identifier used in OCI platform specs.
ArchARM64 = "arm64"
)

// DefaultPlatforms are the default platforms for artifacts.
// These cover most Kubernetes clusters.
var DefaultPlatforms = []ocispec.Platform{
{OS: OSLinux, Architecture: ArchAMD64},
{OS: OSLinux, Architecture: ArchARM64},
}
Loading
Loading