Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: handle hardlinks #151

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading