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..7cf1a03f8 100644 --- a/internal/tarball/extract.go +++ b/internal/tarball/extract.go @@ -120,6 +120,14 @@ 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) + // 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) + // 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() @@ -139,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. @@ -221,8 +234,24 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { } link := tarHeader.Linkname if tarHeader.Typeflag == tar.TypeLink { + 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 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. @@ -236,22 +265,45 @@ 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) + if err != nil { + return err + } + if targetPath == sourcePath { + extracted[targetPath] = true + } + } + } + + // 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, } - info := pendingHardLink{ - path: targetPath, - extractInfos: extractInfos, + err := options.Create(link.extractInfos, createOptions) + if err != nil { + return err } - pendingHardLinks[relLinkPath] = append(pendingHardLinks[relLinkPath], info) - } else if err != nil { - return err } + delete(pendingHardLinks, linkPath) + } else if endPath != linkPath { + delete(pendingHardLinks, linkPath) + pendingHardLinks[endPath] = append(pendingHardLinks[endPath], links...) } } @@ -289,6 +341,7 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { type pendingHardLink struct { path string + mode fs.FileMode extractInfos []ExtractInfo } @@ -325,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 { @@ -350,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 { @@ -388,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 fd5eb6147..09f3ffac7 100644 --- a/internal/tarball/extract_test.go +++ b/internal/tarball/extract_test.go @@ -439,6 +439,171 @@ 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: "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"],