Skip to content

Commit d935a41

Browse files
JAORMXclaude
andcommitted
Add guest, hooks, extract, and image/disk packages
Extract reusable packages from apiary and toolhive-appliance: - guest/{boot,env,mount,netcfg,reaper,sshd}: Guest-side VM init packages with functional options for boot orchestration - hooks/: Reusable RootFSHook builders with path traversal validation via internal/pathutil - extract/: Embedded binary extraction cache with versioned caching, atomic writes, and cross-process locking - image/disk/: Disk image download (with retry) and zstd+tar decompression reusing image/ tar security helpers - net/egress/profiles: Permissive/Standard/Locked egress profiles - internal/pathutil/: Shared path containment validation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 91274a1 commit d935a41

45 files changed

Lines changed: 4095 additions & 27 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

extract/bundle.go

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package extract
5+
6+
import (
7+
"crypto/sha256"
8+
"encoding/hex"
9+
"fmt"
10+
"os"
11+
"path/filepath"
12+
"sync"
13+
14+
"github.com/gofrs/flock"
15+
)
16+
17+
// File describes a single file to extract.
18+
type File struct {
19+
Name string
20+
Content []byte
21+
Mode os.FileMode
22+
}
23+
24+
// Symlink describes a symbolic link to create after extraction.
25+
type Symlink struct {
26+
Target string // e.g. "libkrun.so.1"
27+
Name string // e.g. "libkrun.so"
28+
}
29+
30+
// Bundle holds files and symlinks to extract into a versioned cache directory.
31+
type Bundle struct {
32+
version string
33+
files []File
34+
links []Symlink
35+
mu sync.Mutex
36+
}
37+
38+
// NewBundle creates a bundle with the given version string, files, and optional symlinks.
39+
func NewBundle(version string, files []File, links ...Symlink) *Bundle {
40+
return &Bundle{version: version, files: files, links: links}
41+
}
42+
43+
// Ensure extracts the bundle into a versioned subdirectory of cacheDir.
44+
// It returns the path to the directory containing the extracted files.
45+
// Extraction is skipped if a matching version already exists (cache hit).
46+
// Concurrent and cross-process safety is provided via in-process mutex
47+
// and cross-process file locking.
48+
func (b *Bundle) Ensure(cacheDir string) (string, error) {
49+
hash := b.computeHash()
50+
targetDir := filepath.Join(cacheDir, "extract-"+hash[:16])
51+
52+
// Fast path: check if already extracted.
53+
if b.isValid(targetDir, hash) {
54+
return targetDir, nil
55+
}
56+
57+
// Acquire in-process mutex.
58+
b.mu.Lock()
59+
defer b.mu.Unlock()
60+
61+
// Acquire cross-process file lock.
62+
lockPath := filepath.Join(cacheDir, ".extract.lock")
63+
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
64+
return "", fmt.Errorf("create cache dir: %w", err)
65+
}
66+
fl := flock.New(lockPath)
67+
if err := fl.Lock(); err != nil {
68+
return "", fmt.Errorf("acquire file lock: %w", err)
69+
}
70+
defer fl.Unlock() //nolint:errcheck // best-effort unlock
71+
72+
// Double-check after acquiring lock.
73+
if b.isValid(targetDir, hash) {
74+
return targetDir, nil
75+
}
76+
77+
// Create temp dir in cacheDir for atomic swap.
78+
tmpDir, err := os.MkdirTemp(cacheDir, "extract-tmp-")
79+
if err != nil {
80+
return "", fmt.Errorf("create temp dir: %w", err)
81+
}
82+
// Clean up temp dir on failure.
83+
defer func() {
84+
if err != nil {
85+
_ = os.RemoveAll(tmpDir)
86+
}
87+
}()
88+
89+
// Extract all files.
90+
for _, f := range b.files {
91+
if extractErr := b.extractFile(tmpDir, f); extractErr != nil {
92+
err = extractErr
93+
return "", fmt.Errorf("extract %s: %w", f.Name, extractErr)
94+
}
95+
}
96+
97+
// Create symlinks.
98+
for _, l := range b.links {
99+
if linkErr := b.createSymlink(tmpDir, l); linkErr != nil {
100+
err = linkErr
101+
return "", fmt.Errorf("create symlink %s: %w", l.Name, linkErr)
102+
}
103+
}
104+
105+
// Write version file.
106+
versionPath := filepath.Join(tmpDir, ".version")
107+
if writeErr := os.WriteFile(versionPath, []byte(hash), 0o644); writeErr != nil {
108+
err = writeErr
109+
return "", fmt.Errorf("write version file: %w", writeErr)
110+
}
111+
112+
// Atomic rename to target.
113+
if renameErr := os.Rename(tmpDir, targetDir); renameErr != nil {
114+
err = renameErr
115+
return "", fmt.Errorf("rename to target: %w", renameErr)
116+
}
117+
118+
return targetDir, nil
119+
}
120+
121+
// computeHash returns a SHA-256 hex digest of the version string and all
122+
// file contents.
123+
func (b *Bundle) computeHash() string {
124+
h := sha256.New()
125+
h.Write([]byte(b.version))
126+
for _, f := range b.files {
127+
h.Write([]byte(f.Name))
128+
h.Write(f.Content)
129+
}
130+
return hex.EncodeToString(h.Sum(nil))
131+
}
132+
133+
// isValid checks whether targetDir exists and contains a .version file
134+
// matching the expected hash.
135+
func (b *Bundle) isValid(targetDir, hash string) bool {
136+
data, err := os.ReadFile(filepath.Join(targetDir, ".version"))
137+
if err != nil {
138+
return false
139+
}
140+
return string(data) == hash
141+
}
142+
143+
// extractFile writes a single file atomically via a temp file and rename.
144+
func (b *Bundle) extractFile(dir string, f File) error {
145+
dst := filepath.Join(dir, f.Name)
146+
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
147+
return fmt.Errorf("create parent dirs: %w", err)
148+
}
149+
150+
// Write to temp file first, then rename for atomicity.
151+
tmp, err := os.CreateTemp(filepath.Dir(dst), ".tmp-"+filepath.Base(f.Name)+"-")
152+
if err != nil {
153+
return fmt.Errorf("create temp file: %w", err)
154+
}
155+
tmpName := tmp.Name()
156+
157+
if _, err := tmp.Write(f.Content); err != nil {
158+
_ = tmp.Close()
159+
_ = os.Remove(tmpName)
160+
return fmt.Errorf("write content: %w", err)
161+
}
162+
if err := tmp.Chmod(f.Mode); err != nil {
163+
_ = tmp.Close()
164+
_ = os.Remove(tmpName)
165+
return fmt.Errorf("chmod: %w", err)
166+
}
167+
if err := tmp.Close(); err != nil {
168+
_ = os.Remove(tmpName)
169+
return fmt.Errorf("close temp file: %w", err)
170+
}
171+
if err := os.Rename(tmpName, dst); err != nil {
172+
_ = os.Remove(tmpName)
173+
return fmt.Errorf("rename to final: %w", err)
174+
}
175+
return nil
176+
}
177+
178+
// createSymlink creates a symbolic link in the given directory.
179+
func (b *Bundle) createSymlink(dir string, l Symlink) error {
180+
linkPath := filepath.Join(dir, l.Name)
181+
if err := os.Symlink(l.Target, linkPath); err != nil {
182+
return fmt.Errorf("symlink %s -> %s: %w", l.Name, l.Target, err)
183+
}
184+
return nil
185+
}

