From 9b1e02777a482ad966855832b5f9e11180bed455 Mon Sep 17 00:00:00 2001 From: lczyk Date: Sat, 10 Oct 2026 14:16:23 +0100 Subject: [PATCH 1/3] fix: hard links against content from another package 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. --- internal/slicer/slicer_test.go | 48 ++++++++++++++++++++++++++++++++++ internal/tarball/extract.go | 37 ++++++++++++++++---------- 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/internal/slicer/slicer_test.go b/internal/slicer/slicer_test.go index d6ef9ca0d..41cce9a6c 100644 --- a/internal/slicer/slicer_test.go +++ b/internal/slicer/slicer_test.go @@ -1661,6 +1661,54 @@ var slicerTests = []slicerTest{{ "/symlink": "symlink ./file <1> {test-package_myslice}", "/hardlink": "symlink ./file <1> {test-package_myslice}", }, +}, { + summary: "Hard link target path provided by another package extracted first", + slices: []setup.SliceKey{ + {"test-package2", "myslice"}, + {"test-package1", "myslice"}, + }, + pkgs: []*testutil.TestPackage{{ + Name: "test-package1", + Data: testutil.MustMakeDeb([]testutil.TarEntry{ + testutil.Dir(0755, "./"), + testutil.Reg(0644, "./file", "foo"), + testutil.Hrd(0644, "./hardlink", "./file"), + }), + }, { + Name: "test-package2", + Data: testutil.MustMakeDeb([]testutil.TarEntry{ + testutil.Dir(0755, "./"), + testutil.Reg(0644, "./file", "bar"), + }), + }}, + release: map[string]string{ + "slices/mydir/test-package1.yaml": ` + package: test-package1 + slices: + myslice: + essential: + - test-package2_myslice + contents: + /hardlink: + `, + "slices/mydir/test-package2.yaml": ` + package: test-package2 + slices: + myslice: + contents: + /file: + `, + }, + // test-package2 is extracted first, so its /file is on disk when the + // hard link is extracted. The link carries test-package1's content. + filesystem: map[string]string{ + "/file": "file 0644 fcde2b2e", + "/hardlink": "file 0644 2c26b46b", + }, + manifestPaths: map[string]string{ + "/file": "file 0644 fcde2b2e {test-package2_myslice}", + "/hardlink": "file 0644 2c26b46b {test-package1_myslice}", + }, }, { summary: "Hard link identifiers are unique across packages", slices: []setup.SliceKey{ diff --git a/internal/tarball/extract.go b/internal/tarball/extract.go index 40db2cbac..66210da32 100644 --- a/internal/tarball/extract.go +++ b/internal/tarball/extract.go @@ -120,6 +120,10 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { // before the entry for the file itself. This is the case for .deb files but // not for all tarballs. tarDirMode := make(map[string]fs.FileMode) + // Regular files extracted by this package at their own path. A hard link + // is only created against one of these: anything else at the link target + // came from another package. + extracted := make(map[string]bool) tarReader := tar.NewReader(dataReader) for { tarHeader, err := tarReader.Next() @@ -221,6 +225,21 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { } link := tarHeader.Linkname if tarHeader.Typeflag == tar.TypeLink { + relLinkPath, ok := sanitizeTarPath(tarHeader.Linkname) + if !ok { + return fmt.Errorf("invalid link target %s", tarHeader.Linkname) + } + if !extracted[relLinkPath] { + // The content was not extracted previously, so the + // hard link entry is extracted later with its own + // content. + info := pendingHardLink{ + path: targetPath, + extractInfos: extractInfos, + } + pendingHardLinks[relLinkPath] = append(pendingHardLinks[relLinkPath], info) + continue + } // A hard link requires the real path of the target file. link = filepath.Join(options.TargetDir, link) } @@ -236,22 +255,12 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { OverrideMode: true, } err := options.Create(extractInfos, createOptions) - if err != nil && os.IsNotExist(err) && tarHeader.Typeflag == tar.TypeLink { - // The hard link could not be created because the content - // was not extracted previously. Add this hard link entry - // to the pending list to extract later. - relLinkPath, ok := sanitizeTarPath(tarHeader.Linkname) - if !ok { - return fmt.Errorf("invalid link target %s", tarHeader.Linkname) - } - info := pendingHardLink{ - path: targetPath, - extractInfos: extractInfos, - } - pendingHardLinks[relLinkPath] = append(pendingHardLinks[relLinkPath], info) - } else if err != nil { + if err != nil { return err } + if tarHeader.Typeflag == tar.TypeReg && targetPath == sourcePath { + extracted[targetPath] = true + } } } From 094a45906151f5f33ee1c1845228732030b3761d Mon Sep 17 00:00:00 2001 From: lczyk Date: Sat, 10 Oct 2026 15:13:25 +0100 Subject: [PATCH 2/3] fix: hard links to symlinks and to other hard links 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. --- internal/tarball/extract.go | 6 +++--- internal/tarball/extract_test.go | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/internal/tarball/extract.go b/internal/tarball/extract.go index 66210da32..15384ce35 100644 --- a/internal/tarball/extract.go +++ b/internal/tarball/extract.go @@ -120,8 +120,8 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { // before the entry for the file itself. This is the case for .deb files but // not for all tarballs. tarDirMode := make(map[string]fs.FileMode) - // Regular files extracted by this package at their own path. A hard link - // is only created against one of these: anything else at the link target + // Entries extracted by this package at their own path. A hard link is + // only created against one of these: anything else at the link target // came from another package. extracted := make(map[string]bool) tarReader := tar.NewReader(dataReader) @@ -258,7 +258,7 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { if err != nil { return err } - if tarHeader.Typeflag == tar.TypeReg && targetPath == sourcePath { + if targetPath == sourcePath { extracted[targetPath] = true } } diff --git a/internal/tarball/extract_test.go b/internal/tarball/extract_test.go index fd5eb6147..d98100946 100644 --- a/internal/tarball/extract_test.go +++ b/internal/tarball/extract_test.go @@ -439,6 +439,27 @@ var extractTests = []extractTest{{ "/symlink": "symlink ./file <1>", }, notCreated: []string{}, +}, { + summary: "Hard link to a hard link", + pkgdata: testutil.MustMakeDeb([]testutil.TarEntry{ + testutil.Dir(0755, "./"), + testutil.Reg(0644, "./file", "foo"), + testutil.Hrd(0644, "./hardlink1", "./file"), + testutil.Hrd(0644, "./hardlink2", "./hardlink1"), + }), + options: tarball.ExtractOptions{ + Extract: map[string][]tarball.ExtractInfo{ + "/**": []tarball.ExtractInfo{{ + Path: "/**", + }}, + }, + }, + result: map[string]string{ + "/file": "file 0644 2c26b46b <1>", + "/hardlink1": "file 0644 2c26b46b <1>", + "/hardlink2": "file 0644 2c26b46b <1>", + }, + notCreated: []string{}, }, { summary: "Explicit extraction overrides existing file", pkgdata: testutil.PackageData["test-package"], From f204f9cb4e16d48a68c8a17554917e36c649e667 Mon Sep 17 00:00:00 2001 From: lczyk Date: Sat, 10 Oct 2026 15:36:19 +0100 Subject: [PATCH 3/3] fix: deferred hard links to links, to symlinks and to clean paths 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. --- internal/tarball/extract.go | 83 +++++++++++++++--- internal/tarball/extract_test.go | 144 +++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 10 deletions(-) diff --git a/internal/tarball/extract.go b/internal/tarball/extract.go index 15384ce35..7cf1a03f8 100644 --- a/internal/tarball/extract.go +++ b/internal/tarball/extract.go @@ -124,6 +124,10 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { // only created against one of these: anything else at the link target // came from another package. extracted := make(map[string]bool) + // Where each hard link entry of the tarball points, so that a link that + // could not be created can find the content at the end of a chain of + // links once the whole tarball has been seen. + linkTargets := make(map[string]string) tarReader := tar.NewReader(dataReader) for { tarHeader, err := tarReader.Next() @@ -143,6 +147,11 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { if sourceIsDir { tarDirMode[sourcePath] = tarHeader.FileInfo().Mode() } + if tarHeader.Typeflag == tar.TypeLink { + if target, ok := sanitizeLinkTarget(tarHeader.Linkname); ok { + linkTargets[sourcePath] = target + } + } // Find all globs and copies that require this source, and map them by // their target paths on disk. @@ -225,23 +234,24 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { } link := tarHeader.Linkname if tarHeader.Typeflag == tar.TypeLink { - relLinkPath, ok := sanitizeTarPath(tarHeader.Linkname) + relLinkPath, ok := sanitizeLinkTarget(tarHeader.Linkname) if !ok { return fmt.Errorf("invalid link target %s", tarHeader.Linkname) } if !extracted[relLinkPath] { // The content was not extracted previously, so the - // hard link entry is extracted later with its own - // content. + // hard link entry is dealt with once the whole + // tarball has been seen. info := pendingHardLink{ path: targetPath, + mode: tarHeader.FileInfo().Mode(), extractInfos: extractInfos, } pendingHardLinks[relLinkPath] = append(pendingHardLinks[relLinkPath], info) continue } // A hard link requires the real path of the target file. - link = filepath.Join(options.TargetDir, link) + link = filepath.Join(options.TargetDir, relLinkPath) } // Create the entry itself. @@ -264,6 +274,39 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { } } + // A pending link waits for the content at the end of its chain of links. + // When that content was extracted by this package the link is created + // against it now; otherwise the second pass extracts the content. + for linkPath, links := range pendingHardLinks { + endPath := linkPath + for i := 0; i < len(linkTargets); i++ { + next, ok := linkTargets[endPath] + if !ok { + break + } + endPath = next + } + if extracted[endPath] { + for _, link := range links { + createOptions := &fsutil.CreateOptions{ + Root: options.TargetDir, + Path: link.path, + Mode: link.mode, + Link: filepath.Join(options.TargetDir, endPath), + MakeParents: true, + } + err := options.Create(link.extractInfos, createOptions) + if err != nil { + return err + } + } + delete(pendingHardLinks, linkPath) + } else if endPath != linkPath { + delete(pendingHardLinks, linkPath) + pendingHardLinks[endPath] = append(pendingHardLinks[endPath], links...) + } + } + if len(pendingHardLinks) > 0 { // Go over the tarball again to textract the pending hard links. extractHardLinkOptions := &extractHardLinkOptions{ @@ -298,6 +341,7 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { type pendingHardLink struct { path string + mode fs.FileMode extractInfos []ExtractInfo } @@ -334,18 +378,27 @@ func extractHardLinks(pkgReader io.ReadSeeker, opts *extractHardLinkOptions) err if len(links) == 0 { continue } + if tarHeader.Typeflag == tar.TypeLink { + // Only a chain of links leading nowhere ends here. + return fmt.Errorf("cannot create hard link %s: %s is a hard link", links[0].path, sourcePath) + } // For a target path, the first hard link will be created as a file with // the content of the target path. If there are more pending hard links, // the remaining ones will be created as hard links with the newly // created file as their target. absLink := filepath.Join(opts.TargetDir, links[0].path) - // Extract the content to the first hard link path. + // Extract the content to the first hard link path. A hard link to a + // symlink becomes a symlink with the same target. createOptions := &fsutil.CreateOptions{ - Root: opts.TargetDir, - Path: links[0].path, - Mode: tarHeader.FileInfo().Mode(), - Data: tarReader, + Root: opts.TargetDir, + Path: links[0].path, + Mode: tarHeader.FileInfo().Mode(), + Data: tarReader, + MakeParents: true, + } + if tarHeader.Typeflag == tar.TypeSymlink { + createOptions.Link = tarHeader.Linkname } err = opts.Create(links[0].extractInfos, createOptions) if err != nil { @@ -359,7 +412,8 @@ func extractHardLinks(pkgReader io.ReadSeeker, opts *extractHardLinkOptions) err Path: link.path, Mode: tarHeader.FileInfo().Mode(), // Link to the first file extracted for the hard links. - Link: absLink, + Link: absLink, + MakeParents: true, } err := opts.Create(link.extractInfos, createOptions) if err != nil { @@ -397,6 +451,15 @@ func parentDirs(path string) []string { return parents } +// sanitizeLinkTarget returns the clean path a hard link entry points at, which +// the tarball may spell with or without the leading "./". +func sanitizeLinkTarget(link string) (string, bool) { + if link == "" || link[0] == '/' { + return "", false + } + return filepath.Clean("/" + strings.TrimPrefix(link, "./")), true +} + // sanitizeTarPath removes the leading "./" from the source path in the tarball, // and verifies that the path is not empty. func sanitizeTarPath(path string) (string, bool) { diff --git a/internal/tarball/extract_test.go b/internal/tarball/extract_test.go index d98100946..09f3ffac7 100644 --- a/internal/tarball/extract_test.go +++ b/internal/tarball/extract_test.go @@ -460,6 +460,150 @@ var extractTests = []extractTest{{ "/hardlink2": "file 0644 2c26b46b <1>", }, notCreated: []string{}, +}, { + summary: "Hard link to a hard link whose target is not extracted", + pkgdata: testutil.MustMakeDeb([]testutil.TarEntry{ + testutil.Dir(0755, "./"), + testutil.Reg(0644, "./file", "foo"), + testutil.Hrd(0644, "./hardlink1", "./file"), + testutil.Hrd(0644, "./hardlink2", "./hardlink1"), + }), + options: tarball.ExtractOptions{ + Extract: map[string][]tarball.ExtractInfo{ + "/hardlink1": []tarball.ExtractInfo{{ + Path: "/hardlink1", + }}, + "/hardlink2": []tarball.ExtractInfo{{ + Path: "/hardlink2", + }}, + }, + }, + result: map[string]string{ + "/hardlink1": "file 0644 2c26b46b <1>", + "/hardlink2": "file 0644 2c26b46b <1>", + }, + notCreated: []string{}, +}, { + summary: "Hard link target spelled without the leading dot slash", + pkgdata: testutil.MustMakeDeb([]testutil.TarEntry{ + testutil.Dir(0755, "./"), + testutil.Reg(0644, "./file", "foo"), + testutil.Hrd(0644, "./hardlink", "file"), + }), + options: tarball.ExtractOptions{ + Extract: map[string][]tarball.ExtractInfo{ + "/**": []tarball.ExtractInfo{{ + Path: "/**", + }}, + }, + }, + result: map[string]string{ + "/file": "file 0644 2c26b46b <1>", + "/hardlink": "file 0644 2c26b46b <1>", + }, + notCreated: []string{}, +}, { + summary: "Hard link to a symlink that is not extracted", + pkgdata: testutil.MustMakeDeb([]testutil.TarEntry{ + testutil.Dir(0755, "./"), + testutil.Lnk(0644, "./symlink", "./file"), + testutil.Hrd(0644, "./hardlink", "./symlink"), + }), + options: tarball.ExtractOptions{ + Extract: map[string][]tarball.ExtractInfo{ + "/hardlink": []tarball.ExtractInfo{{ + Path: "/hardlink", + }}, + }, + }, + result: map[string]string{ + "/hardlink": "symlink ./file", + }, + notCreated: []string{}, +}, { + summary: "Hard link to a hard link listed before its target link", + pkgdata: testutil.MustMakeDeb([]testutil.TarEntry{ + testutil.Dir(0755, "./"), + testutil.Reg(0644, "./file", "foo"), + testutil.Hrd(0644, "./hardlink2", "./hardlink1"), + testutil.Hrd(0644, "./hardlink1", "./file"), + }), + options: tarball.ExtractOptions{ + Extract: map[string][]tarball.ExtractInfo{ + "/hardlink2": []tarball.ExtractInfo{{ + Path: "/hardlink2", + }}, + }, + }, + result: map[string]string{ + "/hardlink2": "file 0644 2c26b46b", + }, + notCreated: []string{}, +}, { + summary: "Hard link to a hard link whose target is extracted links to the target", + pkgdata: testutil.MustMakeDeb([]testutil.TarEntry{ + testutil.Dir(0755, "./"), + testutil.Reg(0644, "./file", "foo"), + testutil.Hrd(0644, "./hardlink1", "./file"), + testutil.Hrd(0644, "./hardlink2", "./hardlink1"), + }), + options: tarball.ExtractOptions{ + Extract: map[string][]tarball.ExtractInfo{ + "/file": []tarball.ExtractInfo{{ + Path: "/file", + }}, + "/hardlink2": []tarball.ExtractInfo{{ + Path: "/hardlink2", + }}, + }, + }, + result: map[string]string{ + "/file": "file 0644 2c26b46b <1>", + "/hardlink2": "file 0644 2c26b46b <1>", + }, + notCreated: []string{}, +}, { + summary: "Hard link target spelled with a non-canonical path", + pkgdata: testutil.MustMakeDeb([]testutil.TarEntry{ + testutil.Dir(0755, "./"), + testutil.Dir(0755, "./dir/"), + testutil.Reg(0644, "./dir/file", "foo"), + testutil.Hrd(0644, "./hardlink1", "./dir//file"), + testutil.Hrd(0644, "./hardlink2", "./dir/../dir/file"), + }), + options: tarball.ExtractOptions{ + Extract: map[string][]tarball.ExtractInfo{ + "/**": []tarball.ExtractInfo{{ + Path: "/**", + }}, + }, + }, + result: map[string]string{ + "/dir/": "dir 0755", + "/dir/file": "file 0644 2c26b46b <1>", + "/hardlink1": "file 0644 2c26b46b <1>", + "/hardlink2": "file 0644 2c26b46b <1>", + }, + notCreated: []string{}, +}, { + summary: "Deferred hard link under a directory the tarball does not carry", + pkgdata: testutil.MustMakeDeb([]testutil.TarEntry{ + testutil.Dir(0755, "./"), + testutil.Reg(0644, "./file", "foo"), + testutil.Hrd(0644, "./dir/hardlink", "./file"), + }), + options: tarball.ExtractOptions{ + Extract: map[string][]tarball.ExtractInfo{ + "/dir/hardlink": []tarball.ExtractInfo{{ + Path: "/dir/hardlink", + }}, + }, + }, + result: map[string]string{ + "/dir/": "dir 0755", + "/dir/hardlink": "file 0644 2c26b46b", + }, + notCreated: []string{"/dir/"}, }, { summary: "Explicit extraction overrides existing file", pkgdata: testutil.PackageData["test-package"],