diff --git a/cmd/image-builder/bib_legacy.go b/cmd/image-builder/bib_legacy.go index af09cc485f..e2bf8ab348 100644 --- a/cmd/image-builder/bib_legacy.go +++ b/cmd/image-builder/bib_legacy.go @@ -80,7 +80,7 @@ func manifestForLegacyISO(imgref, buildImgref, rootFs, rpmCacheRoot string, conf baseCnt = container buildCnt = container - sourceinfo, err = osinfo.Load(container.Root()) + sourceinfo, err = osinfo.Load(container.RootFS()) if err != nil { return nil, nil, err } @@ -111,11 +111,11 @@ func manifestForLegacyISO(imgref, buildImgref, rootFs, rpmCacheRoot string, conf } }() - sourceinfo, err = osinfo.Load(baseCnt.Root()) + sourceinfo, err = osinfo.Load(baseCnt.RootFS()) if err != nil { return nil, nil, err } - buildSourceinfo, err = osinfo.Load(buildCnt.Root()) + buildSourceinfo, err = osinfo.Load(buildCnt.RootFS()) if err != nil { return nil, nil, err } diff --git a/pkg/bib/blueprintload/config.go b/pkg/bib/blueprintload/config.go index 4fa4f261dd..4940bfe842 100644 --- a/pkg/bib/blueprintload/config.go +++ b/pkg/bib/blueprintload/config.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "os" "path/filepath" @@ -103,6 +104,24 @@ func Load(path string) (*blueprint.Blueprint, error) { return loadConfig(path) } +// LoadFS loads the blueprint at path from the given fs.FS, it auto +// detects if the blueprint is in json/toml based on the filename. +func LoadFS(fsys fs.FS, path string) (*blueprint.Blueprint, error) { + data, err := fs.ReadFile(fsys, path) + if err != nil { + return nil, err + } + + switch filepath.Ext(path) { + case ".json": + return decodeJsonBuildConfig(bytes.NewReader(data), path) + case ".toml": + return decodeTomlBuildConfig(bytes.NewReader(data), path) + default: + return nil, fmt.Errorf("unsupported file extension for %q", path) + } +} + func readWithFallback(userConfig string) (*blueprint.Blueprint, error) { // user asked for an explicit config if userConfig != "" { diff --git a/pkg/bib/osinfo/osinfo.go b/pkg/bib/osinfo/osinfo.go index 33005deea1..62b22e67ff 100644 --- a/pkg/bib/osinfo/osinfo.go +++ b/pkg/bib/osinfo/osinfo.go @@ -4,9 +4,9 @@ import ( "bufio" "errors" "fmt" + "io/fs" "os" "path" - "path/filepath" "slices" "strings" @@ -112,19 +112,19 @@ func validateOSRelease(osrelease map[string]string) error { return nil } -func uefiVendor(root string) (string, error) { +func uefiVendor(fsys fs.FS) (string, error) { var searchPath = []string{ "usr/lib/bootupd/updates/EFI/*", "usr/lib/efi/shim/*/EFI/*", } for _, baseDir := range searchPath { - dents, err := filepath.Glob(filepath.Join(root, baseDir)) + dents, err := fs.Glob(fsys, baseDir) if err != nil { return "", err } // best-effort search: return the first directory that's not "BOOT" for _, p := range dents { - entry, err := os.Stat(p) + entry, err := fs.Stat(fsys, p) if err != nil { return "", err } @@ -141,9 +141,9 @@ func uefiVendor(root string) (string, error) { return "", fmt.Errorf("cannot find UEFI vendor in %s", searchPath) } -func readSelinuxPolicy(root string) (string, error) { +func readSelinuxPolicy(fsys fs.FS) (string, error) { configPath := "etc/selinux/config" - f, err := os.Open(path.Join(root, configPath)) + f, err := fsys.Open(configPath) if err != nil { return "", fmt.Errorf("cannot read selinux config %s: %w", configPath, err) } @@ -174,18 +174,18 @@ func readSelinuxPolicy(root string) (string, error) { return policy, nil } -func readImageCustomization(root string) (*blueprint.Customizations, error) { +func readImageCustomization(fsys fs.FS) (*blueprint.Customizations, error) { // note that we only look at the 'old' search path here, we do want to // look in the new path as well but i'd like to only support the actual // blueprint format there instead of buildconfig as well - prefix := path.Join(root, searchPaths[1]) + prefix := searchPaths[1] - config, err := blueprintload.Load(path.Join(prefix, "config.json")) + config, err := blueprintload.LoadFS(fsys, path.Join(prefix, "config.json")) if err != nil && !os.IsNotExist(err) { return nil, err } if config == nil { - config, err = blueprintload.Load(path.Join(prefix, "config.toml")) + config, err = blueprintload.LoadFS(fsys, path.Join(prefix, "config.toml")) if err != nil && !os.IsNotExist(err) { return nil, err } @@ -203,11 +203,11 @@ type diskYAML struct { PartitionTable *disk.PartitionTable `json:"partition_table" yaml:"partition_table"` } -func readDiskYaml(root string) (*diskYAML, error) { +func readDiskYaml(fsys fs.FS) (*diskYAML, error) { for _, prefixPath := range searchPaths { var disk diskYAML - p := path.Join(root, prefixPath, "disk.yaml") - f, err := os.Open(p) + p := path.Join(prefixPath, "disk.yaml") + f, err := fsys.Open(p) if err != nil { if os.IsNotExist(err) { continue @@ -240,11 +240,11 @@ type isoYAML struct { } `json:"grub2" yaml:"grub2"` } -func readISOYaml(root string) (*isoYAML, error) { +func readISOYaml(fsys fs.FS) (*isoYAML, error) { for _, prefixPath := range searchPaths { var iso isoYAML - p := path.Join(root, prefixPath, "iso.yaml") - f, err := os.Open(p) + p := path.Join(prefixPath, "iso.yaml") + f, err := fsys.Open(p) if err != nil { if os.IsNotExist(err) { continue @@ -263,9 +263,9 @@ func readISOYaml(root string) (*isoYAML, error) { return nil, nil } -func readKernelInfo(root string) (*KernelInfo, error) { - modulesDir := path.Join(root, "usr/lib/modules") - entries, err := os.ReadDir(modulesDir) +func readKernelInfo(fsys fs.FS) (*KernelInfo, error) { + modulesDir := "usr/lib/modules" + entries, err := fs.ReadDir(fsys, modulesDir) if err != nil { return nil, err } @@ -280,11 +280,11 @@ func readKernelInfo(root string) (*KernelInfo, error) { // pick the first here kernelDir := path.Join(modulesDir, e.Name()) kernelPath := path.Join(kernelDir, "vmlinuz") - _, err := os.Stat(kernelPath) + _, err := fs.Stat(fsys, kernelPath) if err == nil { abootPath := path.Join(kernelDir, "aboot.img") - _, err := os.Stat(abootPath) + _, err := fs.Stat(fsys, abootPath) hasAbootImg := err == nil return &KernelInfo{ Version: e.Name(), @@ -296,8 +296,8 @@ func readKernelInfo(root string) (*KernelInfo, error) { return nil, fmt.Errorf("no valid kernel modules directory") } -func Load(root string) (*Info, error) { - osrelease, err := distro.ReadOSReleaseFromTree(root) +func Load(fsys fs.FS) (*Info, error) { + osrelease, err := distro.ReadOSReleaseFromFS(fsys) if err != nil { return nil, err } @@ -305,17 +305,17 @@ func Load(root string) (*Info, error) { return nil, err } - vendor, err := uefiVendor(root) + vendor, err := uefiVendor(fsys) if err != nil { olog.Printf("cannot read UEFI vendor: %v, setting it to none", err) } - customization, err := readImageCustomization(root) + customization, err := readImageCustomization(fsys) if err != nil { return nil, err } - diskYaml, err := readDiskYaml(root) + diskYaml, err := readDiskYaml(fsys) if err != nil { return nil, err } @@ -326,7 +326,7 @@ func Load(root string) (*Info, error) { pt = diskYaml.PartitionTable } - isoYaml, err := readISOYaml(root) + isoYaml, err := readISOYaml(fsys) if err != nil { return nil, err } @@ -348,12 +348,12 @@ func Load(root string) (*Info, error) { } } - kernelInfo, err := readKernelInfo(root) + kernelInfo, err := readKernelInfo(fsys) if err != nil { olog.Printf("cannot read kernel info: %v", err) } - selinuxPolicy, err := readSelinuxPolicy(root) + selinuxPolicy, err := readSelinuxPolicy(fsys) if err != nil { olog.Printf("cannot read selinux policy: %v, setting it to none", err) } diff --git a/pkg/bib/osinfo/osinfo_test.go b/pkg/bib/osinfo/osinfo_test.go index 8bda80ffae..16a0a50a23 100644 --- a/pkg/bib/osinfo/osinfo_test.go +++ b/pkg/bib/osinfo/osinfo_test.go @@ -123,7 +123,7 @@ func TestLoadInfo(t *testing.T) { {"sad-no-id", "", "40", "Fedora Linux", "fedora", "platform:f40", "", "", "json", "missing ID in os-release"}, {"sad-no-id", "fedora", "", "Fedora Linux", "fedora", "platform:f40", "", "", "json", "missing VERSION_ID in os-release"}, {"sad-no-id", "fedora", "40", "", "fedora", "platform:f40", "", "", "json", "missing NAME in os-release"}, - {"sad-broken-json", "fedora", "40", "Fedora Linux", "fedora", "platform:f40", "coreos", "", "broken", "cannot decode \"$ROOT/usr/lib/bootc-image-builder/config.json\": unexpected EOF"}, + {"sad-broken-json", "fedora", "40", "Fedora Linux", "fedora", "platform:f40", "coreos", "", "broken", "cannot decode \"usr/lib/bootc-image-builder/config.json\": unexpected EOF"}, } for _, c := range cases { @@ -139,7 +139,7 @@ func TestLoadInfo(t *testing.T) { } - info, err := Load(root) + info, err := Load(os.DirFS(root)) if c.errorStr != "" { require.EqualError(t, err, strings.ReplaceAll(c.errorStr, "$ROOT", root)) @@ -207,7 +207,7 @@ func TestLoadInfoKernel(t *testing.T) { filePath := path.Join(baseDir, file) require.NoError(t, os.WriteFile(filePath, nil, 0644)) } - info, err := readKernelInfo(root) + info, err := readKernelInfo(os.DirFS(root)) if c.expected == nil { require.Error(t, err) assert.Nil(t, info) @@ -256,7 +256,7 @@ func TestLoadInfoPartitionTableHappy(t *testing.T) { writeOSRelease(t, root, "fedora", "40", "Fedora Linux", "fedora", "platform:f40", "coreos") createPartitionTable(t, root, fakePartitionTableYAML, dest) - info, err := Load(root) + info, err := Load(os.DirFS(root)) require.NoError(t, err) assert.Equal(t, &disk.PartitionTable{ Type: disk.PT_GPT, @@ -277,8 +277,8 @@ func TestLoadInfoPartitionTableSad(t *testing.T) { writeOSRelease(t, root, "fedora", "40", "Fedora Linux", "fedora", "platform:f40", "coreos") createPartitionTable(t, root, "@invalidYAML", "/usr/lib/bootc-image-builder/disk.yaml") - _, err := Load(root) - assert.EqualError(t, err, fmt.Sprintf(`cannot parse disk definitions from "%s/usr/lib/bootc-image-builder/disk.yaml": yaml: found character that cannot start any token`, root)) + _, err := Load(os.DirFS(root)) + assert.EqualError(t, err, `cannot parse disk definitions from "usr/lib/bootc-image-builder/disk.yaml": yaml: found character that cannot start any token`) } var fakeISOYAML = ` @@ -319,7 +319,7 @@ func TestLoadInfoISOHappy(t *testing.T) { writeOSRelease(t, root, "fedora", "40", "Fedora Linux", "fedora", "platform:f40", "coreos") createISO(t, root, fakeISOYAML, dest) - info, err := Load(root) + info, err := Load(os.DirFS(root)) require.NoError(t, err) assert.Equal(t, "My-ISO", info.ISOInfo.Label) @@ -345,8 +345,8 @@ func TestLoadInfoISOSad(t *testing.T) { writeOSRelease(t, root, "fedora", "40", "Fedora Linux", "fedora", "platform:f40", "coreos") createISO(t, root, "@invalidYAML", "/usr/lib/bootc-image-builder/iso.yaml") - _, err := Load(root) - assert.EqualError(t, err, fmt.Sprintf(`cannot parse iso definitions from "%s/usr/lib/bootc-image-builder/iso.yaml": yaml: found character that cannot start any token`, root)) + _, err := Load(os.DirFS(root)) + assert.EqualError(t, err, `cannot parse iso definitions from "usr/lib/bootc-image-builder/iso.yaml": yaml: found character that cannot start any token`) } func TestLoadInfoUEFIVendorSearchPath(t *testing.T) { @@ -356,7 +356,7 @@ func TestLoadInfoUEFIVendorSearchPath(t *testing.T) { err := os.MkdirAll(path.Join(root, "usr/lib/efi/shim/1.64/EFI/fedora"), 0755) assert.NoError(t, err) - info, err := Load(root) + info, err := Load(os.DirFS(root)) assert.NoError(t, err) assert.Equal(t, "fedora", info.UEFIVendor) } diff --git a/pkg/bootc/resolver.go b/pkg/bootc/resolver.go index 963aba9034..0c4da3760b 100644 --- a/pkg/bootc/resolver.go +++ b/pkg/bootc/resolver.go @@ -48,21 +48,27 @@ func isPodmanRootless() (bool, error) { return false, nil } +func isUnprivileged() bool { + return os.Geteuid() != 0 +} + // Container is a simpler wrapper around a running podman container. // This type isn't meant as a general-purpose container management tool, but // as an opinonated library for bootc-image-builder. type Container struct { - ref string - id string - root string - arch string - storeOpts []string + ref string + id string + root string + arch string + storeOpts []string + unprivileged bool } // Initialise a new container from the given image reference. func NewContainer(ref string) (*Container, error) { cnt := &Container{ - ref: ref, + ref: ref, + unprivileged: isUnprivileged(), } if err := cnt.start("none", false); err != nil { return nil, err @@ -75,7 +81,8 @@ func NewContainer(ref string) (*Container, error) { // host networking and mount secrets from the host if available. func NewContainerWithRepos(ref string) (*Container, error) { cnt := &Container{ - ref: ref, + ref: ref, + unprivileged: isUnprivileged(), } if err := cnt.start("host", true); err != nil { return nil, err @@ -147,7 +154,11 @@ func (cnt *Container) start(network string, mountSecrets bool) error { return err } - args = []string{"mount"} + args = []string{} + if cnt.unprivileged { + args = append(args, "unshare", "podman") + } + args = append(args, "mount") args = append(args, cnt.storeOpts...) args = append(args, cnt.id) @@ -199,7 +210,7 @@ func (c *Container) ResolveInfo() (*Info, error) { Arch: c.Arch(), } - os, err := osinfo.Load(c.Root()) + os, err := osinfo.Load(c.RootFS()) if err != nil { return nil, err } @@ -245,9 +256,11 @@ func (c *Container) ResolveBuildInfo() (*Info, error) { }, nil } -// Root returns the root directory of the container as available on the host. -func (c *Container) Root() string { - return c.root +func (c *Container) RootFS() fs.FS { + if c.unprivileged { + return newPodmanUnshareFS(c.root) + } + return os.DirFS(c.root) } // Arch returns the architecture of the container @@ -550,6 +563,10 @@ func (cnt *Container) setupRunSecrets() error { } func (cnt *Container) NewContainerSolver(cacheRoot string, architecture arch.Arch, sourceInfo *osinfo.Info) (*depsolvednf.Solver, error) { + if cnt.unprivileged { + return nil, errors.New("Container depsolver is only supported when running as root") + } + solver := depsolvednf.NewSolver( sourceInfo.OSRelease.PlatformID, sourceInfo.OSRelease.VersionID, diff --git a/pkg/bootc/resolver_test.go b/pkg/bootc/resolver_test.go index 796afa3e0d..20a5862ad5 100644 --- a/pkg/bootc/resolver_test.go +++ b/pkg/bootc/resolver_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "flag" "fmt" + "io/fs" "os" "os/exec" "path" @@ -112,8 +113,7 @@ func TestNew(t *testing.T) { assert.Equal(t, testingImage, info.Image) assert.Equal(t, "running", info.State) - root := c.Root() - osRelease, err := os.ReadFile(path.Join(root, "etc/os-release")) + osRelease, err := fs.ReadFile(c.RootFS(), "etc/os-release") require.NoError(t, err) assert.Contains(t, string(osRelease), `ID="rhel"`) @@ -161,9 +161,7 @@ func TestCopyInto(t *testing.T) { err = c.CopyInto(testfile, "/testfile") require.NoError(t, err) - root := c.Root() - testfileInContainer := path.Join(root, "testfile") - testfileContent, err := os.ReadFile(testfileInContainer) + testfileContent, err := fs.ReadFile(c.RootFS(), "testfile") require.NoError(t, err) require.Equal(t, "Hello, world!", string(testfileContent)) } diff --git a/pkg/bootc/solver_test.go b/pkg/bootc/solver_test.go index a1807b3815..e0fb128310 100644 --- a/pkg/bootc/solver_test.go +++ b/pkg/bootc/solver_test.go @@ -58,7 +58,7 @@ func TestDepsolveDNFWorks(t *testing.T) { err = cnt.InitDNF() require.NoError(t, err) - sourceInfo, err := osinfo.Load(cnt.Root()) + sourceInfo, err := osinfo.Load(cnt.RootFS()) require.NoError(t, err) solver, err := cnt.NewContainerSolver(cacheRoot, arch.Current(), sourceInfo) require.NoError(t, err) @@ -136,7 +136,7 @@ func TestDepsolveDNFWorkWithSubscribedContent(t *testing.T) { err = cnt.InitDNF() require.NoError(t, err) - sourceInfo, err := osinfo.Load(cnt.Root()) + sourceInfo, err := osinfo.Load(cnt.RootFS()) require.NoError(t, err) solver, err := cnt.NewContainerSolver(cacheRoot, arch.ARCH_X86_64, sourceInfo) require.NoError(t, err) diff --git a/pkg/bootc/unsharefs.go b/pkg/bootc/unsharefs.go new file mode 100644 index 0000000000..e737aadde5 --- /dev/null +++ b/pkg/bootc/unsharefs.go @@ -0,0 +1,240 @@ +package bootc + +import ( + "bytes" + "fmt" + "io" + "io/fs" + "os/exec" + "path" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +const fieldSep = "\x1f" + +// podmanUnshareFS is an fs.FS that accesses a container or image +// mount that exists inside the rootless "podman unshare" mount +// namespace. +type podmanUnshareFS struct { + root string + argPrefix []string +} + +// Ensure we implement the related ifaces +var _ fs.FS = podmanUnshareFS{} +var _ fs.ReadFileFS = podmanUnshareFS{} +var _ fs.StatFS = podmanUnshareFS{} +var _ fs.ReadDirFS = podmanUnshareFS{} + +func newPodmanUnshareFS(root string) podmanUnshareFS { + return podmanUnshareFS{root: root, argPrefix: []string{"podman", "unshare"}} +} + +func (fsys podmanUnshareFS) run(args ...string) ([]byte, error) { + argv := append(append([]string{}, fsys.argPrefix...), args...) + /* #nosec G204 */ + cmd := exec.Command(argv[0], argv[1:]...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("%w\nstderr:\n%s", err, stderr.String()) + } + return stdout.Bytes(), nil +} + +func (fsys podmanUnshareFS) fullPath(name string) string { + return filepath.Join(fsys.root, name) +} + +func (fsys podmanUnshareFS) exists(name string) bool { + _, err := fsys.run("test", "-e", fsys.fullPath(name)) + return err == nil +} + +// Ensure os.IsNotExist() works on the errors +func (fsys podmanUnshareFS) wrapErr(op, name string, err error) error { + if !fsys.exists(name) { + return &fs.PathError{Op: op, Path: name, Err: fs.ErrNotExist} + } + return &fs.PathError{Op: op, Path: name, Err: err} +} + +func (fsys podmanUnshareFS) ReadFile(name string) ([]byte, error) { + if !fs.ValidPath(name) { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid} + } + out, err := fsys.run("cat", fsys.fullPath(name)) + if err != nil { + return nil, fsys.wrapErr("open", name, err) + } + return out, nil +} + +// Internal stat that doesn't validate path +func (fsys podmanUnshareFS) stat(op, name string) (fs.FileInfo, error) { + // -L => follows symlinks + out, err := fsys.run("stat", "-L", "-c", "%s"+fieldSep+"%F", fsys.fullPath(name)) + if err != nil { + return nil, fsys.wrapErr(op, name, err) + } + parts := strings.SplitN(strings.TrimRight(string(out), "\n"), fieldSep, 2) + size, _ := strconv.ParseInt(parts[0], 10, 64) + isDir := len(parts) > 1 && parts[1] == "directory" + return fileInfo{name: path.Base(name), size: size, isDir: isDir}, nil +} + +func (fsys podmanUnshareFS) Stat(name string) (fs.FileInfo, error) { + if !fs.ValidPath(name) { + return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrInvalid} + } + return fsys.stat("stat", name) +} + +func (fsys podmanUnshareFS) ReadDir(name string) ([]fs.DirEntry, error) { + if !fs.ValidPath(name) { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid} + } + // -L follows symlinks so that symlinked directories report as 'd'. + out, err := fsys.run("find", "-L", fsys.fullPath(name), + "-maxdepth", "1", "-mindepth", "1", "-printf", "%y"+fieldSep+"%s"+fieldSep+`%f\0`) + if err != nil { + return nil, fsys.wrapErr("open", name, err) + } + + var entries []fs.DirEntry + for _, rec := range bytes.Split(out, []byte{0}) { + if len(rec) == 0 { + continue + } + f := strings.SplitN(string(rec), fieldSep, 3) + if len(f) != 3 { + continue + } + size, _ := strconv.ParseInt(f[1], 10, 64) + entries = append(entries, dirEntry{name: f[2], isDir: f[0] == "d", size: size}) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + return entries, nil +} + +func (fsys podmanUnshareFS) Open(name string) (fs.File, error) { + if !fs.ValidPath(name) { + return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid} + } + fi, err := fsys.stat("open", name) + if err != nil { + return nil, err + } + if fi.IsDir() { + return &dirFile{fsys: fsys, name: name, info: fi}, nil + } + data, err := fsys.ReadFile(name) + if err != nil { + return nil, err + } + return &memFile{ + reader: bytes.NewReader(data), + info: fileInfo{name: path.Base(name), size: int64(len(data))}, + }, nil +} + +type fileInfo struct { + name string + size int64 + isDir bool +} + +func (fi fileInfo) Name() string { return fi.name } +func (fi fileInfo) Size() int64 { return fi.size } +func (fi fileInfo) Mode() fs.FileMode { + if fi.isDir { + return fs.ModeDir | 0555 + } + return 0444 +} +func (fi fileInfo) ModTime() time.Time { return time.Time{} } +func (fi fileInfo) IsDir() bool { return fi.isDir } +func (fi fileInfo) Sys() any { return nil } + +type dirEntry struct { + name string + isDir bool + size int64 +} + +func (e dirEntry) Name() string { return e.name } +func (e dirEntry) IsDir() bool { return e.isDir } +func (e dirEntry) Type() fs.FileMode { + if e.isDir { + return fs.ModeDir + } + return 0 +} +func (e dirEntry) Info() (fs.FileInfo, error) { + return fileInfo{name: e.name, isDir: e.isDir, size: e.size}, nil +} + +type memFile struct { + reader *bytes.Reader + info fs.FileInfo +} + +func (f *memFile) Stat() (fs.FileInfo, error) { return f.info, nil } +func (f *memFile) Read(p []byte) (int, error) { return f.reader.Read(p) } +func (f *memFile) Close() error { return nil } + +type dirFile struct { + fsys podmanUnshareFS + name string + info fs.FileInfo + entries []fs.DirEntry + offset int +} + +// Ensure we implement the directory file iface +var _ fs.ReadDirFile = (*dirFile)(nil) + +func (d *dirFile) Stat() (fs.FileInfo, error) { return d.info, nil } +func (d *dirFile) Close() error { return nil } + +// Read on a directory is not supported, matching *os.File behaviour. +func (d *dirFile) Read([]byte) (int, error) { + return 0, &fs.PathError{Op: "read", Path: d.name, Err: fs.ErrInvalid} +} + +func (d *dirFile) ReadDir(n int) ([]fs.DirEntry, error) { + if d.entries == nil { + entries, err := d.fsys.ReadDir(d.name) + if err != nil { + return nil, err + } + // Use a non-nil slice so a subsequent call is not treated as + // uninitialised even when the directory is empty. + if entries == nil { + entries = []fs.DirEntry{} + } + d.entries = entries + } + + if n <= 0 { + rest := d.entries[d.offset:] + d.offset = len(d.entries) + return rest, nil + } + + if d.offset >= len(d.entries) { + return nil, io.EOF + } + end := d.offset + n + if end > len(d.entries) { + end = len(d.entries) + } + batch := d.entries[d.offset:end] + d.offset = end + return batch, nil +} diff --git a/pkg/bootc/unsharefs_test.go b/pkg/bootc/unsharefs_test.go new file mode 100644 index 0000000000..65a9a683b4 --- /dev/null +++ b/pkg/bootc/unsharefs_test.go @@ -0,0 +1,215 @@ +package bootc + +import ( + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/osbuild/image-builder/pkg/bib/osinfo" +) + +func newTestFS(t *testing.T, root string) podmanUnshareFS { + t.Helper() + for _, tool := range []string{"cat", "stat", "find", "test"} { + if _, err := exec.LookPath(tool); err != nil { + // "test" is usually a shell builtin, resolve via sh if missing + if tool == "test" { + continue + } + t.Skipf("skipping: %q not found in PATH", tool) + } + } + return podmanUnshareFS{root: root} +} + +func TestPodmanUnshareFSReadFile(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "hello"), []byte("world"), 0644)) + fsys := newTestFS(t, root) + + content, err := fs.ReadFile(fsys, "hello") + require.NoError(t, err) + assert.Equal(t, "world", string(content)) + + _, err = fs.ReadFile(fsys, "missing") + assert.True(t, os.IsNotExist(err), "expected IsNotExist, got %v", err) +} + +func TestPodmanUnshareFSStat(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "file"), []byte("12345"), 0644)) + require.NoError(t, os.Mkdir(filepath.Join(root, "dir"), 0755)) + fsys := newTestFS(t, root) + + fi, err := fs.Stat(fsys, "file") + require.NoError(t, err) + assert.False(t, fi.IsDir()) + assert.Equal(t, "file", fi.Name()) + assert.Equal(t, int64(5), fi.Size()) + + fi, err = fs.Stat(fsys, "dir") + require.NoError(t, err) + assert.True(t, fi.IsDir()) + + _, err = fs.Stat(fsys, "nope") + assert.True(t, os.IsNotExist(err), "expected IsNotExist, got %v", err) +} + +func TestPodmanUnshareFSStatFollowsSymlink(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, "real"), 0755)) + require.NoError(t, os.Symlink("real", filepath.Join(root, "link"))) + fsys := newTestFS(t, root) + + fi, err := fs.Stat(fsys, "link") + require.NoError(t, err) + assert.True(t, fi.IsDir(), "stat should follow the symlink to the directory") +} + +func TestPodmanUnshareFSReadDir(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, "sub"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "sub", "b-file"), nil, 0644)) + require.NoError(t, os.Mkdir(filepath.Join(root, "sub", "a-dir"), 0755)) + fsys := newTestFS(t, root) + + entries, err := fs.ReadDir(fsys, "sub") + require.NoError(t, err) + require.Len(t, entries, 2) + // entries must be sorted by name + assert.Equal(t, "a-dir", entries[0].Name()) + assert.True(t, entries[0].IsDir()) + assert.Equal(t, "b-file", entries[1].Name()) + assert.False(t, entries[1].IsDir()) + + _, err = fs.ReadDir(fsys, "does-not-exist") + assert.True(t, os.IsNotExist(err), "expected IsNotExist, got %v", err) +} + +func TestPodmanUnshareFSGlob(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "usr/lib/bootupd/updates/EFI") + require.NoError(t, os.MkdirAll(filepath.Join(base, "fedora"), 0755)) + require.NoError(t, os.MkdirAll(filepath.Join(base, "BOOT"), 0755)) + fsys := newTestFS(t, root) + + matches, err := fs.Glob(fsys, "usr/lib/bootupd/updates/EFI/*") + require.NoError(t, err) + assert.ElementsMatch(t, []string{ + "usr/lib/bootupd/updates/EFI/BOOT", + "usr/lib/bootupd/updates/EFI/fedora", + }, matches) +} + +func TestPodmanUnshareFSOpen(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "data"), []byte("payload"), 0644)) + fsys := newTestFS(t, root) + + f, err := fsys.Open("data") + require.NoError(t, err) + defer f.Close() + + fi, err := f.Stat() + require.NoError(t, err) + assert.Equal(t, "data", fi.Name()) + + buf := make([]byte, 7) + n, err := f.Read(buf) + require.NoError(t, err) + assert.Equal(t, "payload", string(buf[:n])) +} + +func TestPodmanUnshareFSOpenDir(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, "dir"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "dir", "b-file"), nil, 0644)) + require.NoError(t, os.Mkdir(filepath.Join(root, "dir", "a-dir"), 0755)) + fsys := newTestFS(t, root) + + f, err := fsys.Open("dir") + require.NoError(t, err) + defer f.Close() + + fi, err := f.Stat() + require.NoError(t, err) + assert.True(t, fi.IsDir()) + + // Reading bytes from a directory must fail. + _, err = f.Read(make([]byte, 1)) + assert.Error(t, err) + + // The returned file must implement fs.ReadDirFile. + rdf, ok := f.(fs.ReadDirFile) + require.True(t, ok, "directory file must implement fs.ReadDirFile") + + // Paged reads: one entry at a time, then io.EOF. + first, err := rdf.ReadDir(1) + require.NoError(t, err) + require.Len(t, first, 1) + assert.Equal(t, "a-dir", first[0].Name()) + + second, err := rdf.ReadDir(1) + require.NoError(t, err) + require.Len(t, second, 1) + assert.Equal(t, "b-file", second[0].Name()) + + _, err = rdf.ReadDir(1) + assert.ErrorIs(t, err, io.EOF) +} + +func TestPodmanUnshareFSOpenMissing(t *testing.T) { + fsys := newTestFS(t, t.TempDir()) + + _, err := fsys.Open("nope") + assert.True(t, os.IsNotExist(err), "expected IsNotExist, got %v", err) + + var pathErr *fs.PathError + require.ErrorAs(t, err, &pathErr) + assert.Equal(t, "open", pathErr.Op) +} + +func TestPodmanUnshareFSTestFS(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "hello"), []byte("world"), 0644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "sub/nested"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "sub/file"), []byte("data"), 0644)) + fsys := newTestFS(t, root) + + require.NoError(t, fstest.TestFS(fsys, "hello", "sub/file", "sub/nested")) +} + +func TestPodmanUnshareFSWithOsinfo(t *testing.T) { + root := t.TempDir() + fsys := newTestFS(t, root) + + writeFile := func(rel, content string) { + p := filepath.Join(root, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(p), 0755)) + require.NoError(t, os.WriteFile(p, []byte(content), 0644)) + } + + writeFile("etc/os-release", `ID="fedora" +VERSION_ID="40" +NAME="Fedora Linux" +PLATFORM_ID="platform:f40" +`) + writeFile("usr/lib/modules/6.1.0-1.fc40.x86_64/vmlinuz", "kernel") + require.NoError(t, os.MkdirAll(filepath.Join(root, "usr/lib/bootupd/updates/EFI/fedora"), 0755)) + + info, err := osinfo.Load(fsys) + require.NoError(t, err) + assert.Equal(t, "fedora", info.OSRelease.ID) + assert.Equal(t, "40", info.OSRelease.VersionID) + assert.Equal(t, "Fedora Linux", info.OSRelease.Name) + assert.Equal(t, "fedora", info.UEFIVendor) + require.NotNil(t, info.KernelInfo) + assert.Equal(t, "6.1.0-1.fc40.x86_64", info.KernelInfo.Version) +} diff --git a/pkg/container/client.go b/pkg/container/client.go index 6a1517002d..b0eefc92a7 100644 --- a/pkg/container/client.go +++ b/pkg/container/client.go @@ -113,7 +113,41 @@ type Client struct { policy *signature.Policy sysCtx *types.SystemContext - store string // another store location other than the main one, useful for testing + store string // graphroot of the container storage + runroot string // runroot of the container storage +} + +func getXdgDataDir() string { + dataHome := os.Getenv("XDG_DATA_HOME") + if dataHome == "" { + home := os.Getenv("HOME") + if home != "" { + dataHome = filepath.Join(home, ".local", "share") + } + } + return dataHome +} + +func getXdgRuntimeDir() string { + runtimeDir := os.Getenv("XDG_RUNTIME_DIR") + if runtimeDir == "" { + runtimeDir = fmt.Sprintf("/run/user/%d", os.Getuid()) + } + return runtimeDir +} + +func defaultStorePaths() (string, string) { + graphRoot := "/var/lib/containers/storage" + runRoot := "/run/containers/storage" + if os.Geteuid() != 0 { + dataDir := getXdgDataDir() + if dataDir != "" { + graphRoot = filepath.Join(dataDir, "containers", "storage") + runRoot = filepath.Join(getXdgRuntimeDir(), "containers") + } + } + + return graphRoot, runRoot } // NewClient constructs a new Client for target with default options. @@ -157,8 +191,8 @@ func NewClient(target string) (*Client, error) { AuthFilePath: GetDefaultAuthFile(), }, policy: policy, - store: "/var/lib/containers/storage", } + client.store, client.runroot = defaultStorePaths() // default to the host architecture client.SetArchitectureChoice(arch.Current().String()) @@ -397,7 +431,7 @@ func (cl *Client) getLocalManifest(ctx context.Context, instanceDigest digest.Di } target = fmt.Sprintf("@%s", imageId) } - data, err := cl.skopeoInspect(fmt.Sprintf("containers-storage:[overlay@%s+/run/containers/storage]%s", cl.store, target)) + data, err := cl.skopeoInspect(fmt.Sprintf("containers-storage:[overlay@%s+%s]%s", cl.store, cl.runroot, target)) if err != nil { return RawManifest{}, err } @@ -564,7 +598,7 @@ func (cl *Client) getLocalImageID(digest string) (string, error) { // up the image ID and use that instead. store := cl.store - cmd := exec.Command("podman", "--root", store, "image", "ls", "--format=json") + cmd := exec.Command("podman", "--root", store, "--runroot", cl.runroot, "image", "ls", "--format=json") var stdout bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = os.Stderr diff --git a/pkg/distro/host.go b/pkg/distro/host.go index 470a149eed..d557025b11 100644 --- a/pkg/distro/host.go +++ b/pkg/distro/host.go @@ -5,8 +5,8 @@ import ( "errors" "fmt" "io" + "io/fs" "os" - "path" "strconv" "strings" ) @@ -106,13 +106,20 @@ func readOSRelease(r io.Reader) (map[string]string, error) { // According to os-release(5), the os-release file should be located in either /etc/os-release or /usr/lib/os-release, // so both locations are tried, with the former taking precedence. func ReadOSReleaseFromTree(root string) (map[string]string, error) { + return ReadOSReleaseFromFS(os.DirFS(root)) +} + +// ReadOSReleaseFromFS reads the os-release file from the given fs.FS. +// According to os-release(5), the os-release file should be located in either /etc/os-release or /usr/lib/os-release, +// so both locations are tried, with the former taking precedence. +func ReadOSReleaseFromFS(fsys fs.FS) (map[string]string, error) { locations := []string{ "etc/os-release", "usr/lib/os-release", } var errs []string for _, location := range locations { - f, err := os.Open(path.Join(root, location)) + f, err := fsys.Open(location) if err == nil { defer f.Close() return readOSRelease(f)