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
31 changes: 14 additions & 17 deletions cmd/cat.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"fmt"
"io"
"os"
"strings"

"github.com/meigma/blob"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -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
Expand Down
101 changes: 20 additions & 81 deletions cmd/cp.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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() {
Expand Down
10 changes: 4 additions & 6 deletions cmd/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,26 +126,24 @@ 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)
}

// 10. Build result
result := pullResult{
Ref: inputRef,
Destination: destDir,
FileCount: blobArchive.Len(),
FileCount: copyStats.FileCount,
TotalSize: copyStats.TotalBytes,
Verified: len(policies) > 0,
}

if inputRef != resolvedRef {
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 {
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
3 changes: 2 additions & 1 deletion internal/archive/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "/")
Expand Down