From 3ee16c5b33d7599cdc013b44651b814fe7b9381a Mon Sep 17 00:00:00 2001 From: lczyk Date: Sat, 10 Oct 2026 14:18:07 +0100 Subject: [PATCH 1/3] fix: hard links whose target is not in the manifest Adding a hard link to the report required its target to be there already, so a hard link to a path listed with "until: mutate", directly or through a glob, failed the cut with "cannot add hard link ... target not previously added" although the release validates. Hard link entries now carry the hash and size of their content, and a link whose target is not reported is recorded as a regular file with that content. --- internal/fsutil/create.go | 25 ++++++++++++++++++++++++- internal/manifestutil/report.go | 27 +++++++++++++++------------ internal/manifestutil/report_test.go | 21 +++++++++++++++++++++ internal/slicer/slicer_test.go | 28 ++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 13 deletions(-) diff --git a/internal/fsutil/create.go b/internal/fsutil/create.go index 0503c96f1..22408b590 100644 --- a/internal/fsutil/create.go +++ b/internal/fsutil/create.go @@ -57,6 +57,7 @@ func Create(options *CreateOptions) (*Entry, error) { } var hash string + var size int if o.MakeParents { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return nil, err @@ -73,9 +74,16 @@ func Create(options *CreateOptions) (*Entry, error) { } } err = createHardLink(o) + if err == nil { + // The content is the target's, so hash it from disk. The + // entry can then stand on its own if the target is not + // reported. + hash, size, err = hashFile(path) + } } else { err = createFile(o) hash = hex.EncodeToString(rp.h.Sum(nil)) + size = rp.size } case fs.ModeDir: err = createDir(o) @@ -114,12 +122,27 @@ func Create(options *CreateOptions) (*Entry, error) { Path: path, Mode: mode, SHA256: hash, - Size: rp.size, + Size: size, Link: o.Link, } return entry, nil } +// hashFile returns the SHA256 and size of the content at path. +func hashFile(path string) (string, int, error) { + file, err := os.Open(path) + if err != nil { + return "", 0, err + } + defer file.Close() + h := sha256.New() + n, err := io.Copy(h, file) + if err != nil { + return "", 0, err + } + return hex.EncodeToString(h.Sum(nil)), int(n), nil +} + // CreateWriter handles the creation of a regular file and collects the // information recorded in Entry. The Hash and Size attributes are set on // calling Close() on the Writer. diff --git a/internal/manifestutil/report.go b/internal/manifestutil/report.go index c294b1d22..d7ecc41c8 100644 --- a/internal/manifestutil/report.go +++ b/internal/manifestutil/report.go @@ -63,19 +63,22 @@ func (r *Report) Add(slice *setup.Slice, fsEntry *fsutil.Entry) error { if fsEntry.Mode.IsRegular() && fsEntry.Link != "" { // Hard link. relLinkPath, _ := r.sanitizeAbsPath(fsEntry.Link, false) - entry, ok := r.Entries[relLinkPath] - if !ok { - return fmt.Errorf("cannot add hard link %s to report: target %s not previously added", relPath, relLinkPath) + if entry, ok := r.Entries[relLinkPath]; ok { + if entry.Inode == 0 { + r.lastInode += 1 + entry.Inode = r.lastInode + r.Entries[relLinkPath] = entry + } + inode = entry.Inode + fsEntryCpy.SHA256 = entry.SHA256 + fsEntryCpy.Size = entry.Size + fsEntryCpy.Link = entry.Link + } else { + // The target is not in the report, for instance because it is + // listed with "until: mutate", so the link is reported as a + // regular file with its own content. + fsEntryCpy.Link = "" } - if entry.Inode == 0 { - r.lastInode += 1 - entry.Inode = r.lastInode - r.Entries[relLinkPath] = entry - } - inode = entry.Inode - fsEntryCpy.SHA256 = entry.SHA256 - fsEntryCpy.Size = entry.Size - fsEntryCpy.Link = entry.Link } if entry, ok := r.Entries[relPath]; ok { diff --git a/internal/manifestutil/report_test.go b/internal/manifestutil/report_test.go index d56acddb6..2799f7178 100644 --- a/internal/manifestutil/report_test.go +++ b/internal/manifestutil/report_test.go @@ -296,6 +296,27 @@ var reportTests = []struct { Inode: 1, }, }, +}, { + summary: "Hard link to a target that is not in the report", + add: []sliceAndEntry{{ + entry: fsutil.Entry{ + Path: "/base/example-hard-link", + Mode: 0777, + SHA256: "example-file_hash", + Size: 5678, + Link: "/base/example-file", + }, + slice: oneSlice, + }}, + expected: map[string]manifestutil.ReportEntry{ + "/example-hard-link": { + Path: "/example-hard-link", + Mode: 0777, + SHA256: "example-file_hash", + Size: 5678, + Slices: map[*setup.Slice]bool{oneSlice: true}, + }, + }, }, { summary: "Multiple hard links groups", add: []sliceAndEntry{{ diff --git a/internal/slicer/slicer_test.go b/internal/slicer/slicer_test.go index d6ef9ca0d..df4bc224b 100644 --- a/internal/slicer/slicer_test.go +++ b/internal/slicer/slicer_test.go @@ -1661,6 +1661,34 @@ var slicerTests = []slicerTest{{ "/symlink": "symlink ./file <1> {test-package_myslice}", "/hardlink": "symlink ./file <1> {test-package_myslice}", }, +}, { + summary: "Hard link whose target has until:mutate", + slices: []setup.SliceKey{{"test-package", "myslice"}}, + pkgs: []*testutil.TestPackage{{ + Name: "test-package", + Data: testutil.MustMakeDeb([]testutil.TarEntry{ + testutil.Dir(0755, "./"), + testutil.Reg(0644, "./file", "foo"), + testutil.Hrd(0644, "./hardlink", "./file"), + }), + }}, + release: map[string]string{ + "slices/mydir/test-package.yaml": ` + package: test-package + slices: + myslice: + contents: + /file: {until: mutate} + /hardlink: + `, + }, + // The target is not in the manifest, so the link is a regular file there. + filesystem: map[string]string{ + "/hardlink": "file 0644 2c26b46b", + }, + manifestPaths: map[string]string{ + "/hardlink": "file 0644 2c26b46b {test-package_myslice}", + }, }, { summary: "Hard link identifiers are unique across packages", slices: []setup.SliceKey{ From c89355782a332ad6e41f1fbf38c671b1c63da419 Mon Sep 17 00:00:00 2001 From: lczyk Date: Sat, 10 Oct 2026 15:13:28 +0100 Subject: [PATCH 2/3] fix: hash only hard links to regular files A hard link to a symlink has no content of its own and the symlink may be dangling, so only links to regular files are hashed. --- internal/fsutil/create.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/fsutil/create.go b/internal/fsutil/create.go index 22408b590..f1f72b5c4 100644 --- a/internal/fsutil/create.go +++ b/internal/fsutil/create.go @@ -75,10 +75,14 @@ func Create(options *CreateOptions) (*Entry, error) { } err = createHardLink(o) if err == nil { - // The content is the target's, so hash it from disk. The - // entry can then stand on its own if the target is not - // reported. - hash, size, err = hashFile(path) + // A link to a regular file carries that content, so hash it + // from disk and the entry can stand on its own if the target + // is not reported. A link to a symlink has no content. + var info fs.FileInfo + info, err = os.Lstat(path) + if err == nil && info.Mode().IsRegular() { + hash, size, err = hashFile(path) + } } } else { err = createFile(o) From 477069d1f2197ce864da1249c6398dcc979bada1 Mon Sep 17 00:00:00 2001 From: lczyk Date: Sat, 10 Oct 2026 15:36:21 +0100 Subject: [PATCH 3/3] fix: hard links without content stay an error, unreadable ones get none A hard link to a symlink has no content of its own, so when its target is not in the report the error stays instead of recording an empty regular file. A link whose content cannot be read gets no hash either, which only matters when its target is not reported. --- internal/fsutil/create.go | 5 +++++ internal/fsutil/create_test.go | 20 ++++++++++++++++---- internal/manifestutil/report.go | 5 ++++- internal/manifestutil/report_test.go | 11 +++++++++++ internal/slicer/slicer_test.go | 23 +++++++++++++++++++++++ 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/internal/fsutil/create.go b/internal/fsutil/create.go index f1f72b5c4..6d2ef3e29 100644 --- a/internal/fsutil/create.go +++ b/internal/fsutil/create.go @@ -82,6 +82,11 @@ func Create(options *CreateOptions) (*Entry, error) { info, err = os.Lstat(path) if err == nil && info.Mode().IsRegular() { hash, size, err = hashFile(path) + if os.IsPermission(err) { + // The content is not readable, so the entry has no + // hash of its own. + hash, size, err = "", 0, nil + } } } } else { diff --git a/internal/fsutil/create_test.go b/internal/fsutil/create_test.go index 5e908fccc..cff0d20bb 100644 --- a/internal/fsutil/create_test.go +++ b/internal/fsutil/create_test.go @@ -2,6 +2,8 @@ package fsutil_test import ( "bytes" + "crypto/sha256" + "encoding/hex" "io/fs" "os" "path/filepath" @@ -332,16 +334,26 @@ func (s *S) TestCreate(c *C) { c.Assert(testutil.TreeDump(dir), DeepEquals, test.result) if options.Mode.IsRegular() && options.Link != "" { - // We should test hard link entries differently because - // fsutil.Create does not return hash or size when it creates hard - // links. + // Hard link entries are checked differently: the entry carries + // the hash and size of the content when the target is a regular + // file, and nothing when it is a symlink. c.Assert(entry.Link, Equals, options.Link) c.Assert(entry.Mode.IsRegular(), Equals, true) pathInfo, err := os.Lstat(entry.Path) c.Assert(err, IsNil) linkInfo, err := os.Lstat(entry.Link) c.Assert(err, IsNil) - os.SameFile(pathInfo, linkInfo) + c.Assert(os.SameFile(pathInfo, linkInfo), Equals, true) + if pathInfo.Mode().IsRegular() { + data, err := os.ReadFile(entry.Path) + c.Assert(err, IsNil) + sum := sha256.Sum256(data) + c.Assert(entry.SHA256, Equals, hex.EncodeToString(sum[:])) + c.Assert(entry.Size, Equals, len(data)) + } else { + c.Assert(entry.SHA256, Equals, "") + c.Assert(entry.Size, Equals, 0) + } } else { // [fsutil.Create] does not return information about parent directories // created implicitly. We only check for the requested path. diff --git a/internal/manifestutil/report.go b/internal/manifestutil/report.go index d7ecc41c8..98295af24 100644 --- a/internal/manifestutil/report.go +++ b/internal/manifestutil/report.go @@ -73,11 +73,14 @@ func (r *Report) Add(slice *setup.Slice, fsEntry *fsutil.Entry) error { fsEntryCpy.SHA256 = entry.SHA256 fsEntryCpy.Size = entry.Size fsEntryCpy.Link = entry.Link - } else { + } else if fsEntry.SHA256 != "" { // The target is not in the report, for instance because it is // listed with "until: mutate", so the link is reported as a // regular file with its own content. fsEntryCpy.Link = "" + } else { + // A hard link to a symlink carries no content of its own. + return fmt.Errorf("cannot add hard link %s to report: target %s not previously added", relPath, relLinkPath) } } diff --git a/internal/manifestutil/report_test.go b/internal/manifestutil/report_test.go index 2799f7178..6c76ce7cb 100644 --- a/internal/manifestutil/report_test.go +++ b/internal/manifestutil/report_test.go @@ -317,6 +317,17 @@ var reportTests = []struct { Slices: map[*setup.Slice]bool{oneSlice: true}, }, }, +}, { + summary: "Hard link without content to a target that is not in the report", + add: []sliceAndEntry{{ + entry: fsutil.Entry{ + Path: "/base/example-hard-link", + Mode: 0777, + Link: "/base/example-symlink", + }, + slice: oneSlice, + }}, + err: `cannot add hard link /example-hard-link to report: target /example-symlink not previously added`, }, { summary: "Multiple hard links groups", add: []sliceAndEntry{{ diff --git a/internal/slicer/slicer_test.go b/internal/slicer/slicer_test.go index df4bc224b..018ab7367 100644 --- a/internal/slicer/slicer_test.go +++ b/internal/slicer/slicer_test.go @@ -1689,6 +1689,29 @@ var slicerTests = []slicerTest{{ manifestPaths: map[string]string{ "/hardlink": "file 0644 2c26b46b {test-package_myslice}", }, +}, { + summary: "Hard link to a symlink that has until:mutate", + slices: []setup.SliceKey{{"test-package", "myslice"}}, + pkgs: []*testutil.TestPackage{{ + Name: "test-package", + Data: testutil.MustMakeDeb([]testutil.TarEntry{ + testutil.Dir(0755, "./"), + testutil.Reg(0644, "./file", "foo"), + testutil.Lnk(0644, "./symlink", "./file"), + testutil.Hrd(0644, "./hardlink", "./symlink"), + }), + }}, + release: map[string]string{ + "slices/mydir/test-package.yaml": ` + package: test-package + slices: + myslice: + contents: + /symlink: {until: mutate} + /hardlink: + `, + }, + error: `cannot extract from package "test-package": cannot add hard link /hardlink to report: target /symlink not previously added`, }, { summary: "Hard link identifiers are unique across packages", slices: []setup.SliceKey{