Skip to content
Draft
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
11 changes: 8 additions & 3 deletions cmd/hauler/cli/store/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,19 @@ func AddFileCmd(ctx context.Context, o *flags.AddFileOpts, s *store.Layout, refe
if len(o.Name) > 0 {
cfg.Name = o.Name
}
return storeFile(ctx, s, cfg)
var allowInternal bool
if o.StoreRootOpts != nil {
allowInternal = o.AllowInternalTargets
}
return storeFile(ctx, s, cfg, allowInternal)
}

func storeFile(ctx context.Context, s *store.Layout, fi v1.File) error {
func storeFile(ctx context.Context, s *store.Layout, fi v1.File, allowInternalTargets bool) error {
l := log.FromContext(ctx)

copts := getter.ClientOptions{
NameOverride: fi.Name,
NameOverride: fi.Name,
AllowInternalTargets: allowInternalTargets,
}

f := file.NewFile(fi.Path, file.WithClient(getter.NewClient(copts)))
Expand Down
8 changes: 4 additions & 4 deletions cmd/hauler/cli/store/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ func TestStoreFile(t *testing.T) {
tmp.Close()

s := newTestStore(t)
if err := storeFile(ctx, s, v1.File{Path: tmp.Name()}); err != nil {
if err := storeFile(ctx, s, v1.File{Path: tmp.Name()}, false); err != nil {
t.Fatalf("storeFile: %v", err)
}
assertArtifactInStore(t, s, filepath.Base(tmp.Name()))
Expand All @@ -380,7 +380,7 @@ func TestStoreFile(t *testing.T) {
t.Run("HTTP URL stored under basename", func(t *testing.T) {
url := seedFileInHTTPServer(t, "script.sh", "#!/bin/sh\necho ok")
s := newTestStore(t)
if err := storeFile(ctx, s, v1.File{Path: url}); err != nil {
if err := storeFile(ctx, s, v1.File{Path: url}, true); err != nil {
t.Fatalf("storeFile: %v", err)
}
assertArtifactInStore(t, s, "script.sh")
Expand All @@ -394,15 +394,15 @@ func TestStoreFile(t *testing.T) {
tmp.Close()

s := newTestStore(t)
if err := storeFile(ctx, s, v1.File{Path: tmp.Name(), Name: "custom.sh"}); err != nil {
if err := storeFile(ctx, s, v1.File{Path: tmp.Name(), Name: "custom.sh"}, false); err != nil {
t.Fatalf("storeFile: %v", err)
}
assertArtifactInStore(t, s, "custom.sh")
})

t.Run("nonexistent local path returns error", func(t *testing.T) {
s := newTestStore(t)
err := storeFile(ctx, s, v1.File{Path: "/nonexistent/path/missing-file.txt"})
err := storeFile(ctx, s, v1.File{Path: "/nonexistent/path/missing-file.txt"}, false)
if err == nil {
t.Fatal("expected error for nonexistent path, got nil")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/hauler/cli/store/copy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ func TestCopyCmd_Dir_Files(t *testing.T) {
url := seedFileInHTTPServer(t, "data.txt", content)

s := newTestStore(t)
if err := storeFile(ctx, s, v1.File{Path: url}); err != nil {
if err := storeFile(ctx, s, v1.File{Path: url}, true); err != nil {
t.Fatalf("storeFile: %v", err)
}

Expand Down
6 changes: 3 additions & 3 deletions cmd/hauler/cli/store/extract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func TestExtractCmd_File(t *testing.T) {

fileContent := "hello extract test"
url := seedFileInHTTPServer(t, "extract-me.txt", fileContent)
if err := storeFile(ctx, s, v1.File{Path: url}); err != nil {
if err := storeFile(ctx, s, v1.File{Path: url}, true); err != nil {
t.Fatalf("storeFile: %v", err)
}

Expand Down Expand Up @@ -533,7 +533,7 @@ func TestExtractCmd_SubstringMatch(t *testing.T) {

fileContent := "substring match content"
url := seedFileInHTTPServer(t, "extract-sub.txt", fileContent)
if err := storeFile(ctx, s, v1.File{Path: url}); err != nil {
if err := storeFile(ctx, s, v1.File{Path: url}, true); err != nil {
t.Fatalf("storeFile: %v", err)
}

Expand Down Expand Up @@ -581,7 +581,7 @@ func TestExtractCmd_CosignArtifactsProduceNoContainerImageWarning(t *testing.T)
// Seed a real file artifact so ExtractCmd finds something to extract.
fileContent := "cosign-filter test file content"
url := seedFileInHTTPServer(t, "sigtest.txt", fileContent)
if err := storeFile(ctx, s, v1.File{Path: url}); err != nil {
if err := storeFile(ctx, s, v1.File{Path: url}, true); err != nil {
t.Fatalf("storeFile: %v", err)
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/hauler/cli/store/info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ func TestInfoCmd(t *testing.T) {
t.Fatalf("write tmpFile: %v", err)
}
fi := v1.File{Path: tmpFile}
if err := storeFile(ctx, s, fi); err != nil {
if err := storeFile(ctx, s, fi, false); err != nil {
t.Fatalf("storeFile: %v", err)
}

Expand Down
6 changes: 3 additions & 3 deletions cmd/hauler/cli/store/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func TestLifecycle_FileArtifact_AddSaveLoadCopy(t *testing.T) {

// Step 2: storeFile into store A.
storeA := newTestStore(t)
if err := storeFile(ctx, storeA, v1.File{Path: url}); err != nil {
if err := storeFile(ctx, storeA, v1.File{Path: url}, true); err != nil {
t.Fatalf("storeFile: %v", err)
}
assertArtifactInStore(t, storeA, "lifecycle.txt")
Expand Down Expand Up @@ -252,10 +252,10 @@ func TestLifecycle_Remove_ThenSave(t *testing.T) {
url2 := seedFileInHTTPServer(t, "remove-me.txt", "content to remove")

storeA := newTestStore(t)
if err := storeFile(ctx, storeA, v1.File{Path: url1}); err != nil {
if err := storeFile(ctx, storeA, v1.File{Path: url1}, true); err != nil {
t.Fatalf("storeFile keep-me: %v", err)
}
if err := storeFile(ctx, storeA, v1.File{Path: url2}); err != nil {
if err := storeFile(ctx, storeA, v1.File{Path: url2}, true); err != nil {
t.Fatalf("storeFile remove-me: %v", err)
}

Expand Down
23 changes: 18 additions & 5 deletions cmd/hauler/cli/store/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package store
import (
"context"
"encoding/json"
"fmt"
"io"
"net/url"
"os"
Expand Down Expand Up @@ -41,7 +42,7 @@ func LoadCmd(ctx context.Context, o *flags.LoadOpts, rso *flags.StoreRootOpts, r
for _, fileName := range o.FileName {
resolved := resolveHaulPath(fileName)
l.Infof("loading haul [%s] to [%s]", resolved, o.StoreDir)
err := unarchiveLayoutTo(ctx, resolved, o.StoreDir, tempDir)
err := unarchiveLayoutTo(ctx, resolved, o.StoreDir, tempDir, rso.AllowInternalTargets)
if err != nil {
return err
}
Expand All @@ -52,13 +53,13 @@ func LoadCmd(ctx context.Context, o *flags.LoadOpts, rso *flags.StoreRootOpts, r
}

// accepts an archived OCI layout, extracts the contents to an existing OCI layout, and preserves the index
func unarchiveLayoutTo(ctx context.Context, haulPath string, dest string, tempDir string) error {
func unarchiveLayoutTo(ctx context.Context, haulPath string, dest string, tempDir string, allowInternalTargets bool) error {
l := log.FromContext(ctx)

if strings.HasPrefix(haulPath, "http://") || strings.HasPrefix(haulPath, "https://") {
l.Debugf("detected remote archive... starting download... [%s]", haulPath)

h := getter.NewHttp()
h := getter.NewHttpWithOptions(getter.HttpOptions{AllowInternalTargets: allowInternalTargets})
parsedURL, err := url.Parse(haulPath)
if err != nil {
return err
Expand All @@ -81,9 +82,13 @@ func unarchiveLayoutTo(ctx context.Context, haulPath string, dest string, tempDi
}
defer out.Close()

if _, err = io.Copy(out, rc); err != nil {
n, err := io.Copy(out, io.LimitReader(rc, consts.MaxDownloadBytes+1))
if err != nil {
return err
}
if n > consts.MaxDownloadBytes {
Comment on lines +85 to +89
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This download path bounds the copy with io.LimitReader(rc, MaxDownloadBytes+1) and then checks n > MaxDownloadBytes after writing. That can leave an oversized temp file (up to +1 byte) on disk even though the operation fails. Consider copying only up to MaxDownloadBytes and probing for one extra byte (or truncating/removing the temp file) to enforce a strict on-disk cap; also ensure the reported URL in any error message isn’t using the rewritten local haulPath value.

Copilot uses AI. Check for mistakes.
return fmt.Errorf("remote archive at %s exceeds maximum allowed size (%d bytes)", haulPath, consts.MaxDownloadBytes)
}
}

// reassemble chunk files if haulPath matches the chunk naming pattern
Expand All @@ -98,10 +103,18 @@ func unarchiveLayoutTo(ctx context.Context, haulPath string, dest string, tempDi
}

// ensure the incoming index.json has the correct annotations.
data, err := os.ReadFile(tempDir + "/index.json")
indexFile, err := os.Open(tempDir + "/index.json")
if err != nil {
return (err)
}
data, err := io.ReadAll(io.LimitReader(indexFile, consts.MaxManifestBytes+1))
indexFile.Close()
if err != nil {
return (err)
}
if int64(len(data)) > consts.MaxManifestBytes {
return fmt.Errorf("index.json exceeds maximum allowed size (%d bytes)", consts.MaxManifestBytes)
}

var idx ocispec.Index
if err := json.Unmarshal(data, &idx); err != nil {
Expand Down
14 changes: 9 additions & 5 deletions cmd/hauler/cli/store/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func TestUnarchiveLayoutTo(t *testing.T) {
destDir := t.TempDir()
tempDir := t.TempDir()

if err := unarchiveLayoutTo(ctx, testHaulArchive, destDir, tempDir); err != nil {
if err := unarchiveLayoutTo(ctx, testHaulArchive, destDir, tempDir, false); err != nil {
t.Fatalf("unarchiveLayoutTo: %v", err)
}

Expand Down Expand Up @@ -192,12 +192,16 @@ func TestLoadCmd_RemoteArchive(t *testing.T) {
destDir := t.TempDir()
remoteURL := srv.URL + "/haul.tar.zst"

// AllowInternalTargets=true because the test server binds to loopback.
rso := defaultRootOpts(destDir)
rso.AllowInternalTargets = true

o := &flags.LoadOpts{
StoreRootOpts: defaultRootOpts(destDir),
StoreRootOpts: rso,
FileName: []string{remoteURL},
}

if err := LoadCmd(ctx, o, defaultRootOpts(destDir), defaultCliOpts()); err != nil {
if err := LoadCmd(ctx, o, rso, defaultCliOpts()); err != nil {
t.Fatalf("LoadCmd remote: %v", err)
}

Expand Down Expand Up @@ -262,7 +266,7 @@ func TestUnarchiveLayoutTo_AnnotationBackfill(t *testing.T) {
// Step 4: Load the stripped archive.
destDir := t.TempDir()
tempDir := t.TempDir()
if err := unarchiveLayoutTo(ctx, strippedArchive, destDir, tempDir); err != nil {
if err := unarchiveLayoutTo(ctx, strippedArchive, destDir, tempDir, false); err != nil {
t.Fatalf("unarchiveLayoutTo stripped: %v", err)
}

Expand Down Expand Up @@ -354,7 +358,7 @@ func TestUnarchiveLayoutTo_LegacyKindMigration(t *testing.T) {
// Step 4: Load the legacy archive.
destDir := t.TempDir()
tempDir := t.TempDir()
if err := unarchiveLayoutTo(ctx, legacyArchive, destDir, tempDir); err != nil {
if err := unarchiveLayoutTo(ctx, legacyArchive, destDir, tempDir, false); err != nil {
t.Fatalf("unarchiveLayoutTo legacy: %v", err)
}

Expand Down
6 changes: 3 additions & 3 deletions cmd/hauler/cli/store/remove_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func TestRemoveCmd_Force(t *testing.T) {
s := newTestStore(t)

url := seedFileInHTTPServer(t, "removeme.txt", "file-to-remove")
if err := storeFile(ctx, s, v1.File{Path: url}); err != nil {
if err := storeFile(ctx, s, v1.File{Path: url}, true); err != nil {
t.Fatalf("storeFile: %v", err)
}

Expand Down Expand Up @@ -133,10 +133,10 @@ func TestRemoveCmd_Force_MultipleMatches(t *testing.T) {
url1 := seedFileInHTTPServer(t, "testfile-alpha.txt", "content-alpha")
url2 := seedFileInHTTPServer(t, "testfile-beta.txt", "content-beta")

if err := storeFile(ctx, s, v1.File{Path: url1}); err != nil {
if err := storeFile(ctx, s, v1.File{Path: url1}, true); err != nil {
t.Fatalf("storeFile alpha: %v", err)
}
if err := storeFile(ctx, s, v1.File{Path: url2}); err != nil {
if err := storeFile(ctx, s, v1.File{Path: url2}, true); err != nil {
t.Fatalf("storeFile beta: %v", err)
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/hauler/cli/store/save_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ func TestWriteExportsManifest_SkipsNonImages(t *testing.T) {

url := seedFileInHTTPServer(t, "skip.sh", "#!/bin/sh\necho skip")
s := newTestStore(t)
if err := storeFile(ctx, s, v1.File{Path: url}); err != nil {
if err := storeFile(ctx, s, v1.File{Path: url}, true); err != nil {
t.Fatalf("storeFile: %v", err)
}

Expand Down
23 changes: 17 additions & 6 deletions cmd/hauler/cli/store/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,14 @@ func SyncCmd(ctx context.Context, o *flags.SyncOpts, s *store.Layout, rso *flags
if err != nil {
return err
}
content, err := io.ReadAll(rc)
content, err := io.ReadAll(io.LimitReader(rc, consts.MaxManifestBytes+1))
rc.Close()
if err != nil {
return err
}
if int64(len(content)) > consts.MaxManifestBytes {
return fmt.Errorf("product manifest for [%s] exceeds maximum allowed size (%d bytes)", productName, consts.MaxManifestBytes)
}

// Ensure each manifest starts with a YAML document separator.
if !strings.HasPrefix(string(content), "---") {
Expand Down Expand Up @@ -161,7 +164,7 @@ func SyncCmd(ctx context.Context, o *flags.SyncOpts, s *store.Layout, rso *flags
if strings.HasPrefix(haulPath, "http://") || strings.HasPrefix(haulPath, "https://") {
l.Debugf("detected remote manifest... starting download... [%s]", haulPath)

h := getter.NewHttp()
h := getter.NewHttpWithOptions(getter.HttpOptions{AllowInternalTargets: rso.AllowInternalTargets})
parsedURL, err := url.Parse(haulPath)
if err != nil {
return err
Expand All @@ -184,9 +187,13 @@ func SyncCmd(ctx context.Context, o *flags.SyncOpts, s *store.Layout, rso *flags
}
defer out.Close()

if _, err = io.Copy(out, rc); err != nil {
n, err := io.Copy(out, io.LimitReader(rc, consts.MaxDownloadBytes+1))
if err != nil {
return err
}
if n > consts.MaxDownloadBytes {
return fmt.Errorf("remote manifest at %s exceeds maximum allowed size (%d bytes)", haulPath, consts.MaxDownloadBytes)
}
Comment on lines 192 to +196
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remote-manifest download uses io.LimitReader(rc, MaxDownloadBytes+1) and only errors after the copy completes. This can write past the intended cap (up to +1 byte) into the temp file before failing. Consider enforcing the cap without writing beyond it (copy MaxDownloadBytes then check for an extra byte, and/or truncate/remove the temp file), and make sure the error message reports the original remote URL rather than the rewritten local haulPath temp path.

Copilot uses AI. Check for mistakes.
}

fi, err := os.Open(haulPath)
Expand All @@ -213,7 +220,7 @@ func SyncCmd(ctx context.Context, o *flags.SyncOpts, s *store.Layout, rso *flags
if strings.HasPrefix(haulPath, "http://") || strings.HasPrefix(haulPath, "https://") {
l.Debugf("detected remote image.txt... starting download... [%s]", haulPath)

h := getter.NewHttp()
h := getter.NewHttpWithOptions(getter.HttpOptions{AllowInternalTargets: rso.AllowInternalTargets})
parsedURL, err := url.Parse(haulPath)
if err != nil {
return err
Expand All @@ -236,9 +243,13 @@ func SyncCmd(ctx context.Context, o *flags.SyncOpts, s *store.Layout, rso *flags
}
defer out.Close()

if _, err = io.Copy(out, rc); err != nil {
n, err := io.Copy(out, io.LimitReader(rc, consts.MaxDownloadBytes+1))
if err != nil {
return err
}
if n > consts.MaxDownloadBytes {
return fmt.Errorf("remote image.txt at %s exceeds maximum allowed size (%d bytes)", haulPath, consts.MaxDownloadBytes)
}
Comment on lines 248 to +252
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as the manifest download: the image.txt download can write beyond the intended MaxDownloadBytes cap (up to +1 byte) before returning an error, leaving an oversized temp file behind. Enforce the cap without writing past it (copy MaxDownloadBytes then check for an extra byte, and/or truncate/remove the temp file) and ensure the error message reports the original URL rather than the local haulPath temp file path.

Copilot uses AI. Check for mistakes.
}

fi, err := os.Open(haulPath)
Expand Down Expand Up @@ -296,7 +307,7 @@ func processContent(ctx context.Context, fi *os.File, o *flags.SyncOpts, s *stor
return err
}
for _, f := range cfg.Spec.Files {
if err := storeFile(ctx, s, f); err != nil {
if err := storeFile(ctx, s, f, rso.AllowInternalTargets); err != nil {
return err
}
}
Expand Down
5 changes: 5 additions & 0 deletions cmd/hauler/cli/store/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ spec:

fi := writeManifestFile(t, manifest)
o := newSyncOpts(s.Root)
o.StoreRootOpts.AllowInternalTargets = true // test server is on loopback
ro := defaultCliOpts()

if err := processContent(ctx, fi, o, s, o.StoreRootOpts, ro); err != nil {
Expand Down Expand Up @@ -216,6 +217,7 @@ spec:

fi := writeManifestFile(t, manifest)
o := newSyncOpts(s.Root)
o.StoreRootOpts.AllowInternalTargets = true // test servers are on loopback
ro := defaultCliOpts()

if err := processContent(ctx, fi, o, s, o.StoreRootOpts, ro); err != nil {
Expand Down Expand Up @@ -260,6 +262,7 @@ spec:
o := newSyncOpts(s.Root)
o.FileName = []string{manifestPath}
rso := defaultRootOpts(s.Root)
rso.AllowInternalTargets = true // test server is on loopback
ro := defaultCliOpts()

if err := SyncCmd(ctx, o, s, rso, ro); err != nil {
Expand Down Expand Up @@ -411,6 +414,7 @@ func TestSyncCmd_ImageTxt_RemoteFile(t *testing.T) {
o := newSyncOpts(s.Root)
o.ImageTxt = []string{imageSrv.URL + "/images.txt"}
rso := defaultRootOpts(s.Root)
rso.AllowInternalTargets = true // test server is on loopback
ro := defaultCliOpts()

if err := SyncCmd(ctx, o, s, rso, ro); err != nil {
Expand Down Expand Up @@ -444,6 +448,7 @@ spec:
o := newSyncOpts(s.Root)
o.FileName = []string{manifestSrv.URL + "/manifest.yaml"}
rso := defaultRootOpts(s.Root)
rso.AllowInternalTargets = true // both manifest server and file server are on loopback
ro := defaultCliOpts()

if err := SyncCmd(ctx, o, s, rso, ro); err != nil {
Expand Down
4 changes: 2 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,8 @@ if [ ! -d "${HAULER_DIR}" ]; then
mkdir -p "${HAULER_DIR}" || fatal "Failed to Create Hauler Directory: ${HAULER_DIR}"
fi

# ensure hauler directory is writable (by user or root privileges)
chmod -R 777 "${HAULER_DIR}" || fatal "Failed to Update Permissions of Hauler Directory: ${HAULER_DIR}"
# ensure hauler directory is only accessible by the owner
chmod -R 0700 "${HAULER_DIR}" || fatal "Failed to Update Permissions of Hauler Directory: ${HAULER_DIR}"
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chmod -R 0700 applies execute bits to all files under ~/.hauler (not just directories), which can unintentionally make regular files executable and also removes group read access even for non-sensitive artifacts. If the goal is “owner-only access”, prefer a mode that applies x only to directories (e.g., chmod -R go-rwx,u+rwX ...) or chmod the directory itself and set file modes more selectively.

Suggested change
chmod -R 0700 "${HAULER_DIR}" || fatal "Failed to Update Permissions of Hauler Directory: ${HAULER_DIR}"
chmod -R go-rwx,u+rwX "${HAULER_DIR}" || fatal "Failed to Update Permissions of Hauler Directory: ${HAULER_DIR}"

Copilot uses AI. Check for mistakes.

# change to hauler directory
cd "${HAULER_DIR}" || fatal "Failed to Change Directory to Hauler Directory: ${HAULER_DIR}"
Expand Down
Loading
Loading