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
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
2 changes: 1 addition & 1 deletion oci/skills/gzip_test.go → oci/artifact/gzip_test.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
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},
}
128 changes: 128 additions & 0 deletions oci/artifact/platform_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package artifact

import (
"testing"

ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// testArchARM is the 32-bit ARM architecture identifier used in test platform specs.
const testArchARM = "arm"

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

tests := []struct {
name string
input string
want ocispec.Platform
wantErr bool
}{
{
name: "os/arch",
input: "linux/amd64",
want: ocispec.Platform{OS: OSLinux, Architecture: ArchAMD64},
},
{
name: "os/arch/variant",
input: "linux/arm/v7",
want: ocispec.Platform{OS: OSLinux, Architecture: testArchARM, Variant: "v7"},
},
{
name: "fewer than 2 parts (no slash)",
input: "linuxamd64",
wantErr: true,
},
{
name: "more than 3 parts",
input: "linux/amd64/v8/extra",
wantErr: true,
},
{
name: "empty os",
input: "/amd64",
wantErr: true,
},
{
name: "empty arch",
input: "linux/",
wantErr: true,
},
{
name: "empty variant",
input: "linux/arm/",
wantErr: true,
},
}

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

got, err := ParsePlatform(tt.input)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

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

tests := []struct {
name string
platform ocispec.Platform
want string
}{
{
name: "os/arch",
platform: ocispec.Platform{OS: OSLinux, Architecture: ArchAMD64},
want: "linux/amd64",
},
{
name: "os/arch/variant",
platform: ocispec.Platform{OS: OSLinux, Architecture: testArchARM, Variant: "v7"},
want: "linux/arm/v7",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, PlatformString(tt.platform))
})
}
}

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

platforms := []ocispec.Platform{
{OS: OSLinux, Architecture: ArchAMD64},
{OS: OSLinux, Architecture: ArchARM64},
{OS: OSLinux, Architecture: testArchARM, Variant: "v7"},
}

for _, p := range platforms {
parsed, err := ParsePlatform(PlatformString(p))
require.NoError(t, err)
assert.Equal(t, p, parsed)
}
}

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

require.Len(t, DefaultPlatforms, 2)
assert.Equal(t, ocispec.Platform{OS: OSLinux, Architecture: ArchAMD64}, DefaultPlatforms[0])
assert.Equal(t, ocispec.Platform{OS: OSLinux, Architecture: ArchARM64}, DefaultPlatforms[1])
}
23 changes: 14 additions & 9 deletions oci/skills/tar.go → oci/artifact/tar.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
Comment thread
jhrozek marked this conversation as resolved.

import (
"archive/tar"
Expand Down Expand Up @@ -112,7 +112,8 @@ func ExtractTarWithLimit(data []byte, maxFileSize int64) ([]FileEntry, error) {
}

// Reject path traversal
if err := validateTarPath(hdr.Name); err != nil {
cleanedPath, err := validateTarPath(hdr.Name)
if err != nil {
return nil, err
}

Expand Down Expand Up @@ -148,7 +149,7 @@ func ExtractTarWithLimit(data []byte, maxFileSize int64) ([]FileEntry, error) {
}

files = append(files, FileEntry{
Path: hdr.Name,
Path: cleanedPath,
Content: content,
Mode: hdr.Mode,
})
Expand All @@ -157,16 +158,20 @@ func ExtractTarWithLimit(data []byte, maxFileSize int64) ([]FileEntry, error) {
return files, nil
}

// validateTarPath checks that a tar entry path is safe.
func validateTarPath(p string) error {
// validateTarPath checks that a tar entry path is safe and returns its cleaned path.
func validateTarPath(p string) (string, error) {
if strings.Contains(p, `\\`) {
return "", fmt.Errorf("backslash path separators not allowed in archive: %s", p)
}

// path.Clean resolves all ".." segments; any remaining leading ".."
// means the path escapes the archive root.
cleaned := path.Clean(p)
if strings.HasPrefix(cleaned, "..") {
return fmt.Errorf("path traversal detected in archive: %s", p)
if cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", fmt.Errorf("path traversal detected in archive: %s", p)
}
if path.IsAbs(cleaned) {
return fmt.Errorf("absolute path not allowed in archive: %s", p)
return "", fmt.Errorf("absolute path not allowed in archive: %s", p)
}
return nil
return cleaned, nil
}
27 changes: 26 additions & 1 deletion oci/skills/tar_test.go → oci/artifact/tar_test.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 (
"archive/tar"
Expand Down Expand Up @@ -205,6 +205,7 @@ func TestExtractTar_RejectsPathTraversal(t *testing.T) {
{name: "dotdot prefix", path: "../etc/passwd"},
{name: "dotdot in middle", path: "foo/../../etc/passwd"},
{name: "absolute path", path: "/etc/passwd"},
{name: "windows traversal", path: `foo\\..\\..\\etc\\passwd`},
}

for _, tt := range tests {
Expand All @@ -231,6 +232,30 @@ func TestExtractTar_RejectsPathTraversal(t *testing.T) {
}
}

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

var buf bytes.Buffer
tw := tar.NewWriter(&buf)

content := []byte("test")
require.NoError(t, tw.WriteHeader(&tar.Header{
Name: "foo/./bar.txt",
Size: int64(len(content)),
Typeflag: tar.TypeReg,
Mode: 0644,
}))
_, err := tw.Write(content)
require.NoError(t, err)
require.NoError(t, tw.Close())

files, err := ExtractTar(buf.Bytes())
require.NoError(t, err)
require.Len(t, files, 1)
assert.Equal(t, "foo/bar.txt", files[0].Path)
assert.Equal(t, content, files[0].Content)
}

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

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

package artifact

const (
testFileA = "a.txt"
testFileB = "b.txt"
)
Loading
Loading