Skip to content

fix(file): resolve tar hard link targets from the archive root - #1510

Merged
TerryHowe merged 2 commits into
oras-project:mainfrom
jadhavgaurav:fix/hardlink-linkname-root-1499
Oct 8, 2026
Merged

TerryHowe merged 2 commits into
oras-project:mainfrom
jadhavgaurav:fix/hardlink-linkname-root-1499

Conversation

@jadhavgaurav

Copy link
Copy Markdown
Contributor

What

A tar hard link's Linkname is a path from the archive root, the same convention as the entry Name (GNU tar and bsdtar both write it that way, and tar tvf reports data/a/OWNERS link to data/b/OWNERS). extractTarDirectory resolved it against the directory of the link instead. A hard link whose target is in a different directory therefore looked for data/b/data/a/file and the whole unpack failed with no such file or directory.

The relative join came from the fix for GHSA-fxhp-mv3v-67qp. That fix was right to stop handing the raw relative path to os.Link, which resolves against the process CWD, but it reused the symlink base. For hard links the base is the extraction root.

Fixes #1499

Change

extractHardLink resolves Linkname with resolveRelToBase(dirPath, dirName, linkname), the same call used for entry names, and joins the result onto dirPath. Symlink handling is unchanged.

The containment checks are kept and one is added:

  • resolveRelToBase rejects a target outside the root (.. components, a root-relative path that leaves dirName, an absolute path outside dirPath) and a target reached through a symlinked directory.
  • checkSymlinkEscape runs on the resolved target, as it does for entry paths.
  • The target must exist and must not itself be a symlink. Both checks above only look at the ancestors of the target, and link(2) may follow a symlink target on some platforms, so a pre-existing symlink in the extraction directory could otherwise be used to hard link a file from outside. A missing target now returns an error naming the Linkname.

An absolute Linkname inside dirPath is still accepted, as before.

Behavior note

A hard link Linkname written relative to the link's own directory (for example test.txt for a link in the same directory) was accepted before and is now read as an archive root path, so it is rejected unless it names a file at that root path. That form is not what tar tools produce, and it was the bug.

Tests

utils_test.go:

  • cross-directory hard link using the archive root path (the reporter's case)
  • same directory, nested directories in both directions, .. components that stay inside the root, absolute path inside the root
  • missing target fails and creates no link
  • rejected, with no link created: .. out of the root (also from a nested directory), ../outside.txt, absolute path outside the root, through a symlinked directory (relative and absolute symlink target), and a final component that is a symlink to a file outside the root (relative and absolute)

file_test.go: TestStore_Dir_Push_HardLinkAcrossDirectories pushes the layer from the issue through Store.Push and checks data/b/file is a hard link to data/a/file.

Reverting only the body of extractHardLink to the old resolution makes the four positive unit tests and the store test fail, with the error from the issue. Removing the symlink-target check makes the two symlink-target escape cases fail.

Checks

make test equivalent (go test ./... -race with coverage, plus the encoding check) passes. go build ./..., go vet ./... and gofmt -l are clean. extractHardLink is at 91.7% coverage. golangci-lint is not installed locally.

The issue says v2.6.2 is affected too. I can send a backport to v2 if you want one.

A tar hard link's Linkname is a path from the archive root, the same
convention as the entry Name. extractTarDirectory resolved it against the
directory of the link, so a hard link whose target sits in another
directory failed to extract with "no such file or directory". The join
came from the fix for GHSA-fxhp-mv3v-67qp, which was right to stop
passing the raw relative path to os.Link but picked the symlink base.

Resolve the target with resolveRelToBase against the extraction root, the
same way entry names are resolved. The containment checks stay: the
target must be inside the root, the symlink-escape check still runs on
it, and a target that is itself a symlink is refused because link(2) may
follow it on some platforms.

Signed-off-by: Gaurav Jadhav <gaurav.vjadhav01@gmail.com>
@codecov

codecov Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codecov Report

โŒ Patch coverage is 92.85714% with 1 line in your changes missing coverage. Please review.
โœ… Project coverage is 91.57%. Comparing base (81fcfca) to head (2d61d93).

Files with missing lines Patch % Lines
content/file/utils.go 92.85% 1 Missing โš ๏ธ
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1510      +/-   ##
==========================================
+ Coverage   91.50%   91.57%   +0.07%     
==========================================
  Files         102      102              
  Lines        8099     8108       +9     
==========================================
+ Hits         7411     7425      +14     
+ Misses        677      672       -5     
  Partials       11       11              

โ˜” View full report in Codecov by Harness.
๐Ÿ“ข Have feedback on the report? Share it here.

๐Ÿš€ New features to boost your workflow:
  • โ„๏ธ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TerryHowe TerryHowe left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

/lgtm

@TerryHowe
TerryHowe merged commit ae73b33 into oras-project:main Oct 8, 2026
10 checks passed
@TerryHowe

Copy link
Copy Markdown
Member

Thanks!

TerryHowe added a commit that referenced this pull request Oct 14, 2026
#1572)

A tar hard link's Linkname is a path from the archive root, the same
convention as the entry Name. extractTarDirectory resolved it against the
directory of the link, so a hard link whose target sits in another
directory failed to extract with "no such file or directory". The join
came from the fix for GHSA-fxhp-mv3v-67qp, which was right to stop
passing the raw relative path to os.Link but picked the symlink base.

Resolve the target with resolveRelToBase against the extraction root, the
same way entry names are resolved. The containment checks stay: the
target must be inside the root, the symlink-escape check still runs on
it, and a target that is itself a symlink is refused because link(2) may
follow it on some platforms.



(cherry picked from commit ae73b33)

Signed-off-by: Gaurav Jadhav <gaurav.vjadhav01@gmail.com>
Signed-off-by: Terry Howe <terrylhowe@gmail.com>
Co-authored-by: Gaurav Vijay Jadhav <gaurav.vjadhav01@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

file store: tar hardlink Linkname resolved relative to the link's directory instead of the archive root

2 participants