diff --git a/cmd/cat.go b/cmd/cat.go index 24e15ff..d138de7 100644 --- a/cmd/cat.go +++ b/cmd/cat.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "os" - "strings" "github.com/meigma/blob" "github.com/spf13/cobra" @@ -55,23 +54,21 @@ func runCat(cmd *cobra.Command, args []string) error { return fmt.Errorf("accessing archive %s: %w", resolvedRef, err) } - // 6. Normalize and validate all files exist and are not directories before outputting anything - normalizedPaths := make([]string, len(filePaths)) - for i, filePath := range filePaths { - // Normalize path - strip leading slash for fs.FS compatibility - normalized := strings.TrimPrefix(filePath, "/") - if normalized == "" { - return fmt.Errorf("invalid path: %s", filePath) - } - normalizedPaths[i] = normalized - - info, err := blobArchive.Stat(normalized) - if err != nil { - return fmt.Errorf("file not found: %s", filePath) - } - if info.IsDir() { - return fmt.Errorf("cannot cat directory: %s", filePath) + // 6. Validate all files exist and are not directories before outputting anything + normalizedPaths, err := blobArchive.ValidateFiles(filePaths...) + if err != nil { + var ve *blob.ValidationError + if errors.As(err, &ve) { + switch ve.Reason { + case "is a directory": + return fmt.Errorf("cannot cat directory: %s", ve.Path) + case "not found": + return fmt.Errorf("file not found: %s", ve.Path) + default: + return fmt.Errorf("invalid path: %s: %s", ve.Path, ve.Reason) + } } + return fmt.Errorf("validating files: %w", err) } // 7. Check quiet mode - suppress output only after validation diff --git a/cmd/cp.go b/cmd/cp.go index 93c4fdb..eae5fcf 100644 --- a/cmd/cp.go +++ b/cmd/cp.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "io/fs" "os" "path/filepath" "strings" @@ -166,14 +165,11 @@ func resolveSource(ctx context.Context, src cpSource, cache map[string]*blob.Arc } // Detect if source is a file or directory - srcPath := strings.TrimPrefix(src.path, "/") - if srcPath == "" { - srcPath = "." - } - isDir, dirErr := isArchiveDir(blobArchive, srcPath) - if dirErr != nil { - return cpResolvedSource{}, fmt.Errorf("checking source %s: %w", src.path, dirErr) + srcPath := blob.NormalizePath(src.path) + if !blobArchive.Exists(srcPath) { + return cpResolvedSource{}, fmt.Errorf("path not found in archive: %s", src.path) } + isDir := blobArchive.IsDir(srcPath) return cpResolvedSource{ cpSource: src, @@ -182,29 +178,6 @@ func resolveSource(ctx context.Context, src cpSource, cache map[string]*blob.Arc }, nil } -// isArchiveDir checks if the path is a directory in the archive. -// It uses archive.Stat which synthesizes directory info from entry prefixes. -func isArchiveDir(blobArchive *blob.Archive, path string) (bool, error) { - // Handle trailing slash hint from user input - if strings.HasSuffix(path, "/") { - path = strings.TrimSuffix(path, "/") - if path == "" { - path = "." - } - } - - info, err := blobArchive.Stat(path) - if err != nil { - // Check if this is a "not exist" error - var pathErr *fs.PathError - if errors.As(err, &pathErr) && errors.Is(pathErr.Err, fs.ErrNotExist) { - return false, fmt.Errorf("path not found in archive: %s", path) - } - return false, err - } - return info.IsDir(), nil -} - // destInfo holds information about the destination path. type destInfo struct { absPath string @@ -313,10 +286,7 @@ func prepareSingleSourceDest(src cpResolvedSource, di destInfo) (string, error) // copyResolvedSource copies a resolved source to the destination. func copyResolvedSource(rsrc cpResolvedSource, destPath string, flags cpFlags, opts []blob.CopyOption, multiSource bool) (fileCount int, totalSize uint64, err error) { - srcPath := strings.TrimPrefix(rsrc.path, "/") - if srcPath == "" { - srcPath = "." - } + srcPath := blob.NormalizePath(rsrc.path) if rsrc.isDir { return copyDirectory(rsrc.archive, srcPath, rsrc.path, destPath, opts) @@ -335,71 +305,40 @@ func copyResolvedSource(rsrc cpResolvedSource, destPath string, flags cpFlags, o // copyDirectory copies a directory recursively. func copyDirectory(blobArchive *blob.Archive, srcPath, displayPath, destPath string, opts []blob.CopyOption) (fileCount int, totalSize uint64, err error) { - // Normalize path - strip trailing slash for CopyDir (fs.ValidPath rejects trailing slashes) - normalizedPath := strings.TrimSuffix(srcPath, "/") - if normalizedPath == "" { - normalizedPath = "." - } - - if err = blobArchive.CopyDir(destPath, normalizedPath, opts...); err != nil { + normalizedPath := blob.NormalizePath(srcPath) + stats, err := blobArchive.CopyDir(destPath, normalizedPath, opts...) + if err != nil { return 0, 0, fmt.Errorf("copying directory %s: %w", displayPath, err) } - - // Use normalized path for prefix matching - prefix := normalizedPath - if prefix == "." { - prefix = "" - } - - // Count files - use same prefix logic - if prefix == "" { - // Root copy - count all entries - for entry := range blobArchive.Entries() { - if !entry.Mode().IsDir() { - fileCount++ - totalSize += entry.OriginalSize() - } - } - } else { - // Prefix copy - must add trailing slash for proper matching - for entry := range blobArchive.EntriesWithPrefix(prefix + "/") { - if !entry.Mode().IsDir() { - fileCount++ - totalSize += entry.OriginalSize() - } - } - // Also check for exact file match (in case normalizedPath was both file and prefix) - if entry, ok := blobArchive.Entry(normalizedPath); ok && !entry.Mode().IsDir() { - fileCount++ - totalSize += entry.OriginalSize() - } - } - - return fileCount, totalSize, nil + return stats.FileCount, stats.TotalBytes, nil } // copyFileToDir copies a file into a directory. func copyFileToDir(blobArchive *blob.Archive, srcPath, displayPath, destPath string, opts []blob.CopyOption) (fileCount int, totalSize uint64, err error) { // Verify source exists and is a file - entry, ok := blobArchive.Entry(srcPath) - if !ok { + if !blobArchive.IsFile(srcPath) { + if blobArchive.IsDir(srcPath) { + return 0, 0, fmt.Errorf("expected file but got directory: %s", displayPath) + } return 0, 0, fmt.Errorf("file not found: %s", displayPath) } - if entry.Mode().IsDir() { - return 0, 0, fmt.Errorf("expected file but got directory: %s", displayPath) - } - if err := blobArchive.CopyToWithOptions(destPath, []string{srcPath}, opts...); err != nil { + stats, err := blobArchive.CopyToWithOptions(destPath, []string{srcPath}, opts...) + if err != nil { return 0, 0, fmt.Errorf("copying %s: %w", displayPath, err) } - return 1, entry.OriginalSize(), nil + return stats.FileCount, stats.TotalBytes, nil } // copyFileToFile copies a single file to a specific destination path. +// Uses manual implementation to control permissions (0644 default vs CopyFile's 0600). func copyFileToFile(blobArchive *blob.Archive, srcPath, displayPath, destPath string, flags cpFlags) (fileCount int, totalSize uint64, err error) { entry, ok := blobArchive.Entry(srcPath) if !ok { + if blobArchive.IsDir(srcPath) { + return 0, 0, fmt.Errorf("expected file but got directory: %s", displayPath) + } return 0, 0, fmt.Errorf("file not found: %s", displayPath) } if entry.Mode().IsDir() { diff --git a/cmd/pull.go b/cmd/pull.go index f1d0541..b04719e 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -126,7 +126,8 @@ func runPull(cmd *cobra.Command, args []string) error { blob.CopyWithPreserveMode(true), blob.CopyWithPreserveTimes(true), } - if err := blobArchive.CopyDir(destDir, ".", copyOpts...); err != nil { + copyStats, err := blobArchive.CopyDir(destDir, ".", copyOpts...) + if err != nil { return fmt.Errorf("extracting files: %w", err) } @@ -134,7 +135,8 @@ func runPull(cmd *cobra.Command, args []string) error { result := pullResult{ Ref: inputRef, Destination: destDir, - FileCount: blobArchive.Len(), + FileCount: copyStats.FileCount, + TotalSize: copyStats.TotalBytes, Verified: len(policies) > 0, } @@ -142,10 +144,6 @@ func runPull(cmd *cobra.Command, args []string) error { result.ResolvedRef = resolvedRef } - // Compute total size - for entry := range blobArchive.Entries() { - result.TotalSize += entry.OriginalSize() - } result.TotalSizeHuman = archive.FormatSize(result.TotalSize) if len(policies) > 0 { diff --git a/go.mod b/go.mod index ba90279..46f6f45 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/meigma/blob-cli go 1.25.5 require ( - github.com/meigma/blob v1.0.0 + github.com/meigma/blob v1.1.0 github.com/meigma/blob/policy/opa v0.0.0-20260121212824-972ce5f91c94 github.com/meigma/blob/policy/sigstore v0.0.0-20260121212824-972ce5f91c94 github.com/meigma/blob/policy/slsa v0.0.0-20260121212824-972ce5f91c94 diff --git a/go.sum b/go.sum index d7f0b6a..0458431 100644 --- a/go.sum +++ b/go.sum @@ -332,8 +332,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/meigma/blob v1.0.0 h1:LLc6mLB7XdRq5ONSKQddDu7lb3VIqNuAwu5pgStZg8g= -github.com/meigma/blob v1.0.0/go.mod h1:/vMKwvjRGVuTzvykLUeWRZ4yHjm02mjp3HrAa7kpLOQ= +github.com/meigma/blob v1.1.0 h1:8aAN+SmGFvm5meWNND2XoCyqyrp8zFHcJVzts4SdFBA= +github.com/meigma/blob v1.1.0/go.mod h1:/vMKwvjRGVuTzvykLUeWRZ4yHjm02mjp3HrAa7kpLOQ= github.com/meigma/blob/policy/opa v0.0.0-20260121212824-972ce5f91c94 h1:+f/FSoBWnpjGpV+HvofNBkrROUWwR9EgpB66f0zXJXs= github.com/meigma/blob/policy/opa v0.0.0-20260121212824-972ce5f91c94/go.mod h1:ZXF4/Rnbo1joGHYNZ2KRJkH7IHv4slkxFTs+ld9+Z04= github.com/meigma/blob/policy/sigstore v0.0.0-20260121212824-972ce5f91c94 h1:tGzZMNZ6w5NOE4gPEYnfCm66PRKyhHI98dpu4BILEeM= diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 144413c..543cca5 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -197,13 +197,14 @@ func SortDirsFirst(entries []*DirEntry) { // - Removes leading "/" and trailing "/" // - "foo/" becomes "foo" // - "/foo/bar/" becomes "foo/bar" +// - "foo/./bar" becomes "foo/bar" (via path.Clean) func normalizePath(p string) string { // Handle special cases if p == "" || p == "." || p == "/" { return "" } - // Clean the path and remove leading/trailing slashes + // Clean the path to handle dot segments (./ ../) p = path.Clean(p) p = strings.TrimPrefix(p, "/") p = strings.TrimSuffix(p, "/")