From 3585475af1e903253397a0dbd049e18d08261231 Mon Sep 17 00:00:00 2001 From: lczyk Date: Sat, 10 Oct 2026 14:19:42 +0100 Subject: [PATCH 1/3] fix: listed mode of copied paths The "mode" of a copy path was parsed and validated but never handed to the extraction, so a copied file or directory always kept the mode from the tarball. Generated content (text, make, symlink) did honour it. The mode is now passed along with the extraction request. A mode on a path that a glob of the same package also matches is a diverging mode for the tarball extraction and fails the cut, as the tarball package already specifies. --- internal/slicer/slicer.go | 1 + internal/slicer/slicer_test.go | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/internal/slicer/slicer.go b/internal/slicer/slicer.go index ce791162..6e7ec433 100644 --- a/internal/slicer/slicer.go +++ b/internal/slicer/slicer.go @@ -127,6 +127,7 @@ func Run(options *RunOptions) error { } extractPackage[sourcePath] = append(extractPackage[sourcePath], tarball.ExtractInfo{ Path: targetPath, + Mode: pathInfo.Mode, Context: slice, }) } else { diff --git a/internal/slicer/slicer_test.go b/internal/slicer/slicer_test.go index d6ef9ca0..1ca81bdd 100644 --- a/internal/slicer/slicer_test.go +++ b/internal/slicer/slicer_test.go @@ -454,6 +454,50 @@ var slicerTests = []slicerTest{{ "/dir/text-file-1": "file 0644 5b41362b {test-package_myslice}", "/foo/text-file-2": "file 0644 d98cf53e 5b41362b {test-package_myslice}", }, +}, { + summary: "Copied file and directory take the listed mode", + slices: []setup.SliceKey{{"test-package", "myslice"}}, + release: map[string]string{ + "slices/mydir/test-package.yaml": ` + package: test-package + slices: + myslice: + contents: + /dir/: {mode: 0700} + /dir/file: {mode: 0600} + /other-dir/file-copy: {copy: /dir/file, mode: 0640} + `, + }, + filesystem: map[string]string{ + "/dir/": "dir 0700", + "/dir/file": "file 0600 cc55e2ec", + "/other-dir/": "dir 0755", + "/other-dir/file-copy": "file 0640 cc55e2ec", + }, + manifestPaths: map[string]string{ + "/dir/": "dir 0700 {test-package_myslice}", + "/dir/file": "file 0600 cc55e2ec {test-package_myslice}", + "/other-dir/file-copy": "file 0640 cc55e2ec {test-package_myslice}", + }, +}, { + summary: "Listed mode on a path a glob also matches is a diverging mode", + slices: []setup.SliceKey{ + {"test-package", "myslice1"}, + {"test-package", "myslice2"}, + }, + release: map[string]string{ + "slices/mydir/test-package.yaml": ` + package: test-package + slices: + myslice1: + contents: + /dir/fil*: + myslice2: + contents: + /dir/file: {mode: 0600} + `, + }, + error: `cannot extract from package "test-package": path /dir/file requested twice with diverging mode: 0600 != 0000`, }, { summary: "Script: use 'until' to remove file after mutate", slices: []setup.SliceKey{{"test-package", "myslice"}}, From f509b795b02b7e6298fb2aaf8061a08dba1a7478 Mon Sep 17 00:00:00 2001 From: lczyk Date: Sat, 10 Oct 2026 15:14:35 +0100 Subject: [PATCH 2/3] fix: listed mode of hard links and of repeated sources The tar header mode set for one destination of a source leaked into the next destination of the same source, so a destination without a listed mode could take another one. Each destination now starts from the mode in the tarball. A hard link extracted on its own takes its listed mode; one created against its target shares the inode, so a mode of its own is an error. --- internal/slicer/slicer_test.go | 69 ++++++++++++++++++++++++++++++++++ internal/tarball/extract.go | 49 +++++++++++++++++++----- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/internal/slicer/slicer_test.go b/internal/slicer/slicer_test.go index 1ca81bdd..a09daed3 100644 --- a/internal/slicer/slicer_test.go +++ b/internal/slicer/slicer_test.go @@ -479,6 +479,75 @@ var slicerTests = []slicerTest{{ "/dir/file": "file 0600 cc55e2ec {test-package_myslice}", "/other-dir/file-copy": "file 0640 cc55e2ec {test-package_myslice}", }, +}, { + summary: "Same source copied twice, listed mode applies to one destination only", + slices: []setup.SliceKey{{"test-package", "myslice"}}, + release: map[string]string{ + "slices/mydir/test-package.yaml": ` + package: test-package + slices: + myslice: + contents: + /copy-a: {copy: /dir/file, mode: 0600} + /copy-b: {copy: /dir/file} + `, + }, + filesystem: map[string]string{ + "/copy-a": "file 0600 cc55e2ec", + "/copy-b": "file 0644 cc55e2ec", + }, + manifestPaths: map[string]string{ + "/copy-a": "file 0600 cc55e2ec {test-package_myslice}", + "/copy-b": "file 0644 cc55e2ec {test-package_myslice}", + }, +}, { + summary: "Hard link extracted on its own takes the listed mode", + 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: + /hardlink: {mode: 0600} + `, + }, + filesystem: map[string]string{ + "/hardlink": "file 0600 2c26b46b", + }, + manifestPaths: map[string]string{ + "/hardlink": "file 0600 2c26b46b {test-package_myslice}", + }, +}, { + summary: "Hard link sharing its target's inode cannot have its own mode", + 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: + /hardlink: {mode: 0600} + `, + }, + error: `cannot extract from package "test-package": path /hardlink is a hard link and cannot have its own mode 0600`, }, { summary: "Listed mode on a path a glob also matches is a diverging mode", slices: []setup.SliceKey{ diff --git a/internal/tarball/extract.go b/internal/tarball/extract.go index 40db2cba..2788be3b 100644 --- a/internal/tarball/extract.go +++ b/internal/tarball/extract.go @@ -180,19 +180,17 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { } var pathReader io.Reader = tarReader + tarMode := tarHeader.Mode for targetPath, extractInfos := range targetPaths { if contentIsCached { pathReader = bytes.NewReader(contentCache) } - mode := extractInfos[0].Mode - for _, extractInfo := range extractInfos { - if extractInfo.Mode != mode { - if mode < extractInfo.Mode { - mode, extractInfo.Mode = extractInfo.Mode, mode - } - return fmt.Errorf("path %s requested twice with diverging mode: 0%03o != 0%03o", targetPath, mode, extractInfo.Mode) - } + mode, err := listedMode(targetPath, extractInfos) + if err != nil { + return err } + // Each target starts from the mode in the tarball. + tarHeader.Mode = tarMode if mode != 0 { tarHeader.Mode = int64(mode) } @@ -235,7 +233,7 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { MakeParents: true, OverrideMode: true, } - err := options.Create(extractInfos, createOptions) + err = options.Create(extractInfos, createOptions) if err != nil && os.IsNotExist(err) && tarHeader.Typeflag == tar.TypeLink { // The hard link could not be created because the content // was not extracted previously. Add this hard link entry @@ -251,6 +249,10 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { pendingHardLinks[relLinkPath] = append(pendingHardLinks[relLinkPath], info) } else if err != nil { return err + } else if tarHeader.Typeflag == tar.TypeLink && mode != 0 { + // The link shares its inode with the target, so a mode of + // its own cannot be honoured. + return fmt.Errorf("path %s is a hard link and cannot have its own mode 0%03o", targetPath, mode) } } } @@ -332,6 +334,14 @@ func extractHardLinks(pkgReader io.ReadSeeker, opts *extractHardLinkOptions) err // created file as their target. absLink := filepath.Join(opts.TargetDir, links[0].path) // Extract the content to the first hard link path. + var mode uint + mode, err = listedMode(links[0].path, links[0].extractInfos) + if err != nil { + return err + } + if mode != 0 { + tarHeader.Mode = int64(mode) + } createOptions := &fsutil.CreateOptions{ Root: opts.TargetDir, Path: links[0].path, @@ -345,6 +355,13 @@ func extractHardLinks(pkgReader io.ReadSeeker, opts *extractHardLinkOptions) err // Create the remaining hard links. for _, link := range links[1:] { + mode, err = listedMode(link.path, link.extractInfos) + if err != nil { + return err + } + if mode != 0 { + return fmt.Errorf("path %s is a hard link and cannot have its own mode 0%03o", link.path, mode) + } createOptions := &fsutil.CreateOptions{ Root: opts.TargetDir, Path: link.path, @@ -375,6 +392,20 @@ func extractHardLinks(pkgReader io.ReadSeeker, opts *extractHardLinkOptions) err return nil } +// listedMode returns the mode the extract infos ask for, 0 when none does. +func listedMode(targetPath string, extractInfos []ExtractInfo) (uint, error) { + mode := extractInfos[0].Mode + for _, extractInfo := range extractInfos { + if extractInfo.Mode != mode { + if mode < extractInfo.Mode { + mode, extractInfo.Mode = extractInfo.Mode, mode + } + return 0, fmt.Errorf("path %s requested twice with diverging mode: 0%03o != 0%03o", targetPath, mode, extractInfo.Mode) + } + } + return mode, nil +} + func parentDirs(path string) []string { path = filepath.Clean(path) parents := make([]string, strings.Count(path, "/")) From 5feb12017bd91aff300bfaf7111c6a3e0284e49e Mon Sep 17 00:00:00 2001 From: lczyk Date: Sat, 10 Oct 2026 15:36:22 +0100 Subject: [PATCH 3/3] fix: hard links with a listed mode of their own A hard link with a listed mode shares the inode of its target when the modes agree, special bits included, and is otherwise extracted as a file of its own with that mode. Pending links to the same target are grouped by listed mode the same way instead of erroring, and the second pass creates missing parent directories as the first one does. --- internal/slicer/slicer_test.go | 189 ++++++++++++++++++++++++++++++++- internal/tarball/extract.go | 126 +++++++++++++--------- 2 files changed, 264 insertions(+), 51 deletions(-) diff --git a/internal/slicer/slicer_test.go b/internal/slicer/slicer_test.go index a09daed3..0214ddd2 100644 --- a/internal/slicer/slicer_test.go +++ b/internal/slicer/slicer_test.go @@ -527,7 +527,7 @@ var slicerTests = []slicerTest{{ "/hardlink": "file 0600 2c26b46b {test-package_myslice}", }, }, { - summary: "Hard link sharing its target's inode cannot have its own mode", + summary: "Hard link with a mode other than its target's is a file of its own", slices: []setup.SliceKey{{"test-package", "myslice"}}, pkgs: []*testutil.TestPackage{{ Name: "test-package", @@ -547,7 +547,192 @@ var slicerTests = []slicerTest{{ /hardlink: {mode: 0600} `, }, - error: `cannot extract from package "test-package": path /hardlink is a hard link and cannot have its own mode 0600`, + filesystem: map[string]string{ + "/file": "file 0644 2c26b46b", + "/hardlink": "file 0600 2c26b46b", + }, + manifestPaths: map[string]string{ + "/file": "file 0644 2c26b46b {test-package_myslice}", + "/hardlink": "file 0600 2c26b46b {test-package_myslice}", + }, +}, { + summary: "Pending hard links with different modes do not share an inode", + 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, "./hardlink1", "./file"), + testutil.Hrd(0644, "./hardlink2", "./file"), + }), + }}, + release: map[string]string{ + "slices/mydir/test-package.yaml": ` + package: test-package + slices: + myslice: + contents: + /hardlink1: {mode: 0600} + /hardlink2: + `, + }, + filesystem: map[string]string{ + "/hardlink1": "file 0600 2c26b46b", + "/hardlink2": "file 0644 2c26b46b", + }, + manifestPaths: map[string]string{ + "/hardlink1": "file 0600 2c26b46b {test-package_myslice}", + "/hardlink2": "file 0644 2c26b46b {test-package_myslice}", + }, +}, { + summary: "Pending hard links with the same mode share an inode", + 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, "./hardlink1", "./file"), + testutil.Hrd(0644, "./hardlink2", "./file"), + }), + }}, + release: map[string]string{ + "slices/mydir/test-package.yaml": ` + package: test-package + slices: + myslice: + contents: + /hardlink1: {mode: 0600} + /hardlink2: {mode: 0600} + `, + }, + filesystem: map[string]string{ + "/hardlink1": "file 0600 2c26b46b <1>", + "/hardlink2": "file 0600 2c26b46b <1>", + }, + manifestPaths: map[string]string{ + "/hardlink1": "file 0600 2c26b46b <1> {test-package_myslice}", + "/hardlink2": "file 0600 2c26b46b <1> {test-package_myslice}", + }, +}, { + summary: "Hard link with the same listed mode as its target shares its inode", + 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: {mode: 0600} + /hardlink: {mode: 0600} + `, + }, + filesystem: map[string]string{ + "/file": "file 0600 2c26b46b <1>", + "/hardlink": "file 0600 2c26b46b <1>", + }, + manifestPaths: map[string]string{ + "/file": "file 0600 2c26b46b <1> {test-package_myslice}", + "/hardlink": "file 0600 2c26b46b <1> {test-package_myslice}", + }, +}, { + summary: "Hard link with the same listed setuid mode as its target shares its inode", + 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: {mode: 04755} + /hardlink: {mode: 04755} + `, + }, + // The dump does not show the setuid bit; the shared inode is the point. + filesystem: map[string]string{ + "/file": "file 0755 2c26b46b <1>", + "/hardlink": "file 0755 2c26b46b <1>", + }, + manifestPaths: map[string]string{ + "/file": "file 0755 2c26b46b <1> {test-package_myslice}", + "/hardlink": "file 0755 2c26b46b <1> {test-package_myslice}", + }, +}, { + summary: "Hard link without the setuid bit of its target is a file of its own", + 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: {mode: 04755} + /hardlink: {mode: 0755} + `, + }, + filesystem: map[string]string{ + "/file": "file 0755 2c26b46b", + "/hardlink": "file 0755 2c26b46b", + }, + manifestPaths: map[string]string{ + "/file": "file 0755 2c26b46b {test-package_myslice}", + "/hardlink": "file 0755 2c26b46b {test-package_myslice}", + }, +}, { + summary: "Hard link with a listed mode under a directory the tarball does not carry", + 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, "./dir/hardlink", "./file"), + }), + }}, + release: map[string]string{ + "slices/mydir/test-package.yaml": ` + package: test-package + slices: + myslice: + contents: + /file: + /dir/hardlink: {mode: 0600} + `, + }, + filesystem: map[string]string{ + "/file": "file 0644 2c26b46b", + "/dir/": "dir 0755", + "/dir/hardlink": "file 0600 2c26b46b", + }, + manifestPaths: map[string]string{ + "/file": "file 0644 2c26b46b {test-package_myslice}", + "/dir/hardlink": "file 0600 2c26b46b {test-package_myslice}", + }, }, { summary: "Listed mode on a path a glob also matches is a diverging mode", slices: []setup.SliceKey{ diff --git a/internal/tarball/extract.go b/internal/tarball/extract.go index 2788be3b..ff1dff07 100644 --- a/internal/tarball/extract.go +++ b/internal/tarball/extract.go @@ -218,23 +218,35 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { } } link := tarHeader.Linkname + deferLink := false if tarHeader.Typeflag == tar.TypeLink { // A hard link requires the real path of the target file. link = filepath.Join(options.TargetDir, link) + if mode != 0 { + // A hard link shares the mode of its target, so one + // with a listed mode of its own is extracted as a file + // of its own in the second pass. + info, err := os.Lstat(link) + if err == nil && modeBits(info.Mode()) != modeBits(tarHeader.FileInfo().Mode()) { + deferLink = true + } + } } - // Create the entry itself. - createOptions := &fsutil.CreateOptions{ - Root: options.TargetDir, - Path: targetPath, - Mode: tarHeader.FileInfo().Mode(), - Data: pathReader, - Link: link, - MakeParents: true, - OverrideMode: true, + if !deferLink { + // Create the entry itself. + createOptions := &fsutil.CreateOptions{ + Root: options.TargetDir, + Path: targetPath, + Mode: tarHeader.FileInfo().Mode(), + Data: pathReader, + Link: link, + MakeParents: true, + OverrideMode: true, + } + err = options.Create(extractInfos, createOptions) } - err = options.Create(extractInfos, createOptions) - if err != nil && os.IsNotExist(err) && tarHeader.Typeflag == tar.TypeLink { + if deferLink || err != nil && os.IsNotExist(err) && tarHeader.Typeflag == tar.TypeLink { // The hard link could not be created because the content // was not extracted previously. Add this hard link entry // to the pending list to extract later. @@ -249,10 +261,6 @@ func extractData(pkgReader io.ReadSeeker, options *ExtractOptions) error { pendingHardLinks[relLinkPath] = append(pendingHardLinks[relLinkPath], info) } else if err != nil { return err - } else if tarHeader.Typeflag == tar.TypeLink && mode != 0 { - // The link shares its inode with the target, so a mode of - // its own cannot be honoured. - return fmt.Errorf("path %s is a hard link and cannot have its own mode 0%03o", targetPath, mode) } } } @@ -328,51 +336,66 @@ func extractHardLinks(pkgReader io.ReadSeeker, opts *extractHardLinkOptions) err continue } - // For a target path, the first hard link will be created as a file with - // the content of the target path. If there are more pending hard links, - // the remaining ones will be created as hard links with the newly - // created file as their target. - absLink := filepath.Join(opts.TargetDir, links[0].path) - // Extract the content to the first hard link path. - var mode uint - mode, err = listedMode(links[0].path, links[0].extractInfos) - if err != nil { - return err - } - if mode != 0 { - tarHeader.Mode = int64(mode) - } - createOptions := &fsutil.CreateOptions{ - Root: opts.TargetDir, - Path: links[0].path, - Mode: tarHeader.FileInfo().Mode(), - Data: tarReader, - } - err = opts.Create(links[0].extractInfos, createOptions) - if err != nil { - return err + // The pending links are grouped by listed mode. The first entry of a + // group is created as a file with the content of the target path and + // the rest of the group are hard links to it. Groups with different + // modes cannot share an inode, so each gets a file of its own. + groups := make(map[uint][]pendingHardLink) + var modes []uint + for _, link := range links { + mode, err := listedMode(link.path, link.extractInfos) + if err != nil { + return err + } + if _, ok := groups[mode]; !ok { + modes = append(modes, mode) + } + groups[mode] = append(groups[mode], link) } - - // Create the remaining hard links. - for _, link := range links[1:] { - mode, err = listedMode(link.path, link.extractInfos) + var content []byte + if len(groups) > 1 { + content, err = io.ReadAll(tarReader) if err != nil { return err } + } + tarMode := tarHeader.Mode + for _, mode := range modes { + group := groups[mode] + tarHeader.Mode = tarMode if mode != 0 { - return fmt.Errorf("path %s is a hard link and cannot have its own mode 0%03o", link.path, mode) + tarHeader.Mode = int64(mode) + } + var data io.Reader = tarReader + if content != nil { + data = bytes.NewReader(content) } + absLink := filepath.Join(opts.TargetDir, group[0].path) createOptions := &fsutil.CreateOptions{ - Root: opts.TargetDir, - Path: link.path, - Mode: tarHeader.FileInfo().Mode(), - // Link to the first file extracted for the hard links. - Link: absLink, + Root: opts.TargetDir, + Path: group[0].path, + Mode: tarHeader.FileInfo().Mode(), + Data: data, + MakeParents: true, } - err := opts.Create(link.extractInfos, createOptions) + err = opts.Create(group[0].extractInfos, createOptions) if err != nil { return err } + for _, link := range group[1:] { + createOptions := &fsutil.CreateOptions{ + Root: opts.TargetDir, + Path: link.path, + Mode: tarHeader.FileInfo().Mode(), + // Link to the first file extracted for the group. + Link: absLink, + MakeParents: true, + } + err = opts.Create(link.extractInfos, createOptions) + if err != nil { + return err + } + } } delete(opts.pendingLinks, sourcePath) } @@ -392,6 +415,11 @@ func extractHardLinks(pkgReader io.ReadSeeker, opts *extractHardLinkOptions) err return nil } +// modeBits returns the permission and special bits of a mode. +func modeBits(mode fs.FileMode) fs.FileMode { + return mode & (fs.ModePerm | fs.ModeSetuid | fs.ModeSetgid | fs.ModeSticky) +} + // listedMode returns the mode the extract infos ask for, 0 when none does. func listedMode(targetPath string, extractInfos []ExtractInfo) (uint, error) { mode := extractInfos[0].Mode