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
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Status bar "toggle / view" hint in split-pane mode now tracks the correct pane's cursor; previously it read the top pane's cursor position even when the bottom pane had focus.
- File viewer (`readFirstFileFromTar`) now reads up to the full `MaxViewSize` limit regardless of the tar entry's declared size, consistent with the save-file path. A crafted archive with an understated size header no longer silently truncates the viewed content.
- `layerx build` iidfile setup now surfaces a removal error instead of silently discarding it; on Windows a failed removal no longer leaves a zero-byte file that causes the engine to report an empty image ID.
- `ErrNoEngineFound` now implements `Unwrap()`, consistent with all other error types in the package.
- `FormatBytes` now delegates to the internal `formatUnsignedBytes` helper, removing ~15 lines of duplicated formatting logic.
- CLI error message for `--engine podman` now mentions `DOCKER_HOST` alongside `CONTAINER_HOST`, matching the hint in the library error.
- Usage synopsis now uses `IMAGE_OR_ARCHIVE` consistently where both image references and local archives are accepted.
- `layerx compare` now uses the pre-computed stacked trees from the analysis for efficiency scoring, consistent with every other caller. Previously it re-stacked from raw layers, which could produce mismatched efficiency and file-diff results when the analysis carried custom stacked trees (e.g. from a cache path).
Expand All @@ -23,6 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The file-permission display in the layer browser now shows setuid (`s`/`S`),
setgid (`s`/`S`), and sticky (`t`/`T`) bits. Previously, a file with mode
`04755` was shown as `-rwxr-xr-x` instead of `-rwsr-xr-x`.
- `FormatBytes` now returns a sign-prefixed string for negative inputs instead of silently wrapping to a large positive value via an unchecked `uint64` cast.
- Daemon file-viewer (`DockerExtractor.Extract`) now correctly marks the returned `FileContent` as truncated when the extracted file exceeds the 1 MB view limit. Previously the flag was always false for daemon-extracted files, suppressing the truncation notice in the viewer.
- `ErrNoEngineFound.Cause` field and its `Unwrap()` method removed; neither construction site ever populated the field, so `errors.Unwrap` always returned `nil`, making the method misleading.

## [v1.6.1] - 2026-08-08

Expand Down
2 changes: 0 additions & 2 deletions image/compare_regression_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package image

// Regression tests for confirmed compare bugs.
// See internal-docs/compare-audit.md for full analysis.
//
// All tests use in-process fixtures only — no Docker required.

import (
Expand Down
3 changes: 0 additions & 3 deletions image/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,16 +134,13 @@ func (e *ErrPodmanSocketNotSet) Error() string {

type ErrNoEngineFound struct {
Tried []string
Cause error
}

func (e *ErrNoEngineFound) Error() string {
return fmt.Sprintf("no container engine found; tried: %s",
strings.Join(e.Tried, ", "))
}

func (e *ErrNoEngineFound) Unwrap() error { return e.Cause }

// ErrPlatformInvalid is returned when --platform cannot be parsed at all
// (empty component, too many slashes). Distinct from ErrPlatformNotInImage:
// the spec itself is malformed, no image lookup happened.
Expand Down
28 changes: 21 additions & 7 deletions image/extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,13 @@ func (e *DockerExtractor) Extract(ctx context.Context, imageRef string, filePath

totalSize := copyResult.Stat.Size

data, err := readFirstFileFromTar(copyResult.Content)
data, declaredSize, err := readFirstFileFromTar(copyResult.Content)
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", filePath, err)
}
if declaredSize > totalSize {
totalSize = declaredSize
}

return processContent(filePath, data, totalSize), nil
}
Expand Down Expand Up @@ -178,19 +181,23 @@ func (e *DockerExtractor) ExtractRaw(ctx context.Context, imageRef string, fileP
return readFullFileFromTar(copyResult.Content)
}

// readFirstFileFromTar reads the first regular file from a tar stream.
// readFirstFileFromTar reads the first regular file from a tar stream,
// returning the (possibly truncated) data and the declared file size from the
// tar header. The declared size lets the caller set FileContent.Truncated when
// the stream exceeds MaxViewSize — without it, processContent always sees
// len(data) <= MaxViewSize and silently suppresses the truncated notice.
// Docker's CopyFromContainer wraps the file in a single-entry tar.
// Non-regular entries (directories, symlinks, hardlinks, devices, fifos) are
// skipped — the contract is "read the first *regular file* in the stream".
func readFirstFileFromTar(r io.Reader) ([]byte, error) {
func readFirstFileFromTar(r io.Reader) ([]byte, int64, error) {
tr := tar.NewReader(r)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
return nil, fmt.Errorf("no file found in tar stream")
return nil, 0, fmt.Errorf("no file found in tar stream")
}
if err != nil {
return nil, err
return nil, 0, err
}
if hdr.Typeflag != tar.TypeReg {
continue
Expand All @@ -201,12 +208,19 @@ func readFirstFileFromTar(r io.Reader) ([]byte, error) {
limit := int64(MaxViewSize + 1)
data, err := io.ReadAll(io.LimitReader(tr, limit))
if err != nil {
return nil, err
return nil, 0, err
}
// Return the declared size from the header so the caller can set
// Truncated correctly. If the stream was larger than the header claimed,
// use the actual bytes read as the size floor.
declaredSize := hdr.Size
if int64(len(data)) > declaredSize {
declaredSize = int64(len(data))
}
if int64(len(data)) > MaxViewSize {
data = data[:MaxViewSize]
}
return data, nil
return data, declaredSize, nil
}
}

Expand Down
5 changes: 4 additions & 1 deletion image/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ func formatUnsignedBytes(b uint64) string {
}

func FormatBytes(b int64) string {
return formatUnsignedBytes(uint64(b)) //nolint:gosec // callers always pass non-negative sizes
if b < 0 {
return "-" + formatUnsignedBytes(uint64(-b))
}
return formatUnsignedBytes(uint64(b))
}

// FormatSignedBytes formats b like FormatBytes but with an explicit sign
Expand Down
Loading