extract/bundle_test.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package extract
5+
6+
import (
7+
"os"
8+
"path/filepath"
9+
"sync"
10+
"testing"
11+
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func TestBundle_Ensure_ExtractsFiles(t *testing.T) {
17+
t.Parallel()
18+
19+
cacheDir := t.TempDir()
20+
files := []File{
21+
{Name: "hello.txt", Content: []byte("hello"), Mode: 0o644},
22+
{Name: "bin/tool", Content: []byte("#!/bin/sh"), Mode: 0o755},
23+
}
24+
b := NewBundle("v1", files)
25+
26+
dir, err := b.Ensure(cacheDir)
27+
require.NoError(t, err)
28+
29+
// Verify file content.
30+
got, err := os.ReadFile(filepath.Join(dir, "hello.txt"))
31+
require.NoError(t, err)
32+
assert.Equal(t, []byte("hello"), got)
33+
34+
// Verify binary content and permissions.
35+
got, err = os.ReadFile(filepath.Join(dir, "bin", "tool"))
36+
require.NoError(t, err)
37+
assert.Equal(t, []byte("#!/bin/sh"), got)
38+
39+
info, err := os.Stat(filepath.Join(dir, "bin", "tool"))
40+
require.NoError(t, err)
41+
assert.Equal(t, os.FileMode(0o755), info.Mode().Perm())
42+
}
43+
44+
func TestBundle_Ensure_Idempotent(t *testing.T) {
45+
t.Parallel()
46+
47+
cacheDir := t.TempDir()
48+
files := []File{
49+
{Name: "data.bin", Content: []byte("content"), Mode: 0o644},
50+
}
51+
b := NewBundle("v1", files)
52+
53+
dir1, err := b.Ensure(cacheDir)
54+
require.NoError(t, err)
55+
56+
dir2, err := b.Ensure(cacheDir)
57+
require.NoError(t, err)
58+
59+
assert.Equal(t, dir1, dir2)
60+
}
61+
62+
func TestBundle_Ensure_VersionChange(t *testing.T) {
63+
t.Parallel()
64+
65+
cacheDir := t.TempDir()
66+
files1 := []File{
67+
{Name: "data.txt", Content: []byte("v1 content"), Mode: 0o644},
68+
}
69+
files2 := []File{
70+
{Name: "data.txt", Content: []byte("v2 content"), Mode: 0o644},
71+
}
72+
73+
b1 := NewBundle("v1", files1)
74+
dir1, err := b1.Ensure(cacheDir)
75+
require.NoError(t, err)
76+
77+
b2 := NewBundle("v2", files2)
78+
dir2, err := b2.Ensure(cacheDir)
79+
require.NoError(t, err)
80+
81+
assert.NotEqual(t, dir1, dir2)
82+
83+
// Verify each directory has its own content.
84+
got1, err := os.ReadFile(filepath.Join(dir1, "data.txt"))
85+
require.NoError(t, err)
86+
assert.Equal(t, []byte("v1 content"), got1)
87+
88+
got2, err := os.ReadFile(filepath.Join(dir2, "data.txt"))
89+
require.NoError(t, err)
90+
assert.Equal(t, []byte("v2 content"), got2)
91+
}
92+
93+
func TestBundle_Ensure_Symlinks(t *testing.T) {
94+
t.Parallel()
95+
96+
cacheDir := t.TempDir()
97+
files := []File{
98+
{Name: "libkrun.so.1", Content: []byte("library"), Mode: 0o755},
99+
}
100+
links := []Symlink{
101+
{Target: "libkrun.so.1", Name: "libkrun.so"},
102+
}
103+
b := NewBundle("v1", files, links...)
104+
105+
dir, err := b.Ensure(cacheDir)
106+
require.NoError(t, err)
107+
108+
// Verify symlink exists and points to the right target.
109+
linkPath := filepath.Join(dir, "libkrun.so")
110+
target, err := os.Readlink(linkPath)
111+
require.NoError(t, err)
112+
assert.Equal(t, "libkrun.so.1", target)
113+
114+
// Verify symlink resolves to correct content.
115+
got, err := os.ReadFile(linkPath)
116+
require.NoError(t, err)
117+
assert.Equal(t, []byte("library"), got)
118+
}
119+
120+
func TestBundle_Ensure_EmptyBundle(t *testing.T) {
121+
t.Parallel()
122+
123+
cacheDir := t.TempDir()
124+
b := NewBundle("v1", nil)
125+
126+
dir, err := b.Ensure(cacheDir)
127+
require.NoError(t, err)
128+
129+
// Directory should exist with version file.
130+
info, err := os.Stat(dir)
131+
require.NoError(t, err)
132+
assert.True(t, info.IsDir())
133+
134+
versionData, err := os.ReadFile(filepath.Join(dir, ".version"))
135+
require.NoError(t, err)
136+
assert.NotEmpty(t, versionData)
137+
}
138+
139+
func TestBundle_Ensure_ConcurrentAccess(t *testing.T) {
140+
t.Parallel()
141+
142+
cacheDir := t.TempDir()
143+
files := []File{
144+
{Name: "shared.txt", Content: []byte("concurrent data"), Mode: 0o644},
145+
}
146+
b := NewBundle("v1", files)
147+
148+
const numGoroutines = 10
149+
dirs := make([]string, numGoroutines)
150+
errs := make([]error, numGoroutines)
151+
152+
var wg sync.WaitGroup
153+
wg.Add(numGoroutines)
154+
for i := range numGoroutines {
155+
go func(idx int) {
156+
defer wg.Done()
157+
dirs[idx], errs[idx] = b.Ensure(cacheDir)
158+
}(i)
159+
}
160+
wg.Wait()
161+
162+
// All should succeed and return the same directory.
163+
for i := range numGoroutines {
164+
require.NoError(t, errs[i], "goroutine %d failed", i)
165+
assert.Equal(t, dirs[0], dirs[i], "goroutine %d returned different dir", i)
166+
}
167+
168+
// Verify file content is not corrupted.
169+
got, err := os.ReadFile(filepath.Join(dirs[0], "shared.txt"))
170+
require.NoError(t, err)
171+
assert.Equal(t, []byte("concurrent data"), got)
172+
}

extract/doc.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Package extract provides a cache-aware extraction mechanism for embedded
5+
// binary bundles. Consumers supply file contents as byte slices; the package
6+
// handles SHA-256 version keying, atomic directory swaps, cross-process file
7+
// locking, and symlink creation.
8+
package extract

extract/libname.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package extract
5+
6+
// LibName returns the platform-specific shared library filename for the
7+
// given base name and major version number.
8+
func LibName(base string, major int) string {
9+
return libName(base, major)
10+
}

extract/libname_darwin.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//go:build darwin
5+
6+
package extract
7+
8+
func libName(base string, _ int) string {
9+
return "lib" + base + ".dylib"
10+
}

0 commit comments

Comments
 (0)