Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package melange for openSUSE:Factory checked in at 2026-10-24 22:05:05 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/melange (Old) and /work/SRC/openSUSE:Factory/.melange.new.2252680 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "melange" Sat Oct 24 22:05:05 2026 rev:186 rq:1392194 version:0.63.1 Changes: -------- --- /work/SRC/openSUSE:Factory/melange/melange.changes 2026-10-20 12:38:52.289884125 +0200 +++ /work/SRC/openSUSE:Factory/.melange.new.2252680/melange.changes 2026-10-24 22:06:55.826404475 +0200 @@ -1,0 +2,6 @@ +Fri Oct 23 15:57:49 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 0.63.1: + * fix: preserve hardlinks when writing .apk files (#2709) + +------------------------------------------------------------------- Old: ---- melange-0.63.0.obscpio New: ---- melange-0.63.1.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ melange.spec ++++++ --- /var/tmp/diff_new_pack.yNc5Dd/_old 2026-10-24 22:06:56.818446227 +0200 +++ /var/tmp/diff_new_pack.yNc5Dd/_new 2026-10-24 22:06:56.820446311 +0200 @@ -17,7 +17,7 @@ Name: melange -Version: 0.63.0 +Version: 0.63.1 Release: 0 Summary: Build APKs from source code License: Apache-2.0 ++++++ _service ++++++ --- /var/tmp/diff_new_pack.yNc5Dd/_old 2026-10-24 22:06:56.853447700 +0200 +++ /var/tmp/diff_new_pack.yNc5Dd/_new 2026-10-24 22:06:56.856447826 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/chainguard-dev/melange.git</param> <param name="scm">git</param> <param name="exclude">.git</param> - <param name="revision">refs/tags/v0.63.0</param> + <param name="revision">refs/tags/v0.63.1</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> <param name="changesgenerate">enable</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.yNc5Dd/_old 2026-10-24 22:06:56.877448710 +0200 +++ /var/tmp/diff_new_pack.yNc5Dd/_new 2026-10-24 22:06:56.880448837 +0200 @@ -3,6 +3,6 @@ <param name="url">https://github.com/chainguard-dev/melange</param> <param name="changesrevision">3f6115b820985d70ca3c93cdf8519c1b3b4cfe81</param></service><service name="tar_scm"> <param name="url">https://github.com/chainguard-dev/melange.git</param> - <param name="changesrevision">cd366b69285d8b7d5ac7e1c352bd96b609c8b26a</param></service></servicedata> + <param name="changesrevision">04c0a4045867d1462842d383487f1f02f80ff7ac</param></service></servicedata> (No newline at EOF) ++++++ melange-0.63.0.obscpio -> melange-0.63.1.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/melange-0.63.0/pkg/build/package.go new/melange-0.63.1/pkg/build/package.go --- old/melange-0.63.0/pkg/build/package.go 2026-10-16 17:15:17.000000000 +0200 +++ new/melange-0.63.1/pkg/build/package.go 2026-10-21 00:14:41.000000000 +0200 @@ -408,6 +408,15 @@ // TODO(kaniini): generate APKv3 packages func (pc *PackageBuild) calculateInstalledSize(fsys apkofs.FullFS) error { + // a file with several hard links is counted once for the paths the data + // section writes as links to it + links, err := tarball.OpenDiskLinks(pc.WorkspaceSubdir()) + if err != nil { + return err + } + defer links.Close() + var targets tarball.LinkTargets + if err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err @@ -418,6 +427,20 @@ return err } + if fi.Mode().IsRegular() { + inode, linked, err := links.Inode(path) + if err != nil { + return err + } + if linked { + // the writer ignores errors listing xattrs too + xattrs, _ := fsys.ListXattrs(path) + if _, ok := targets.Target(inode, path, xattrs); ok { + return nil + } + } + } + pc.InstalledSize += fi.Size() return nil }); err != nil { @@ -434,6 +457,7 @@ tarball.WithRemapUIDs(remapUIDs), tarball.WithRemapGIDs(remapGIDs), tarball.WithUseChecksums(true), + tarball.WithDiskDir(pc.WorkspaceSubdir()), ) if err != nil { return fmt.Errorf("unable to build tarball context: %w", err) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/melange-0.63.0/pkg/build/package_test.go new/melange-0.63.1/pkg/build/package_test.go --- old/melange-0.63.0/pkg/build/package_test.go 2026-10-16 17:15:17.000000000 +0200 +++ new/melange-0.63.1/pkg/build/package_test.go 2026-10-21 00:14:41.000000000 +0200 @@ -15,10 +15,18 @@ package build import ( + "archive/tar" "bytes" + "compress/gzip" + "errors" + "io" + "os" + "path/filepath" "testing" "time" + apkofs "chainguard.dev/apko/pkg/apk/fs" + "chainguard.dev/melange/pkg/config" "github.com/stretchr/testify/require" @@ -137,4 +145,258 @@ } }) } +} + +// hardlinkPackage lays out a package with usr/bin/tool hard linked as +// usr/bin/alias and usr/lib/tool, usr/lib/libfoo.so.1 hard linked as +// usr/lib/libfoo.so, and an unrelated usr/lib/other. It returns the +// filesystem EmitPackage reads it through, a view of pc.Build.WorkspaceDirFS. +func hardlinkPackage(t *testing.T) (*PackageBuild, apkofs.FullFS) { + t.Helper() + pc := &PackageBuild{ + Build: &Build{ + SourceDateEpoch: time.Unix(0, 0), + WorkspaceDir: t.TempDir(), + }, + PackageName: "hardlinks", + } + dir := pc.WorkspaceSubdir() + for _, d := range []string{"usr/bin", "usr/lib"} { + if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil { + t.Fatal(err) + } + } + tool := filepath.Join(dir, "usr/bin/tool") + if err := os.WriteFile(tool, []byte("tool contents"), 0o755); err != nil { + t.Fatal(err) + } + for _, l := range []string{"usr/bin/alias", "usr/lib/tool"} { + if err := os.Link(tool, filepath.Join(dir, l)); err != nil { + t.Fatal(err) + } + } + lib := filepath.Join(dir, "usr/lib/libfoo.so.1") + if err := os.WriteFile(lib, []byte("lib contents"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Link(lib, filepath.Join(dir, "usr/lib/libfoo.so")); err != nil { + t.Fatal(err) + } + other := filepath.Join(dir, "usr/lib/other") + if err := os.WriteFile(other, []byte("other contents"), 0o644); err != nil { + t.Fatal(err) + } + + pc.Build.WorkspaceDirFS = apkofs.DirFS(t.Context(), pc.Build.WorkspaceDir) + fsys, err := apkofs.Sub( + pc.Build.WorkspaceDirFS, + filepath.Join(melangeOutputDirName, pc.PackageName), + ) + if err != nil { + t.Fatal(err) + } + return pc, fsys +} + +// A file with several hard links is installed once, so it counts once. +func Test_calculateInstalledSize_Hardlinks(t *testing.T) { + pc, fsys := hardlinkPackage(t) + + want := int64(len("tool contents") + len("lib contents") + len("other contents")) + for _, d := range []string{".", "usr", "usr/bin", "usr/lib"} { + fi, err := os.Lstat(filepath.Join(pc.WorkspaceSubdir(), d)) + if err != nil { + t.Fatal(err) + } + want += fi.Size() + } + + if err := pc.calculateInstalledSize(fsys); err != nil { + t.Fatal(err) + } + if pc.InstalledSize != want { + t.Errorf("installed size %d, want %d", pc.InstalledSize, want) + } +} + +// Files hard linked in the workspace are written once, with every other path +// to the same inode as a link to the first one in walk order. +func Test_emitDataSection_Hardlinks(t *testing.T) { + pc, fsys := hardlinkPackage(t) + got, contents := readDataSection(t, pc, fsys) + + // usr/bin/alias sorts first, so it carries the contents + regular := map[string]string{ + "usr/bin/alias": "tool contents", + "usr/lib/other": "other contents", + } + for name, want := range regular { + hdr, ok := got[name] + if !ok { + t.Fatalf("%s: missing", name) + } + if hdr.Typeflag != tar.TypeReg { + t.Errorf("%s: type %q, want regular file", name, hdr.Typeflag) + } + if contents[name] != want { + t.Errorf("%s: contents %q, want %q", name, contents[name], want) + } + } + for _, name := range []string{"usr/bin/tool", "usr/lib/tool"} { + hdr, ok := got[name] + if !ok { + t.Fatalf("%s: missing", name) + } + if hdr.Typeflag != tar.TypeLink || hdr.Linkname != "usr/bin/alias" || hdr.Size != 0 { + t.Errorf("%s: type %q linkname %q size %d, want a link to usr/bin/alias with size 0", + name, hdr.Typeflag, hdr.Linkname, hdr.Size) + } + } +} + +// readDataSection writes the data section of the package and returns its +// headers and contents by path. +func readDataSection( + t *testing.T, pc *PackageBuild, fsys apkofs.FullFS, +) (map[string]*tar.Header, map[string]string) { + t.Helper() + out, err := os.Create(filepath.Join(t.TempDir(), "data.tar.gz")) + if err != nil { + t.Fatal(err) + } + defer out.Close() + if err := pc.emitDataSection(t.Context(), fsys, fsys, nil, nil, out); err != nil { + t.Fatal(err) + } + + zr, err := gzip.NewReader(out) + if err != nil { + t.Fatal(err) + } + tr := tar.NewReader(zr) + got := map[string]*tar.Header{} + contents := map[string]string{} + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + got[hdr.Name] = hdr + contents[hdr.Name] = string(body) + } + return got, contents +} + +// setCapability sets a capability on path in the package the way the build +// does for package.setcap, through the workspace filesystem. +func setCapability(t *testing.T, pc *PackageBuild, path string) []byte { + t.Helper() + capability := config.EncodeCapability(1<<13, 1<<13, 0) // cap_net_raw=ep + name := filepath.Join(melangeOutputDirName, pc.PackageName, path) + err := pc.Build.WorkspaceDirFS.SetXattr(name, "security.capability", capability) + if err != nil { + t.Fatal(err) + } + return capability +} + +// A path with xattrs is installed as a file of its own, so it counts in full. +func Test_calculateInstalledSize_HardlinksXattrs(t *testing.T) { + for _, tt := range []struct { + name string + xattr string + tools int64 // times "tool contents" is counted + }{ + // usr/bin/alias is the first path of its inode in walk order + {name: "first path", xattr: "usr/bin/alias", tools: 3}, + {name: "later path", xattr: "usr/lib/tool", tools: 2}, + } { + t.Run(tt.name, func(t *testing.T) { + pc, fsys := hardlinkPackage(t) + setCapability(t, pc, tt.xattr) + + want := tt.tools*int64(len("tool contents")) + + int64(len("lib contents")+len("other contents")) + for _, d := range []string{".", "usr", "usr/bin", "usr/lib"} { + fi, err := os.Lstat(filepath.Join(pc.WorkspaceSubdir(), d)) + if err != nil { + t.Fatal(err) + } + want += fi.Size() + } + + if err := pc.calculateInstalledSize(fsys); err != nil { + t.Fatal(err) + } + if pc.InstalledSize != want { + t.Errorf("installed size %d, want %d", pc.InstalledSize, want) + } + }) + } +} + +// A path with xattrs is never written as a link entry nor used as the target +// of one: apk-tools chowns a link after creating it, and the chown clears +// security.capability on the inode the link shares. +func Test_emitDataSection_HardlinksXattrs(t *testing.T) { + for _, tt := range []struct { + name string + xattr string + regular []string // paths of usr/bin/tool's inode with contents + links map[string]string // link entry to target + }{{ + name: "first path", + xattr: "usr/bin/alias", + regular: []string{"usr/bin/alias", "usr/bin/tool", "usr/lib/tool"}, + links: map[string]string{"usr/lib/libfoo.so.1": "usr/lib/libfoo.so"}, + }, { + name: "later path", + xattr: "usr/lib/tool", + regular: []string{"usr/bin/alias", "usr/lib/tool"}, + links: map[string]string{ + "usr/bin/tool": "usr/bin/alias", + "usr/lib/libfoo.so.1": "usr/lib/libfoo.so", + }, + }} { + t.Run(tt.name, func(t *testing.T) { + pc, fsys := hardlinkPackage(t) + capability := setCapability(t, pc, tt.xattr) + got, contents := readDataSection(t, pc, fsys) + + for _, name := range tt.regular { + hdr, ok := got[name] + if !ok { + t.Fatalf("%s: missing", name) + } + if hdr.Typeflag != tar.TypeReg || contents[name] != "tool contents" { + t.Errorf("%s: type %q contents %q, want a regular file with %q", + name, hdr.Typeflag, contents[name], "tool contents") + } + xattr, ok := hdr.PAXRecords["SCHILY.xattr.security.capability"] + if name == tt.xattr && (!ok || xattr != string(capability)) { + t.Errorf("%s: capability %q, want %q", name, xattr, capability) + } + if name != tt.xattr && ok { + t.Errorf("%s: capability %q, want none", name, xattr) + } + } + for name, target := range tt.links { + hdr, ok := got[name] + if !ok { + t.Fatalf("%s: missing", name) + } + if hdr.Typeflag != tar.TypeLink || hdr.Linkname != target { + t.Errorf("%s: type %q linkname %q, want a link to %s", + name, hdr.Typeflag, hdr.Linkname, target) + } + } + }) + } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/melange-0.63.0/pkg/tarball/tarball.go new/melange-0.63.1/pkg/tarball/tarball.go --- old/melange-0.63.0/pkg/tarball/tarball.go 2026-10-16 17:15:17.000000000 +0200 +++ new/melange-0.63.1/pkg/tarball/tarball.go 2026-10-21 00:14:41.000000000 +0200 @@ -32,6 +32,7 @@ remapUIDs map[int]int remapGIDs map[int]int overridePerms map[string]tar.Header + diskDir string } type Option func(*Context) error @@ -130,3 +131,14 @@ return nil } } + +// WithDiskDir sets the directory on disk that the fs.FS being written is a +// view of. Hardlinks are then detected by stat-ing the files under it, for +// filesystems whose FileInfo.Sys() is not a *syscall.Stat_t (apko's DirFS +// returns a *tar.Header even for files kept on disk). +func WithDiskDir(dir string) Option { + return func(ctx *Context) error { + ctx.diskDir = dir + return nil + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/melange-0.63.0/pkg/tarball/write.go new/melange-0.63.1/pkg/tarball/write.go --- old/melange-0.63.0/pkg/tarball/write.go 2026-10-16 17:15:17.000000000 +0200 +++ new/melange-0.63.1/pkg/tarball/write.go 2026-10-21 00:14:41.000000000 +0200 @@ -19,10 +19,12 @@ "compress/gzip" "context" "encoding/hex" + "errors" "fmt" "io" "io/fs" "os" + "path/filepath" "syscall" "go.opentelemetry.io/otel" @@ -78,6 +80,78 @@ return 0, fmt.Errorf("unable to stat underlying file") } +// DiskLinks finds regular files with several hard links under a directory +// on disk. +type DiskLinks struct { + root *os.Root +} + +// OpenDiskLinks opens dir to look for hardlinks in. Call Close when done. +func OpenDiskLinks(dir string) (*DiskLinks, error) { + root, err := os.OpenRoot(dir) + if err != nil { + return nil, fmt.Errorf("opening %s to detect hardlinks: %w", dir, err) + } + return &DiskLinks{root: root}, nil +} + +func (d *DiskLinks) Close() error { + return d.root.Close() +} + +// Inode returns the inode of the regular file at name, a slash-separated path +// relative to the directory, if it has more than one link. A file missing +// from disk (kept only in memory by an fs.FS such as apko's DirFS) is not +// linked to anything. +func (d *DiskLinks) Inode(name string) (uint64, bool, error) { + fi, err := d.root.Lstat(filepath.FromSlash(name)) + if errors.Is(err, fs.ErrNotExist) { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + if !fi.Mode().IsRegular() || !hasHardlinks(fi) { + return 0, false, nil + } + inode, err := getInodeFromFileInfo(fi) + if err != nil { + return 0, false, err + } + return inode, true, nil +} + +// LinkTargets decides which paths of hard linked files are written as link +// entries. Every path of an inode links to the first one seen, unless either +// of the two has xattrs: apk-tools chowns a link after creating it, which +// makes the kernel clear security.capability on the inode the link shares. +// A path with xattrs is written as a file of its own and is not linked to. +type LinkTargets struct { + seen map[uint64]linkTarget +} + +type linkTarget struct { + name string + xattrs bool +} + +// Target records name as a path of inode, with the xattrs listed for name, and +// returns the path name is to be written as a link to, if any. +func (l *LinkTargets) Target(inode uint64, name string, xattrs map[string][]byte) (string, bool) { + if l.seen == nil { + l.seen = map[uint64]linkTarget{} + } + target, ok := l.seen[inode] + if !ok { + l.seen[inode] = linkTarget{name: name, xattrs: len(xattrs) > 0} + return "", false + } + if target.xattrs || len(xattrs) > 0 { + return "", false + } + return target.name, true +} + func (c *Context) writeTar(ctx context.Context, tw *tar.Writer, fsys fs.FS, users, groups map[int]string) error { //nolint:gocyclo if users == nil { users = map[int]string{} @@ -85,7 +159,15 @@ if groups == nil { groups = map[int]string{} } - seenFiles := map[uint64]string{} + var targets LinkTargets + var disk *DiskLinks + if c.diskDir != "" { + var err error + if disk, err = OpenDiskLinks(c.diskDir); err != nil { + return err + } + defer disk.Close() + } // set this once, to make it easy to look up later if c.overridePerms == nil { c.overridePerms = map[string]tar.Header{} @@ -211,18 +293,41 @@ if link != "" { header.Typeflag = tar.TypeSymlink } - if !info.IsDir() && hasHardlinks(info) { - inode, err := getInodeFromFileInfo(info) - if err != nil { - return err + // only capture xattrs for real objects in the FS + var xattrs map[string][]byte + if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeDir { + if xfs, ok := fsys.(apkfs.XattrFS); ok { + // we can ignore errors + if x, err := xfs.ListXattrs(path); err == nil { + xattrs = x + } + } + } + + // only regular files become links: a link entry carries no target or + // device number of its own + if info.Mode().IsRegular() { + var ( + inode uint64 + linked bool + ) + if disk != nil { + if inode, linked, err = disk.Inode(path); err != nil { + return err + } + } else if hasHardlinks(info) { + if inode, err = getInodeFromFileInfo(info); err != nil { + return err + } + linked = true } - if oldpath, ok := seenFiles[inode]; ok { - header.Typeflag = tar.TypeLink - header.Linkname = oldpath - header.Size = 0 - } else { - seenFiles[inode] = header.Name + if linked { + if oldpath, ok := targets.Target(inode, header.Name, xattrs); ok { + header.Typeflag = tar.TypeLink + header.Linkname = oldpath + header.Size = 0 + } } } @@ -238,7 +343,7 @@ } linkChecksum := hex.EncodeToString(linkDigest[:]) header.PAXRecords["APK-TOOLS.checksum.SHA1"] = linkChecksum - } else if info.Mode().IsRegular() { + } else if header.Typeflag == tar.TypeReg { data, err := fsys.Open(path) if err != nil { return err @@ -260,25 +365,17 @@ } } - // only capture xattrs for real objects in the FS - if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeDir { - xfs, ok := fsys.(apkfs.XattrFS) - if ok { - xattrs, err := xfs.ListXattrs(path) - // we can ignore errors - if err == nil && xattrs != nil { - for name, value := range xattrs { - header.PAXRecords[xattrTarPAXRecordsPrefix+name] = string(value) - } - } - } + // a link entry has no xattrs: LinkTargets keeps paths with xattrs as + // regular entries + for name, value := range xattrs { + header.PAXRecords[xattrTarPAXRecordsPrefix+name] = string(value) } if err := tw.WriteHeader(header); err != nil { return err } - if info.Mode().IsRegular() && header.Size > 0 { + if header.Typeflag == tar.TypeReg && header.Size > 0 { data, err := fsys.Open(path) if err != nil { return err diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/melange-0.63.0/pkg/tarball/write_test.go new/melange-0.63.1/pkg/tarball/write_test.go --- old/melange-0.63.0/pkg/tarball/write_test.go 2026-10-16 17:15:17.000000000 +0200 +++ new/melange-0.63.1/pkg/tarball/write_test.go 2026-10-21 00:14:41.000000000 +0200 @@ -4,6 +4,11 @@ "archive/tar" "bytes" "context" + "errors" + "io" + iofs "io/fs" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -47,3 +52,246 @@ require.Equal(t, file, hdr.Name, "tar file header name mismatch") require.Equal(t, "bar", hdr.PAXRecords[xattrTarPAXRecordsPrefix+"user.file"], "tar header for file xattr mismatch") } + +const checksumRecord = "APK-TOOLS.checksum.SHA1" + +// hardlinkDir returns a directory holding a/first and b/second, hard links of +// one inode, c/other, a separate file, and d/solo, a file with one link. +func hardlinkDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + for _, d := range []string{"a", "b", "c", "d"} { + require.NoError(t, os.Mkdir(filepath.Join(dir, d), 0o755)) + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "a/first"), []byte("shared"), 0o644)) + require.NoError(t, os.Link(filepath.Join(dir, "a/first"), filepath.Join(dir, "b/second"))) + require.NoError(t, os.WriteFile(filepath.Join(dir, "c/other"), []byte("other"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "d/solo"), []byte("solo"), 0o644)) + return dir +} + +type entry struct { + hdr *tar.Header + data string +} + +func writeEntries(t *testing.T, c *Context, fsys iofs.FS) map[string]entry { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + require.NoError(t, c.writeTar(t.Context(), tw, fsys, nil, nil)) + require.NoError(t, tw.Close()) + + got := map[string]entry{} + tr := tar.NewReader(&buf) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return got + } + require.NoError(t, err) + data, err := io.ReadAll(tr) + require.NoError(t, err) + got[hdr.Name] = entry{hdr: hdr, data: string(data)} + } +} + +func TestWriteTar_Hardlinks(t *testing.T) { + for _, tc := range []struct { + name string + fsys func(t *testing.T, dir string) iofs.FS + disk bool + linked bool + }{{ + // apko's DirFS reports a *tar.Header from Sys(), so only the disk lookup sees the link + name: "DirFS with disk dir", + fsys: func(t *testing.T, dir string) iofs.FS { return fs.DirFS(t.Context(), dir) }, + disk: true, + linked: true, + }, { + name: "DirFS without disk dir", + fsys: func(t *testing.T, dir string) iofs.FS { return fs.DirFS(t.Context(), dir) }, + linked: false, + }, { + // os.DirFS reports a *syscall.Stat_t, so no disk dir is needed + name: "os.DirFS without disk dir", + fsys: func(_ *testing.T, dir string) iofs.FS { return os.DirFS(dir) }, + linked: true, + }} { + for _, checksums := range []bool{false, true} { + name := tc.name + if checksums { + name += " with checksums" + } + t.Run(name, func(t *testing.T) { + dir := hardlinkDir(t) + opts := []Option{WithUseChecksums(checksums)} + if tc.disk { + opts = append(opts, WithDiskDir(dir)) + } + c, err := NewContext(opts...) + require.NoError(t, err) + + got := writeEntries(t, c, tc.fsys(t, dir)) + + first, second := got["a/first"], got["b/second"] + require.Equal(t, byte(tar.TypeReg), first.hdr.Typeflag) + require.Equal(t, "shared", first.data) + for _, name := range []string{"c/other", "d/solo"} { + require.Equal(t, byte(tar.TypeReg), got[name].hdr.Typeflag, name) + } + require.Equal(t, "other", got["c/other"].data) + require.Equal(t, "solo", got["d/solo"].data) + + if !tc.linked { + require.Equal(t, byte(tar.TypeReg), second.hdr.Typeflag) + require.Equal(t, "shared", second.data) + } else { + require.Equal(t, byte(tar.TypeLink), second.hdr.Typeflag) + require.Equal(t, "a/first", second.hdr.Linkname) + require.Zero(t, second.hdr.Size) + require.Empty(t, second.data) + } + + // a link entry carries no checksum: the contents are not hashed + // again + for name, e := range got { + if e.hdr.Typeflag != tar.TypeReg { + continue + } + if checksums { + require.NotEmpty(t, e.hdr.PAXRecords[checksumRecord], name) + } else { + require.Empty(t, e.hdr.PAXRecords[checksumRecord], name) + } + } + if checksums && tc.linked { + require.NotContains(t, second.hdr.PAXRecords, checksumRecord) + } + }) + } + } +} + +// A path with xattrs is written in full, and is not a target either: apk-tools +// chowning a link would clear security.capability on the shared inode. +func TestWriteTar_HardlinksWithXattrs(t *testing.T) { + for _, tc := range []struct { + name string + xattrOn string + }{ + {name: "first path", xattrOn: "a/first"}, + {name: "later path", xattrOn: "b/second"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := hardlinkDir(t) + fsys := fs.DirFS(t.Context(), dir) + require.NoError(t, fsys.SetXattr(tc.xattrOn, "security.capability", []byte("cap"))) + c, err := NewContext(WithDiskDir(dir)) + require.NoError(t, err) + + got := writeEntries(t, c, fsys) + for _, name := range []string{"a/first", "b/second"} { + require.Equal(t, byte(tar.TypeReg), got[name].hdr.Typeflag, name) + require.Equal(t, "shared", got[name].data, name) + } + require.Equal(t, "cap", got[tc.xattrOn].hdr.PAXRecords[xattrTarPAXRecordsPrefix+"security.capability"]) + }) + } +} + +func TestWriteTar_DiskDirMissing(t *testing.T) { + c, err := NewContext(WithDiskDir(filepath.Join(t.TempDir(), "absent"))) + require.NoError(t, err) + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + require.Error(t, c.writeTar(t.Context(), tw, fs.NewMemFS(), nil, nil)) +} + +// Files kept only in memory by the fs.FS have nothing on disk to link to. +func TestWriteTar_DiskDirFileNotOnDisk(t *testing.T) { + m := fs.NewMemFS() + require.NoError(t, m.WriteFile("only-in-memory", []byte("hello"), 0o644)) + c, err := NewContext(WithDiskDir(t.TempDir())) + require.NoError(t, err) + + got := writeEntries(t, c, m) + require.Equal(t, byte(tar.TypeReg), got["only-in-memory"].hdr.Typeflag) + require.Equal(t, "hello", got["only-in-memory"].data) +} + +func TestDiskLinks_Inode(t *testing.T) { + dir := hardlinkDir(t) + require.NoError(t, os.Symlink("first", filepath.Join(dir, "a/sym"))) + + d, err := OpenDiskLinks(dir) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, d.Close()) }) + + first, linked, err := d.Inode("a/first") + require.NoError(t, err) + require.True(t, linked) + second, linked, err := d.Inode("b/second") + require.NoError(t, err) + require.True(t, linked) + require.Equal(t, first, second) + + for _, name := range []string{ + "c/other", // one link + "a", // directory + "a/sym", // symlink: Lstat, so not followed to the linked file + "missing/gone", // not on disk + } { + _, linked, err := d.Inode(name) + require.NoError(t, err, name) + require.False(t, linked, name) + } +} + +func TestOpenDiskLinks_Missing(t *testing.T) { + _, err := OpenDiskLinks(filepath.Join(t.TempDir(), "absent")) + require.Error(t, err) +} + +func TestLinkTargets(t *testing.T) { + withCap := map[string][]byte{"security.capability": []byte("cap")} + + t.Run("links to the first path of an inode", func(t *testing.T) { + var l LinkTargets + _, ok := l.Target(1, "a", nil) + require.False(t, ok) + target, ok := l.Target(1, "b", nil) + require.True(t, ok) + require.Equal(t, "a", target) + target, ok = l.Target(1, "c", nil) + require.True(t, ok) + require.Equal(t, "a", target) + }) + + t.Run("inodes are independent", func(t *testing.T) { + var l LinkTargets + _, ok := l.Target(1, "a", nil) + require.False(t, ok) + _, ok = l.Target(2, "b", nil) + require.False(t, ok) + }) + + t.Run("xattrs on the first path", func(t *testing.T) { + var l LinkTargets + _, ok := l.Target(1, "a", withCap) + require.False(t, ok) + _, ok = l.Target(1, "b", nil) + require.False(t, ok) + }) + + t.Run("xattrs on a later path", func(t *testing.T) { + var l LinkTargets + _, ok := l.Target(1, "a", nil) + require.False(t, ok) + _, ok = l.Target(1, "b", withCap) + require.False(t, ok) + target, ok := l.Target(1, "c", nil) + require.True(t, ok) + require.Equal(t, "a", target) + }) +} ++++++ melange.obsinfo ++++++ --- /var/tmp/diff_new_pack.yNc5Dd/_old 2026-10-24 22:06:57.967494587 +0200 +++ /var/tmp/diff_new_pack.yNc5Dd/_new 2026-10-24 22:06:57.969494671 +0200 @@ -1,5 +1,5 @@ name: melange -version: 0.63.0 -mtime: 1792163717 -commit: cd366b69285d8b7d5ac7e1c352bd96b609c8b26a +version: 0.63.1 +mtime: 1792534481 +commit: 04c0a4045867d1462842d383487f1f02f80ff7ac ++++++ vendor.tar.gz ++++++ /work/SRC/openSUSE:Factory/melange/vendor.tar.gz /work/SRC/openSUSE:Factory/.melange.new.2252680/vendor.tar.gz differ: char 30, line 1
