diff --git a/internal/deb/extract.go b/internal/deb/extract.go index 07f219bd..406c1991 100644 --- a/internal/deb/extract.go +++ b/internal/deb/extract.go @@ -246,11 +246,17 @@ func extractData(dataReader io.Reader, options *ExtractOptions) error { } } // Create the entry itself. + link := tarHeader.Linkname + if tarHeader.Typeflag == tar.TypeLink { + // A hard link requires the real path of the target file. + link = filepath.Join(options.TargetDir, link) + } + createOptions := &fsutil.CreateOptions{ Path: filepath.Join(options.TargetDir, targetPath), Mode: tarHeader.FileInfo().Mode(), Data: pathReader, - Link: tarHeader.Linkname, + Link: link, MakeParents: true, } err := options.Create(extractInfos, createOptions) diff --git a/internal/deb/extract_test.go b/internal/deb/extract_test.go index 22a1fd18..1e78f641 100644 --- a/internal/deb/extract_test.go +++ b/internal/deb/extract_test.go @@ -352,6 +352,62 @@ var extractTests = []extractTest{{ }, }, error: `cannot extract from package "test-package": path /dir/ requested twice with diverging mode: 0777 != 0000`, +}, { + summary: "Dangling hard link", + pkgdata: testutil.MustMakeDeb([]testutil.TarEntry{ + testutil.Dir(0755, "./"), + testutil.Hln(0644, "./link", "./non-existing-target"), + }), + options: deb.ExtractOptions{ + Extract: map[string][]deb.ExtractInfo{ + "/**": []deb.ExtractInfo{{ + Path: "/**", + }}, + }, + }, + error: `cannot extract from package "test-package": link target does not exist: \/[^ ]*\/non-existing-target`, +}, { + summary: "Hard link to symlink does not follow symlink", + pkgdata: testutil.MustMakeDeb([]testutil.TarEntry{ + testutil.Dir(0755, "./"), + testutil.Lnk(0644, "./symlink", "./file"), + testutil.Hln(0644, "./hardlink", "./symlink"), + }), + options: deb.ExtractOptions{ + Extract: map[string][]deb.ExtractInfo{ + "/**": []deb.ExtractInfo{{ + Path: "/**", + }}, + }, + }, + result: map[string]string{ + "/hardlink": "symlink ./file", + "/symlink": "symlink ./file", + }, + notCreated: []string{}, +}, { + summary: "Extract all types of files", + pkgdata: testutil.MustMakeDeb([]testutil.TarEntry{ + testutil.Dir(0755, "./"), + testutil.Dir(0755, "./dir/"), + testutil.Reg(0644, "./dir/file", "text for file"), + testutil.Lnk(0644, "./symlink", "./dir/file"), + testutil.Hln(0644, "./hardlink", "./dir/file"), + }), + options: deb.ExtractOptions{ + Extract: map[string][]deb.ExtractInfo{ + "/**": []deb.ExtractInfo{{ + Path: "/**", + }}, + }, + }, + result: map[string]string{ + "/dir/": "dir 0755", + "/dir/file": "file 0644 28121945", + "/hardlink": "file 0644 28121945", + "/symlink": "symlink ./dir/file", + }, + notCreated: []string{}, }} func (s *S) TestExtract(c *C) { diff --git a/internal/fsutil/create.go b/internal/fsutil/create.go index 48b12cb7..06ab8e1d 100644 --- a/internal/fsutil/create.go +++ b/internal/fsutil/create.go @@ -15,6 +15,9 @@ type CreateOptions struct { Path string Mode fs.FileMode Data io.Reader + // If Link is set and the symlink flag is set in Mode, a symlink is + // created. If the Mode is not set to symlink, a hard link is created + // instead. Link string // If MakeParents is true, missing parent directories of Path are // created with permissions 0755. @@ -48,8 +51,14 @@ func Create(options *CreateOptions) (*Entry, error) { switch o.Mode & fs.ModeType { case 0: - err = createFile(o) - hash = hex.EncodeToString(rp.h.Sum(nil)) + if o.Link != "" { + // Creating the hard link does not involve reading the file. + // Therefore, its size and hash is not calculated here. + err = createHardLink(o) + } else { + err = createFile(o) + hash = hex.EncodeToString(rp.h.Sum(nil)) + } case fs.ModeDir: err = createDir(o) case fs.ModeSymlink: @@ -121,6 +130,28 @@ func createSymlink(o *CreateOptions) error { return os.Symlink(o.Link, o.Path) } +func createHardLink(o *CreateOptions) error { + debugf("Creating hard link: %s => %s", o.Path, o.Link) + linkInfo, err := os.Lstat(o.Link) + if err != nil && os.IsNotExist(err) { + return fmt.Errorf("link target does not exist: %s", o.Link) + } else if err != nil { + return err + } + + pathInfo, err := os.Lstat(o.Path) + if err == nil || os.IsExist(err) { + if os.SameFile(linkInfo, pathInfo) { + return nil + } + return fmt.Errorf("path %s already exists", o.Path) + } else if !os.IsNotExist(err) { + return err + } + + return os.Link(o.Link, o.Path) +} + // readerProxy implements the io.Reader interface proxying the calls to its // inner io.Reader. On each read, the proxy keeps track of the file size and hash. type readerProxy struct { diff --git a/internal/fsutil/create_test.go b/internal/fsutil/create_test.go index 7288d878..17fdd79c 100644 --- a/internal/fsutil/create_test.go +++ b/internal/fsutil/create_test.go @@ -15,13 +15,15 @@ import ( ) type createTest struct { + summary string options fsutil.CreateOptions - hackdir func(c *C, dir string) + hackopt func(c *C, targetDir string, options *fsutil.CreateOptions) result map[string]string error string } var createTests = []createTest{{ + summary: "Create a file and its parent directory", options: fsutil.CreateOptions{ Path: "foo/bar", Data: bytes.NewBufferString("data1"), @@ -33,6 +35,7 @@ var createTests = []createTest{{ "/foo/bar": "file 0444 5b41362b", }, }, { + summary: "Create a symlink", options: fsutil.CreateOptions{ Path: "foo/bar", Link: "../baz", @@ -44,6 +47,7 @@ var createTests = []createTest{{ "/foo/bar": "symlink ../baz", }, }, { + summary: "Create a directory", options: fsutil.CreateOptions{ Path: "foo/bar", Mode: fs.ModeDir | 0444, @@ -54,6 +58,7 @@ var createTests = []createTest{{ "/foo/bar/": "dir 0444", }, }, { + summary: "Create a directory with sticky bit", options: fsutil.CreateOptions{ Path: "tmp", Mode: fs.ModeDir | fs.ModeSticky | 0775, @@ -62,37 +67,101 @@ var createTests = []createTest{{ "/tmp/": "dir 01775", }, }, { + summary: "Cannot create a parent directory without MakeParents set", options: fsutil.CreateOptions{ Path: "foo/bar", Mode: fs.ModeDir | 0775, }, - error: `.*: no such file or directory`, + error: `mkdir \/[^ ]*\/foo/bar: no such file or directory`, }, { + summary: "Re-creating an existing directory keeps the original mode", options: fsutil.CreateOptions{ Path: "foo", Mode: fs.ModeDir | 0775, }, - hackdir: func(c *C, dir string) { - c.Assert(os.Mkdir(filepath.Join(dir, "foo/"), fs.ModeDir|0765), IsNil) + hackopt: func(c *C, targetDir string, options *fsutil.CreateOptions) { + c.Assert(os.Mkdir(filepath.Join(targetDir, "foo/"), fs.ModeDir|0765), IsNil) }, result: map[string]string{ // mode is not updated. "/foo/": "dir 0765", }, }, { + summary: "Re-creating an existing file keeps the original mode", options: fsutil.CreateOptions{ Path: "foo", // Mode should be ignored for existing entry. Mode: 0644, Data: bytes.NewBufferString("changed"), }, - hackdir: func(c *C, dir string) { - c.Assert(os.WriteFile(filepath.Join(dir, "foo"), []byte("data"), 0666), IsNil) + hackopt: func(c *C, targetDir string, options *fsutil.CreateOptions) { + c.Assert(os.WriteFile(filepath.Join(targetDir, "foo"), []byte("data"), 0666), IsNil) }, result: map[string]string{ // mode is not updated. "/foo": "file 0666 d67e2e94", }, +}, { + summary: "Create a hard link", + options: fsutil.CreateOptions{ + Path: "dir/hardlink", + Link: "file", + Mode: 0644, + MakeParents: true, + }, + hackopt: func(c *C, targetDir string, options *fsutil.CreateOptions) { + c.Assert(os.WriteFile(filepath.Join(targetDir, "file"), []byte("data"), 0644), IsNil) + // An absolute path is required to create a hard link. + options.Link = filepath.Join(targetDir, options.Link) + }, + result: map[string]string{ + "/file": "file 0644 3a6eb079", + "/dir/": "dir 0755", + "/dir/hardlink": "file 0644 3a6eb079", + }, +}, { + summary: "Cannot create a hard link if the link target does not exist", + options: fsutil.CreateOptions{ + Path: "dir/hardlink", + Link: "missing-file", + Mode: 0644, + MakeParents: true, + }, + hackopt: func(c *C, targetDir string, options *fsutil.CreateOptions) { + options.Link = filepath.Join(targetDir, options.Link) + }, + error: `link target does not exist: \/[^ ]*\/missing-file`, +}, { + summary: "Re-creating a duplicated hard link keeps the original link", + options: fsutil.CreateOptions{ + Path: "hardlink", + Link: "file", + Mode: 0644, + MakeParents: true, + }, + hackopt: func(c *C, targetDir string, options *fsutil.CreateOptions) { + c.Assert(os.WriteFile(filepath.Join(targetDir, "file"), []byte("data"), 0644), IsNil) + c.Assert(os.Link(filepath.Join(targetDir, "file"), filepath.Join(targetDir, "hardlink")), IsNil) + options.Link = filepath.Join(targetDir, options.Link) + }, + result: map[string]string{ + "/file": "file 0644 3a6eb079", + "/hardlink": "file 0644 3a6eb079", + }, +}, { + summary: "Cannot create a hard link if the link path exists and it is not a hard link to the target", + options: fsutil.CreateOptions{ + Path: "hardlink", + Link: "file", + Mode: 0644, + MakeParents: true, + }, + hackopt: func(c *C, targetDir string, options *fsutil.CreateOptions) { + c.Assert(os.WriteFile(filepath.Join(targetDir, "file"), []byte("data"), 0644), IsNil) + c.Assert(os.WriteFile(filepath.Join(targetDir, "hardlink"), []byte("data"), 0644), IsNil) + options.Link = filepath.Join(targetDir, options.Link) + }, + error: `path \/[^ ]*\/hardlink already exists`, }} func (s *S) TestCreate(c *C) { @@ -102,17 +171,18 @@ func (s *S) TestCreate(c *C) { }() for _, test := range createTests { + c.Logf("Test: %s", test.summary) if test.result == nil { // Empty map for no files created. test.result = make(map[string]string) } c.Logf("Options: %v", test.options) dir := c.MkDir() - if test.hackdir != nil { - test.hackdir(c, dir) - } options := test.options options.Path = filepath.Join(dir, options.Path) + if test.hackopt != nil { + test.hackopt(c, dir, &options) + } entry, err := fsutil.Create(&options) if test.error != "" { @@ -122,14 +192,24 @@ func (s *S) TestCreate(c *C) { c.Assert(err, IsNil) c.Assert(testutil.TreeDump(dir), DeepEquals, test.result) + // [fsutil.Create] does not return information about parent directories // created implicitly. We only check for the requested path. - entry.Path = strings.TrimPrefix(entry.Path, dir) - // Add the slashes that TreeDump adds to the path. - slashPath := "/" + test.options.Path - if test.options.Mode.IsDir() { - slashPath = slashPath + "/" + if entry.Link != "" && entry.Mode&fs.ModeSymlink == 0 { + // Entry is a hard link. + pathInfo, err := os.Lstat(entry.Path) + c.Assert(err, IsNil) + linkInfo, err := os.Lstat(entry.Link) + c.Assert(err, IsNil) + os.SameFile(pathInfo, linkInfo) + } else { + entry.Path = strings.TrimPrefix(entry.Path, dir) + // Add the slashes that TreeDump adds to the path. + slashPath := "/" + test.options.Path + if test.options.Mode.IsDir() { + slashPath = slashPath + "/" + } + c.Assert(testutil.TreeDumpEntry(entry), DeepEquals, test.result[slashPath]) } - c.Assert(testutil.TreeDumpEntry(entry), DeepEquals, test.result[slashPath]) } } diff --git a/internal/slicer/slicer_test.go b/internal/slicer/slicer_test.go index 4f9dee55..cd40f3ce 100644 --- a/internal/slicer/slicer_test.go +++ b/internal/slicer/slicer_test.go @@ -1051,6 +1051,42 @@ var slicerTests = []slicerTest{{ content.list("/foo-bar/") `, }, +}, { + summary: "Valid hard link in two slices in the same package", + slices: []setup.SliceKey{ + {"test-package", "slice1"}, + {"test-package", "slice2"}}, + pkgs: map[string][]byte{ + "test-package": testutil.MustMakeDeb([]testutil.TarEntry{ + testutil.Dir(0755, "./"), + testutil.Dir(0755, "./dir/"), + testutil.Reg(0644, "./dir/file", "text for file"), + testutil.Hln(0644, "./hardlink", "./dir/file"), + }), + }, + release: map[string]string{ + "slices/mydir/test-package.yaml": ` + package: test-package + slices: + slice1: + contents: + /dir/file: + /hardlink: + slice2: + contents: + /dir/file: + /hardlink: + `, + }, + filesystem: map[string]string{ + "/dir/": "dir 0755", + "/dir/file": "file 0644 28121945", + "/hardlink": "file 0644 28121945", + }, + report: map[string]string{ + "/dir/file": "file 0644 28121945 {test-package_slice1,test-package_slice2}", + "/hardlink": "hardlink /dir/file {test-package_slice1,test-package_slice2}", + }, }} var defaultChiselYaml = ` @@ -1196,13 +1232,20 @@ func treeDumpReport(report *slicer.Report) map[string]string { fsDump = fmt.Sprintf("dir %#o", fperm) case fs.ModeSymlink: fsDump = fmt.Sprintf("symlink %s", entry.Link) - case 0: // Regular - if entry.Size == 0 { - fsDump = fmt.Sprintf("file %#o empty", entry.Mode.Perm()) - } else if entry.FinalHash != "" { - fsDump = fmt.Sprintf("file %#o %s %s", fperm, entry.Hash[:8], entry.FinalHash[:8]) + case 0: + if entry.Link != "" { + // Hard link. + relLink := filepath.Clean("/" + strings.TrimPrefix(entry.Link, report.Root)) + fsDump = fmt.Sprintf("hardlink %s", relLink) } else { - fsDump = fmt.Sprintf("file %#o %s", fperm, entry.Hash[:8]) + // Regular file. + if entry.Size == 0 { + fsDump = fmt.Sprintf("file %#o empty", entry.Mode.Perm()) + } else if entry.FinalHash != "" { + fsDump = fmt.Sprintf("file %#o %s %s", fperm, entry.Hash[:8], entry.FinalHash[:8]) + } else { + fsDump = fmt.Sprintf("file %#o %s", fperm, entry.Hash[:8]) + } } default: panic(fmt.Errorf("unknown file type %d: %s", entry.Mode.Type(), entry.Path)) diff --git a/internal/testutil/pkgdata.go b/internal/testutil/pkgdata.go index 11bc9028..d151cd6c 100644 --- a/internal/testutil/pkgdata.go +++ b/internal/testutil/pkgdata.go @@ -197,3 +197,16 @@ func Lnk(mode int64, path, target string) TarEntry { }, } } + +// Hln is a shortcut for creating a hard link TarEntry structure (with +// tar.Typeflag set to tar.TypeLink). Hln stands for "Hard LiNk". +func Hln(mode int64, path, target string) TarEntry { + return TarEntry{ + Header: tar.Header{ + Typeflag: tar.TypeLink, + Name: path, + Mode: mode, + Linkname: target, + }, + } +} diff --git a/internal/testutil/treedump.go b/internal/testutil/treedump.go index 26aa164d..7a0f55da 100644 --- a/internal/testutil/treedump.go +++ b/internal/testutil/treedump.go @@ -74,7 +74,12 @@ func TreeDumpEntry(entry *fsutil.Entry) string { return fmt.Sprintf("dir %#o", fperm) case fs.ModeSymlink: return fmt.Sprintf("symlink %s", entry.Link) - case 0: // Regular + case 0: + // Hard link. + if entry.Link != "" { + return fmt.Sprintf("hardlink %s", entry.Link) + } + // Regular file. if entry.Size == 0 { return fmt.Sprintf("file %#o empty", entry.Mode.Perm()) } else {