Skip to content

fix: hard links against content from another package - #334

Open
lczyk wants to merge 3 commits into
canonical:mainfrom
lczyk:fix/hard-link-own-package
Open

lczyk wants to merge 3 commits into
canonical:mainfrom
lczyk:fix/hard-link-own-package

Conversation

@lczyk

@lczyk lczyk commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor
  • Have you signed the CLA?

did some formal verification (fizzbee, my specs are here and here) of the cut path and found 7 minor bugs:

test-package1 ships /file and /hardlink, a hard link to it. test-package2 ships its own /file. cut the following slices:

# slices/test-package1.yaml
package: test-package1
slices:
  myslice:
    essential:
      test-package2_myslice:
    contents:
      /hardlink:
# slices/test-package2.yaml
package: test-package2
slices:
  myslice:
    contents:
      /file:
$ chisel cut --release ./ --arch amd64 --root $rootfs test-package1_myslice test-package2_myslice
Extracting files from package "test-package2"...
Extracting files from package "test-package1"...
$ cat $rootfs/hardlink
bar
$ stat -c '%i' $rootfs/file $rootfs/hardlink
214420890
214420890

/hardlink carries test-package2's content and shares its inode. test-package2 is extracted first (the essential), so its /file is on disk when test-package1's link entry is reached, and the link is made against it. with the packages the other way round the link is a copy of test-package1's own content.

repro using chisel-proxy, with two prebuilt debs (its directory packing does not keep hard links):

$ alias chisel-proxy='go run github.com/lczyk/chisel-proxy/cmd/chisel-proxy@v0.3.0'
$ chisel-proxy cut --arch amd64 ./test-package1_0.0.0_amd64.deb ./test-package2_0.0.0_amd64.deb ./test-package1.yaml ./test-package2.yaml \
    -- --root ./rootfs test-package1_myslice test-package2_myslice
Extracting files from package "test-package2"...
Extracting files from package "test-package1"...
$ cat ./rootfs/hardlink
bar

with the fix cat prints foo and the inodes differ.

fix

a hard link is only created in the first pass when its target was extracted by the same package in the same pass. otherwise the entry goes through the existing second pass, which writes the package's own content. a deferred link to a link waits for the content at the end of the chain, a deferred link to a symlink becomes that symlink, and link targets may be spelled with or without the leading ./.

A hard link entry was created with os.Link against whatever sat at the
link target path when the entry was reached, so when another package
extracted earlier provided that path, the link carried the other
package's content and shared its inode. The second pass that copies the
content of the entry's own package only ran when the target path was
missing.

A hard link is now only created when the target was extracted by the
same package in the same pass; otherwise the entry goes through the
second pass as before.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The tracking excludes same-package symlinks and earlier hard links, breaking those valid link targets.

1 open finding
What changed in this PR

This PR fixes Chisel’s package extraction so a hard link cannot pick up content from a previously extracted package.

Changes:

  • Track regular files extracted by the current package before creating a hard link.
  • Add a slice test for packages that provide the same target path.
File Description
internal/​tarball/​extract.go Restricts first-pass hard links to tracked package content.
internal/​slicer/​slicer_test.go Tests the cross-package hard-link case.

🧠 Review effort: Lite


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/tarball/extract.go Outdated
@lczyk lczyk added the Bug An undesired feature ;-) label Oct 10, 2026
Any entry extracted at its own path is a valid first pass link target,
not only regular files, so a hard link to a symlink or to another hard
link of the same package is created in the first pass as before.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Deferred link chains can produce empty files, and valid relative link targets can now be rejected.

0 open findings

1 resolved since last review
Previously missed (2)

In code that hasn't changed since last review

Medium severity Resolve deferred hard-link chains to content-bearing entries

internal/​tarball/​extract.go:240

When a selected hard link points to another hard-link entry that this package did not extract, deferring it here does not recover its content. For example, package A contains ./file (foo), ./a (link to ./file), and ./b (link to ./a); select only /b while another package already provides /a. This change correctly avoids linking /b to that other package, but the second pass reads A's ./a header, which has no payload, and writes an empty /b instead of foo. Resolve deferred link chains back to their content-bearing entry before writing the selected link.

Medium severity Normalize hard-link targets before validating extracted paths

internal/​tarball/​extract.go:244

This now validates every selected hard-link target with sanitizeTarPath, which accepts only names starting with ./. A valid tar entry can have Name: "./hardlink" and Linkname: "file"; if this package already extracted ./file, the previous code could link to it, but this change returns invalid link target file instead. Normalize safe relative link names to the same path format as tar members before checking extracted, while still rejecting targets outside the root.

🧠 Review effort: Lite

A deferred hard link whose target is itself a hard link waits for the
content at the end of the chain, resolved once the whole tarball has
been seen, and links against that content when this package extracted
it. A deferred link to a symlink becomes that symlink. Link targets are
cleaned and accepted with or without the leading "./", and the second
pass creates missing parent directories as the first one does.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug An undesired feature ;-)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants