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

Reply via email to