Skip to content

Commit

Permalink
feat: handle hardlinks (canonical#17)
Browse files Browse the repository at this point in the history
* feat: handle hardlinks

This commit enables Chisel to be able to handle hardlinks and extract
them from package tarballs.

At the moment, if a slice is extracting a hardlink it also needs to
extract the file that it points to, either as part of that slice or in
its essentials.
  • Loading branch information
zhijie-yang authored Jul 29, 2024
1 parent 504ad63 commit f1de97a
Show file tree
Hide file tree
Showing 7 changed files with 259 additions and 25 deletions.
8 changes: 7 additions & 1 deletion internal/deb/extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions internal/deb/extract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
35 changes: 33 additions & 2 deletions internal/fsutil/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 {
Expand Down
110 changes: 95 additions & 15 deletions internal/fsutil/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -33,6 +35,7 @@ var createTests = []createTest{{
"/foo/bar": "file 0444 5b41362b",
},
}, {
summary: "Create a symlink",
options: fsutil.CreateOptions{
Path: "foo/bar",
Link: "../baz",
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -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 != "" {
Expand All @@ -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])
}
}
Loading

0 comments on commit f1de97a

Please sign in to comment.