From 29a5c697b19241b15b3d4d19032564c53ab03f43 Mon Sep 17 00:00:00 2001 From: Zack Brady Date: Tue, 18 Aug 2026 19:42:58 -0400 Subject: [PATCH] fixed bug with store sync extraction into unwritable directory (#737) --- cmd/hauler/cli/store/sync.go | 6 ++++-- internal/flags/cli.go | 2 ++ internal/flags/store.go | 8 ++++++++ internal/mapper/filestore.go | 6 ++++-- pkg/consts/consts.go | 1 + 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/cmd/hauler/cli/store/sync.go b/cmd/hauler/cli/store/sync.go index 37a4552..2134f21 100644 --- a/cmd/hauler/cli/store/sync.go +++ b/cmd/hauler/cli/store/sync.go @@ -163,12 +163,14 @@ func SyncCmd(ctx context.Context, o *flags.SyncOpts, s *store.Layout, rso *flags if err != nil { return fmt.Errorf("failed to fetch product manifest for [%s]: %w", productName, err) } - err = ExtractCmd(ctx, &flags.ExtractOpts{StoreRootOpts: o.StoreRootOpts}, s, fmt.Sprintf("hauler/%s-manifest.yaml:%s", parts[0], tag)) + // The manifest is output for the user, so it goes to workDir, not tempDir (removed when sync returns). + workDir := flags.ResolveWorkDir(ro) + err = ExtractCmd(ctx, &flags.ExtractOpts{StoreRootOpts: o.StoreRootOpts, DestinationDir: workDir}, s, fmt.Sprintf("hauler/%s-manifest.yaml:%s", parts[0], tag)) if err != nil { return err } fileName := fmt.Sprintf("%s-manifest.yaml", parts[0]) - fi, err := os.Open(fileName) + fi, err := os.Open(filepath.Join(workDir, fileName)) if err != nil { return err } diff --git a/internal/flags/cli.go b/internal/flags/cli.go index 4bfab7f..800322a 100644 --- a/internal/flags/cli.go +++ b/internal/flags/cli.go @@ -7,6 +7,7 @@ type CliRootOpts struct { HaulerDir string IgnoreErrors bool AuditLevel string + WorkDir string } func AddRootFlags(cmd *cobra.Command, ro *CliRootOpts) { @@ -16,4 +17,5 @@ func AddRootFlags(cmd *cobra.Command, ro *CliRootOpts) { pf.StringVarP(&ro.HaulerDir, "haulerdir", "d", "", "Set the location of the hauler directory (default $HOME/.hauler)") pf.BoolVar(&ro.IgnoreErrors, "ignore-errors", false, "Warn and continue instead of failing on errors, including storing images that failed verification (defaults false)") pf.StringVar(&ro.AuditLevel, "audit-level", "", "Set the audit logging level (none, standard, verbose) (defaults standard)") + pf.StringVarP(&ro.WorkDir, "work-dir", "w", "", "(Optional) Set the directory for output that commands would otherwise write to the current directory (default: current directory)") } diff --git a/internal/flags/store.go b/internal/flags/store.go index 31a09a0..6a10793 100644 --- a/internal/flags/store.go +++ b/internal/flags/store.go @@ -144,3 +144,11 @@ func resolveHaulerDir(ro *CliRootOpts) string { home, _ := os.UserHomeDir() return filepath.Join(home, consts.DefaultHaulerDirName) } + +// ResolveWorkDir returns the configured output dir, or "" to mean the current directory (legacy behavior). +func ResolveWorkDir(ro *CliRootOpts) string { + if ro != nil && ro.WorkDir != "" { + return ro.WorkDir + } + return os.Getenv(consts.HaulerWorkDir) +} diff --git a/internal/mapper/filestore.go b/internal/mapper/filestore.go index d2c56d3..6b9a720 100644 --- a/internal/mapper/filestore.go +++ b/internal/mapper/filestore.go @@ -89,8 +89,10 @@ func (s *pusher) Push(ctx context.Context, desc ocispec.Descriptor) (ccontent.Wr } fullFileName := filepath.Join(destDir, filename) - // Guard against path traversal (e.g. filename containing "../") - if !strings.HasPrefix(fullFileName, destDir+string(filepath.Separator)) { + // Guard against path traversal (e.g. "../"). filepath.Rel handles this + // correctly even when destDir is "/", unlike a plain prefix check. + rel, err := filepath.Rel(destDir, fullFileName) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return nil, fmt.Errorf("path_traversal_disallowed: %q resolves outside destination dir", filename) } diff --git a/pkg/consts/consts.go b/pkg/consts/consts.go index 44f0eba..445e296 100644 --- a/pkg/consts/consts.go +++ b/pkg/consts/consts.go @@ -95,6 +95,7 @@ const ( // environment variables HaulerDir = "HAULER_DIR" HaulerTempDir = "HAULER_TEMP_DIR" + HaulerWorkDir = "HAULER_WORK_DIR" HaulerStoreDir = "HAULER_STORE_DIR" HaulerIgnoreErrors = "HAULER_IGNORE_ERRORS" HaulerRetries = "HAULER_RETRIES"