carlos / platform Public

rootfsimage: directory sources, mount points, program architecture (rootfs Plan 1 Task 5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5b033e7f469189e5a5efe9d9d964694c2d88c0a1 parent 2ff90f9
11 files changed, +864 −2
  • approved_rootfs_copy_test.go +2 −0
  • internal/rootfsimage/dir.go +119 −0
  • internal/rootfsimage/dir_other.go +17 −0
  • internal/rootfsimage/dir_test.go +233 −0
  • internal/rootfsimage/dir_unix.go +23 −0
  • internal/rootfsimage/errors.go +17 −0
  • internal/rootfsimage/mounts.go +75 −0
  • internal/rootfsimage/mounts_test.go +166 −0
  • internal/rootfsimage/progarch.go +112 −0
  • internal/rootfsimage/progarch_test.go +97 −0
  • internal/rootfsimage/tree.go +3 −2
diff --git a/approved_rootfs_copy_test.go b/approved_rootfs_copy_test.go
index eaa9c6240..52de3f963 100644
--- a/approved_rootfs_copy_test.go
+++ b/approved_rootfs_copy_test.go
@@ -152,6 +152,8 @@ var rootfsCopyBuilt = []string{
"RFS-1-C18", "RFS-1-C19", "RFS-1-C20", "RFS-1-C21", "RFS-1-C80", "RFS-1-C81", "RFS-1-C82", "RFS-1-C83",
// Task 4: image sources and flattening (internal/rootfsimage/errors.go)
"RFS-1-C45", "RFS-1-C46", "RFS-1-C50", "RFS-1-C51", "RFS-1-C53", "RFS-1-C55", "RFS-1-C78",
+ // Task 5: directory sources, mount points, program architecture (internal/rootfsimage/errors.go, dir_other.go)
+ "RFS-1-C47", "RFS-1-C52", "RFS-1-C56",
}
func mustParseRootfsRecord(t *testing.T) []copyRow {
diff --git a/internal/rootfsimage/dir.go b/internal/rootfsimage/dir.go
new file mode 100644
index 000000000..31cc89322
--- /dev/null
+++ b/internal/rootfsimage/dir.go
@@ -0,0 +1,119 @@
+package rootfsimage
+
+import (
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// OpenDir reads a plain directory as a tree: the directory itself is the
+// root, and what it holds is the image's files.
+//
+// Every entry is owned by 0:0, since a developer's own uid means nothing on
+// a box. Modes and times come from Lstat. A regular file is not copied: its
+// src is its path in the directory, so the spool stays empty and the bytes
+// are read when something opens the entry. Hardlinks (one device and inode)
+// share one src. A symlink is kept as the link it is, with its target as it
+// is written, and the walk never goes through one: a link to a directory
+// outside the source is a link in the tree, and nothing beneath it is read.
+// Device files, sockets and pipes are counted in Skipped and left out, as a
+// layer's are.
+//
+// Only the source directory itself may be a symlink (the caller named it).
+// On a system that does not keep Unix file modes it is DirSourceError.
+func OpenDir(dir, spool string) (*Tree, error) {
+ if err := dirSourceSupported(); err != nil {
+ return nil, err
+ }
+ t := NewTree(spool)
+ if err := readDir(t, dir); err != nil {
+ return nil, err
+ }
+ return t, nil
+}
+
+func readDir(t *Tree, dir string) error {
+ root, err := filepath.Abs(dir)
+ if err != nil {
+ return err
+ }
+ if root, err = filepath.EvalSymlinks(root); err != nil {
+ return err
+ }
+ fi, err := os.Stat(root)
+ if err != nil {
+ return err
+ }
+ if !fi.IsDir() {
+ return fmt.Errorf("rootfsimage: %s is not a directory", shown(dir))
+ }
+ t.Root = Entry{Path: ".", Type: TypeDir, Mode: modeOf(fi), ModTime: timeOf(fi)}
+
+ type fileKey struct{ dev, ino uint64 }
+ linked := map[fileKey]string{} // the src of the first name of a multiply linked file
+ return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return fmt.Errorf("rootfsimage: reading %s: %w", shown(dir), err)
+ }
+ if p == root {
+ return nil
+ }
+ rel, err := filepath.Rel(root, p)
+ if err != nil {
+ return err
+ }
+ name := filepath.ToSlash(rel)
+ if err := checkName(name); err != nil {
+ return fmt.Errorf("rootfsimage: %s: %w", shown(name), err)
+ }
+ fi, err := d.Info()
+ if err != nil {
+ return fmt.Errorf("rootfsimage: reading %s: %w", shown(name), err)
+ }
+ e := &Entry{Path: name, Mode: modeOf(fi), ModTime: timeOf(fi)}
+ switch m := fi.Mode(); {
+ case m.IsDir():
+ e.Type = TypeDir
+ case m.IsRegular():
+ e.Type, e.Size, e.src = TypeFile, fi.Size(), p
+ if dev, ino, links, ok := fileID(fi); ok && links > 1 {
+ k := fileKey{dev, ino}
+ if first, seen := linked[k]; seen {
+ e.src = first
+ } else {
+ linked[k] = p
+ }
+ }
+ case m&fs.ModeSymlink != 0:
+ target, err := os.Readlink(p)
+ if err != nil {
+ return fmt.Errorf("rootfsimage: reading %s: %w", shown(name), err)
+ }
+ if err := checkName(target); err != nil {
+ return fmt.Errorf("rootfsimage: %s: symlink target: %w", shown(name), err)
+ }
+ e.Type, e.LinkTarget = TypeSymlink, target
+ default:
+ t.Skipped++
+ return nil
+ }
+ t.headers++
+ if t.headers > t.maxHeaders {
+ return fmt.Errorf("%w: it has more than %d entries", errTooLarge, t.maxHeaders)
+ }
+ t.add(e)
+ return nil
+ })
+}
+
+// modeOf is the permission bits of fi with setuid, setgid and sticky.
+func modeOf(fi fs.FileInfo) fs.FileMode {
+ return fi.Mode() & (fs.ModePerm | fs.ModeSetuid | fs.ModeSetgid | fs.ModeSticky)
+}
+
+// timeOf is fi's modification time to the second, in UTC.
+func timeOf(fi fs.FileInfo) time.Time {
+ return fi.ModTime().Truncate(time.Second).UTC()
+}
diff --git a/internal/rootfsimage/dir_other.go b/internal/rootfsimage/dir_other.go
new file mode 100644
index 000000000..684e9127e
--- /dev/null
+++ b/internal/rootfsimage/dir_other.go
@@ -0,0 +1,17 @@
+//go:build !unix
+
+package rootfsimage
+
+import "io/fs"
+
+// DirSourceError refuses a directory source on a system that does not keep
+// Unix file modes (RFS-1-C56).
+type DirSourceError struct{}
+
+func (DirSourceError) Error() string {
+ return "a directory can be shipped as a rootfs only from Linux or macOS, which keep file modes. ship an OCI image instead"
+}
+
+func dirSourceSupported() error { return DirSourceError{} }
+
+func fileID(fs.FileInfo) (dev, ino, links uint64, ok bool) { return 0, 0, 0, false }
diff --git a/internal/rootfsimage/dir_test.go b/internal/rootfsimage/dir_test.go
new file mode 100644
index 000000000..643c8d49c
--- /dev/null
+++ b/internal/rootfsimage/dir_test.go
@@ -0,0 +1,233 @@
+//go:build unix
+
+package rootfsimage
+
+import (
+ "errors"
+ "net"
+ "os"
+ "path/filepath"
+ "syscall"
+ "testing"
+ "time"
+)
+
+func writeSrc(t *testing.T, p, body string, mode os.FileMode) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(p, mode); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestOpenDirOwnsEverythingByRoot(t *testing.T) {
+ dir := t.TempDir()
+ writeSrc(t, filepath.Join(dir, "bin", "run"), "#!/bin/sh\n", 0o755)
+ writeSrc(t, filepath.Join(dir, "bin", "su"), "x", 0o755|os.ModeSetuid)
+ writeSrc(t, filepath.Join(dir, "app", "config.ru"), "run App", 0o640)
+ if err := os.Chmod(filepath.Join(dir, "app"), 0o750); err != nil {
+ t.Fatal(err)
+ }
+ when := time.Unix(1_650_000_123, 987_654_321)
+ if err := os.Chtimes(filepath.Join(dir, "app", "config.ru"), when, when); err != nil {
+ t.Fatal(err)
+ }
+
+ tr, err := OpenDir(dir, t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range []struct {
+ path string
+ mode os.FileMode
+ }{
+ {"bin", 0o755}, {"bin/run", 0o755}, {"bin/su", 0o755 | os.ModeSetuid}, {"app", 0o750}, {"app/config.ru", 0o640},
+ } {
+ e := mustLookup(t, tr, c.path)
+ if e.UID != 0 || e.GID != 0 {
+ t.Errorf("%s owner = %d:%d, want 0:0", c.path, e.UID, e.GID)
+ }
+ if e.Mode != c.mode {
+ t.Errorf("%s mode = %v, want %v", c.path, e.Mode, c.mode)
+ }
+ }
+ e := mustLookup(t, tr, "app/config.ru")
+ if want := time.Unix(1_650_000_123, 0).UTC(); !e.ModTime.Equal(want) {
+ t.Errorf("app/config.ru mtime = %v, want %v", e.ModTime, want)
+ }
+ if e.Type != TypeFile || e.Size != int64(len("run App")) {
+ t.Errorf("app/config.ru = type %d size %d", e.Type, e.Size)
+ }
+ if got := readAll(t, tr, e); got != "run App" {
+ t.Errorf("bytes = %q", got)
+ }
+ if tr.Root.UID != 0 || tr.Root.GID != 0 {
+ t.Errorf("root owner = %d:%d, want 0:0", tr.Root.UID, tr.Root.GID)
+ }
+ if tr.Files() != 3 {
+ t.Errorf("Files = %d, want 3", tr.Files())
+ }
+}
+
+func TestOpenDirReadsInPlaceAndKeepsHardlinks(t *testing.T) {
+ dir := t.TempDir()
+ writeSrc(t, filepath.Join(dir, "a"), "same", 0o644)
+ if err := os.Link(filepath.Join(dir, "a"), filepath.Join(dir, "b")); err != nil {
+ t.Fatal(err)
+ }
+ writeSrc(t, filepath.Join(dir, "c"), "same", 0o644)
+ spool := t.TempDir()
+
+ tr, err := OpenDir(dir, spool)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a, b, c := mustLookup(t, tr, "a"), mustLookup(t, tr, "b"), mustLookup(t, tr, "c")
+ if a.src != b.src {
+ t.Errorf("hardlinks do not share src: %q, %q", a.src, b.src)
+ }
+ if a.src != filepath.Join(dir, "a") {
+ t.Errorf("src = %q, want the source path %q", a.src, filepath.Join(dir, "a"))
+ }
+ if c.src != filepath.Join(dir, "c") {
+ t.Errorf("a separate file's src = %q", c.src)
+ }
+ if left, _ := os.ReadDir(spool); len(left) != 0 {
+ t.Errorf("the spool holds %d files, want none", len(left))
+ }
+}
+
+func TestOpenDirSkipsSocketsAndPipes(t *testing.T) {
+ // A unix socket path is limited to about 100 bytes, which a test's
+ // TempDir can pass, so the socket is made by a relative name.
+ dir := t.TempDir()
+ t.Chdir(dir)
+ writeSrc(t, filepath.Join(dir, "keep"), "k", 0o644)
+ if err := syscall.Mkfifo(filepath.Join(dir, "pipe"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ l, err := net.Listen("unix", "sock")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer l.Close()
+
+ tr, err := OpenDir(dir, t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tr.Skipped != 2 {
+ t.Errorf("Skipped = %d, want 2", tr.Skipped)
+ }
+ mustLookup(t, tr, "keep")
+ for _, gone := range []string{"pipe", "sock"} {
+ if _, ok := tr.Lookup(gone); ok {
+ t.Errorf("%s is in the tree", gone)
+ }
+ }
+}
+
+func TestOpenDirKeepsSymlinksAsLinks(t *testing.T) {
+ outside := t.TempDir()
+ writeSrc(t, filepath.Join(outside, "secret"), "s", 0o644)
+ dir := t.TempDir()
+ writeSrc(t, filepath.Join(dir, "lib", "real.so"), "so", 0o644)
+ if err := os.Symlink("real.so", filepath.Join(dir, "lib", "rel.so")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outside, filepath.Join(dir, "abs")); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink("../../nowhere", filepath.Join(dir, "dangling")); err != nil {
+ t.Fatal(err)
+ }
+
+ tr, err := OpenDir(dir, t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for p, target := range map[string]string{"lib/rel.so": "real.so", "abs": outside, "dangling": "../../nowhere"} {
+ e := mustLookup(t, tr, p)
+ if e.Type != TypeSymlink || e.LinkTarget != target {
+ t.Errorf("%s = type %d -> %q, want a symlink to %q", p, e.Type, e.LinkTarget, target)
+ }
+ if e.UID != 0 || e.GID != 0 {
+ t.Errorf("%s owner = %d:%d", p, e.UID, e.GID)
+ }
+ }
+ // The walk did not go through the link to the directory outside.
+ if _, ok := tr.Lookup("abs/secret"); ok {
+ t.Error("the walk followed a symlink out of the source")
+ }
+ if n := len(tr.Entries()); n != 5 {
+ t.Errorf("%d entries, want 5 (lib, lib/real.so, lib/rel.so, abs, dangling)", n)
+ }
+}
+
+func TestOpenDirFollowsASymlinkedSourceDirectory(t *testing.T) {
+ dir := t.TempDir()
+ writeSrc(t, filepath.Join(dir, "real", "f"), "f", 0o644)
+ link := filepath.Join(dir, "link")
+ if err := os.Symlink("real", link); err != nil {
+ t.Fatal(err)
+ }
+ tr, err := OpenDir(link, t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ mustLookup(t, tr, "f")
+}
+
+func TestOpenDirRefusesWhatIsNotADirectory(t *testing.T) {
+ f := filepath.Join(t.TempDir(), "file")
+ writeSrc(t, f, "x", 0o644)
+ if _, err := OpenDir(f, t.TempDir()); err == nil {
+ t.Error("a regular file opened as a directory")
+ }
+ if _, err := OpenDir(filepath.Join(t.TempDir(), "missing"), t.TempDir()); err == nil {
+ t.Error("a missing directory opened")
+ }
+}
+
+func TestOpenDirBoundsTheEntries(t *testing.T) {
+ dir := t.TempDir()
+ for _, n := range []string{"a", "b", "c"} {
+ writeSrc(t, filepath.Join(dir, n), n, 0o644)
+ }
+ tr := NewTree(t.TempDir())
+ tr.maxHeaders = 2
+ if err := readDir(tr, dir); !errors.Is(err, errTooLarge) {
+ t.Errorf("err = %v, want errTooLarge", err)
+ }
+}
+
+func TestOpenSendsALayoutDirToOpenLayout(t *testing.T) {
+ dir := t.TempDir()
+ writeLayout(t, dir, false, tImage{platform: "linux/arm64", layers: [][]byte{shopLayer(t, "arm64")}})
+ tr, err := Open(dir, t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tr.Config.Architecture != "arm64" {
+ t.Errorf("Config.Architecture = %q, want arm64", tr.Config.Architecture)
+ }
+ mustLookup(t, tr, "app/which")
+}
+
+func TestOpenSendsAPlainDirToOpenDir(t *testing.T) {
+ dir := t.TempDir()
+ writeSrc(t, filepath.Join(dir, "app", "run"), "x", 0o755)
+ tr, err := Open(dir, t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tr.Config.Architecture != "" {
+ t.Errorf("Config.Architecture = %q, want none for a directory", tr.Config.Architecture)
+ }
+ mustLookup(t, tr, "app/run")
+}
diff --git a/internal/rootfsimage/dir_unix.go b/internal/rootfsimage/dir_unix.go
new file mode 100644
index 000000000..f6145974b
--- /dev/null
+++ b/internal/rootfsimage/dir_unix.go
@@ -0,0 +1,23 @@
+//go:build unix
+
+package rootfsimage
+
+import (
+ "io/fs"
+ "syscall"
+)
+
+// dirSourceSupported is nil where OpenDir can read a directory: a Unix
+// keeps the modes and the inodes a rootfs needs.
+func dirSourceSupported() error { return nil }
+
+// fileID is the device and inode of fi and its number of names, for finding
+// the hardlinks of a directory. ok is false when fi does not say.
+func fileID(fi fs.FileInfo) (dev, ino, links uint64, ok bool) {
+ st, isStat := fi.Sys().(*syscall.Stat_t)
+ if !isStat {
+ return 0, 0, 0, false
+ }
+ // The field types differ by system, so each is converted.
+ return uint64(st.Dev), uint64(st.Ino), uint64(st.Nlink), true
+}
diff --git a/internal/rootfsimage/errors.go b/internal/rootfsimage/errors.go
index 3e3ab2259..966300e54 100644
--- a/internal/rootfsimage/errors.go
+++ b/internal/rootfsimage/errors.go
@@ -56,6 +56,23 @@ func (e LinkError) Error() string {
return fmt.Sprintf("the image links %s to %s, which is not a file in the image", shown(e.Path), shown(e.Target))
}
+// MountPointError refuses an image that has a file or a symlink where boxes
+// mount a directory (RFS-1-C52). Path is the mount point or the parent of
+// one, and Type is "file" or "symlink".
+type MountPointError struct{ Path, Type string }
+
+func (e MountPointError) Error() string {
+ return fmt.Sprintf("the image has %s as a %s, and boxes mount a directory there. remove it from the image", shown(e.Path), e.Type)
+}
+
+// ProgramArchError refuses a directory holding a program built for another
+// platform (RFS-1-C47). Platform is such as "linux/amd64" or "macOS".
+type ProgramArchError struct{ Path, Platform string }
+
+func (e ProgramArchError) Error() string {
+ return fmt.Sprintf("%s is a program built for %s, and boxes run linux/arm64. build the directory's programs for linux/arm64", shown(e.Path), shown(e.Platform))
+}
+
// ManyImagesError refuses a source holding more than one image (RFS-1-C55).
type ManyImagesError struct{ N int }
diff --git a/internal/rootfsimage/mounts.go b/internal/rootfsimage/mounts.go
new file mode 100644
index 000000000..a53d7a301
--- /dev/null
+++ b/internal/rootfsimage/mounts.go
@@ -0,0 +1,75 @@
+package rootfsimage
+
+import (
+ "io/fs"
+ "strings"
+ "time"
+)
+
+// MountPoints are the directories every image needs, because a box mounts
+// something on each: the instance's data, its control socket directory and
+// the platform's own files, and the kernel's own file systems. (The unit
+// can only mount on a path that exists, and the image is read-only.)
+var MountPoints = []string{"data", "run/carlos", "opt/carlos", "tmp", "dev", "proc", "sys"}
+
+// MountFiles are the files every image needs, because a box binds a file
+// onto each: root's resolver copy onto etc/resolv.conf.
+var MountFiles = []string{"etc/resolv.conf"}
+
+// AddMountPoints adds what MountPoints and MountFiles name that the tree
+// does not have, and the parents they need. A new directory is 0:0 mode 0755
+// ("tmp" is 01777), a new file 0:0 0644 and empty, all with the time of the
+// tree's newest entry, so that adding them does not make a rebuild of the
+// same source differ.
+//
+// What the image already has is kept as it is: a directory with its own
+// owner and mode, a file with its bytes. A mount point or a parent of one
+// that is a file or a symlink is MountPointError. (They are told apart by
+// Lookup, not Resolve: a symlink at "tmp" would send the mount somewhere
+// else.) A mount file that is not a regular file, such as the symlink most
+// images make of resolv.conf, becomes the empty file, since the box binds
+// over it.
+func AddMountPoints(t *Tree) error {
+ when := t.Newest()
+ for _, p := range MountPoints {
+ if err := t.mountDir(p, when); err != nil {
+ return err
+ }
+ }
+ for _, p := range MountFiles {
+ if err := t.mountDir(parentOf(p), when); err != nil {
+ return err
+ }
+ if e, ok := t.Lookup(p); ok && e.Type == TypeFile {
+ continue
+ }
+ src, err := t.writeBlob(strings.NewReader(""), 0)
+ if err != nil {
+ return err
+ }
+ t.add(&Entry{Path: p, Type: TypeFile, Mode: 0o644, ModTime: when, src: src})
+ }
+ return nil
+}
+
+// mountDir makes sure p and each of its parents is a directory of the tree.
+func (t *Tree) mountDir(p string, when time.Time) error {
+ cur := ""
+ for _, c := range splitPath(p) {
+ cur = joinRel(cur, c)
+ e, ok := t.Lookup(cur)
+ switch {
+ case !ok:
+ mode := fs.FileMode(0o755)
+ if cur == "tmp" {
+ mode = 0o777 | fs.ModeSticky
+ }
+ t.add(&Entry{Path: cur, Type: TypeDir, Mode: mode, ModTime: when})
+ case e.Type == TypeFile:
+ return MountPointError{Path: cur, Type: "file"}
+ case e.Type == TypeSymlink:
+ return MountPointError{Path: cur, Type: "symlink"}
+ }
+ }
+ return nil
+}
diff --git a/internal/rootfsimage/mounts_test.go b/internal/rootfsimage/mounts_test.go
new file mode 100644
index 000000000..53ed2ff08
--- /dev/null
+++ b/internal/rootfsimage/mounts_test.go
@@ -0,0 +1,166 @@
+package rootfsimage
+
+import (
+ "archive/tar"
+ "errors"
+ "io/fs"
+ "testing"
+ "time"
+)
+
+func TestAddMountPoints(t *testing.T) {
+ tr := NewTree(t.TempDir())
+ apply(t, tr, layerTar(t, tEntry{name: "app/run", typ: tar.TypeReg, mode: 0o755, body: "x", mtime: 1_700_000_500}))
+ newest := tr.Newest()
+ if err := AddMountPoints(tr); err != nil {
+ t.Fatal(err)
+ }
+ for _, p := range MountPoints {
+ e := mustLookup(t, tr, p)
+ if e.Type != TypeDir || e.UID != 0 || e.GID != 0 {
+ t.Errorf("%s = type %d owner %d:%d", p, e.Type, e.UID, e.GID)
+ }
+ want := fs.FileMode(0o755)
+ if p == "tmp" {
+ want = 0o777 | fs.ModeSticky
+ }
+ if e.Mode != want {
+ t.Errorf("%s mode = %v, want %v", p, e.Mode, want)
+ }
+ if !e.ModTime.Equal(newest) {
+ t.Errorf("%s mtime = %v, want the tree's newest, %v", p, e.ModTime, newest)
+ }
+ }
+ for _, p := range []string{"run", "opt", "etc"} {
+ if e := mustLookup(t, tr, p); e.Type != TypeDir || e.Mode != 0o755 || e.UID != 0 || e.GID != 0 {
+ t.Errorf("%s = %+v, want a 0:0 0755 directory", p, e)
+ }
+ }
+ rc := mustLookup(t, tr, "etc/resolv.conf")
+ if rc.Type != TypeFile || rc.Mode != 0o644 || rc.UID != 0 || rc.GID != 0 || rc.Size != 0 || !rc.ModTime.Equal(newest) {
+ t.Errorf("etc/resolv.conf = %+v, want an empty 0:0 0644 regular file", rc)
+ }
+ if got := readAll(t, tr, rc); got != "" {
+ t.Errorf("etc/resolv.conf holds %q", got)
+ }
+ if got := readAll(t, tr, mustLookup(t, tr, "app/run")); got != "x" {
+ t.Errorf("the image's own file changed: %q", got)
+ }
+
+ // A second call changes nothing.
+ n := len(tr.Entries())
+ if err := AddMountPoints(tr); err != nil {
+ t.Fatal(err)
+ }
+ if len(tr.Entries()) != n {
+ t.Errorf("a second call changed the entry count from %d to %d", n, len(tr.Entries()))
+ }
+}
+
+func TestAddMountPointsKeepsWhatTheImageHas(t *testing.T) {
+ tr := NewTree(t.TempDir())
+ apply(t, tr, layerTar(t,
+ tEntry{name: "etc/", typ: tar.TypeDir, mode: 0o750, uid: 5, gid: 6, mtime: 1_600_000_000},
+ tEntry{name: "etc/resolv.conf", typ: tar.TypeReg, mode: 0o600, body: "nameserver 10.0.0.1\n"},
+ tEntry{name: "tmp/", typ: tar.TypeDir, mode: 0o700, uid: 1000, gid: 1000},
+ tEntry{name: "data/keep", typ: tar.TypeReg, mode: 0o644, body: "k"},
+ ))
+ if err := AddMountPoints(tr); err != nil {
+ t.Fatal(err)
+ }
+ rc := mustLookup(t, tr, "etc/resolv.conf")
+ if got := readAll(t, tr, rc); got != "nameserver 10.0.0.1\n" {
+ t.Errorf("etc/resolv.conf = %q, want the image's bytes", got)
+ }
+ if rc.Mode != 0o600 {
+ t.Errorf("etc/resolv.conf mode = %v, want the image's 0600", rc.Mode)
+ }
+ if e := mustLookup(t, tr, "etc"); e.Mode != 0o750 || e.UID != 5 || e.GID != 6 {
+ t.Errorf("etc = %+v, want the image's own", e)
+ }
+ if e := mustLookup(t, tr, "tmp"); e.Mode != 0o700 || e.UID != 1000 {
+ t.Errorf("tmp = %+v, want the image's 0700 kept", e)
+ }
+ mustLookup(t, tr, "data/keep")
+}
+
+func TestAddMountPointsReplacesAResolvConfSymlink(t *testing.T) {
+ tr := NewTree(t.TempDir())
+ apply(t, tr, layerTar(t,
+ tEntry{name: "etc/", typ: tar.TypeDir, mode: 0o755},
+ tEntry{name: "etc/resolv.conf", typ: tar.TypeSymlink, link: "../run/systemd/resolve/stub-resolv.conf"},
+ ))
+ if err := AddMountPoints(tr); err != nil {
+ t.Fatal(err)
+ }
+ rc := mustLookup(t, tr, "etc/resolv.conf")
+ if rc.Type != TypeFile || rc.Mode != 0o644 || rc.Size != 0 || rc.LinkTarget != "" {
+ t.Errorf("etc/resolv.conf = %+v, want an empty regular file", rc)
+ }
+ if got := readAll(t, tr, rc); got != "" {
+ t.Errorf("etc/resolv.conf holds %q", got)
+ }
+ // The link's target is not made: the mount point is the file itself.
+ if _, ok := tr.Lookup("run/systemd"); ok {
+ t.Error("run/systemd was made")
+ }
+}
+
+func TestAddMountPointsReplacesAResolvConfDirectory(t *testing.T) {
+ tr := NewTree(t.TempDir())
+ apply(t, tr, layerTar(t,
+ tEntry{name: "etc/resolv.conf/", typ: tar.TypeDir, mode: 0o755},
+ tEntry{name: "etc/resolv.conf/x", typ: tar.TypeReg, mode: 0o644, body: "x"},
+ ))
+ if err := AddMountPoints(tr); err != nil {
+ t.Fatal(err)
+ }
+ if rc := mustLookup(t, tr, "etc/resolv.conf"); rc.Type != TypeFile {
+ t.Errorf("etc/resolv.conf = type %d, want a file", rc.Type)
+ }
+ if _, ok := tr.Lookup("etc/resolv.conf/x"); ok {
+ t.Error("the directory's contents stayed")
+ }
+}
+
+func TestAddMountPointsRefusesAFileOrSymlinkWhereBoxesMount(t *testing.T) {
+ for _, c := range []struct {
+ name string
+ es []tEntry
+ want MountPointError
+ }{
+ {"a symlink at tmp", []tEntry{{name: "tmp", typ: tar.TypeSymlink, link: "var/tmp"}}, MountPointError{"tmp", "symlink"}},
+ {"a file at opt/carlos", []tEntry{{name: "opt/carlos", typ: tar.TypeReg, mode: 0o644, body: "x"}}, MountPointError{"opt/carlos", "file"}},
+ {"a symlink at run", []tEntry{{name: "run", typ: tar.TypeSymlink, link: "var/run"}}, MountPointError{"run", "symlink"}},
+ {"a file at data", []tEntry{{name: "data", typ: tar.TypeReg, mode: 0o644, body: "x"}}, MountPointError{"data", "file"}},
+ {"a symlink at etc", []tEntry{{name: "etc", typ: tar.TypeSymlink, link: "usr/etc"}}, MountPointError{"etc", "symlink"}},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ tr := NewTree(t.TempDir())
+ apply(t, tr, layerTar(t, c.es...))
+ var me MountPointError
+ if err := AddMountPoints(tr); !errors.As(err, &me) || me != c.want {
+ t.Errorf("err = %v, want %+v", err, c.want)
+ }
+ })
+ }
+}
+
+func TestMountPointErrorSpeaksTheDeck(t *testing.T) {
+ got := MountPointError{"opt/carlos", "file"}.Error()
+ want := "the image has opt/carlos as a file, and boxes mount a directory there. remove it from the image"
+ if got != want {
+ t.Errorf("%q\nwant %q", got, want)
+ }
+}
+
+func TestAddMountPointsOnAnEmptyTreeUsesTheRootsTime(t *testing.T) {
+ tr := NewTree(t.TempDir())
+ tr.Root.ModTime = time.Unix(1_650_000_000, 0).UTC()
+ if err := AddMountPoints(tr); err != nil {
+ t.Fatal(err)
+ }
+ if e := mustLookup(t, tr, "data"); !e.ModTime.Equal(tr.Root.ModTime) {
+ t.Errorf("data mtime = %v, want the root's %v", e.ModTime, tr.Root.ModTime)
+ }
+}
diff --git a/internal/rootfsimage/progarch.go b/internal/rootfsimage/progarch.go
new file mode 100644
index 000000000..707f1eeaa
--- /dev/null
+++ b/internal/rootfsimage/progarch.go
@@ -0,0 +1,112 @@
+package rootfsimage
+
+import (
+ "debug/elf"
+ "encoding/binary"
+ "io"
+ "strings"
+)
+
+// CheckProgramsArch refuses a tree holding a program built for something
+// other than linux/arm64: a regular file with an exec bit that is an ELF of
+// another machine, or a Mach-O executable. The first one in path order is
+// ProgramArchError.
+//
+// It is for a directory source only. An OCI image's config says what it is
+// built for and is believed (OpenLayout refuses the wrong one), and an image
+// can carry another platform's prebuilt files it never runs, a Rails app's
+// gems for one. A directory has no config, so its programs are all it can
+// be judged by.
+func CheckProgramsArch(t *Tree) error {
+ for _, e := range t.Entries() {
+ if e.Type != TypeFile || e.Mode&0o111 == 0 {
+ continue
+ }
+ head, err := t.head(e)
+ if err != nil {
+ return err
+ }
+ if platform := foreignPlatform(head); platform != "" {
+ return ProgramArchError{Path: e.Path, Platform: platform}
+ }
+ }
+ return nil
+}
+
+// headLen covers an ELF header's e_machine (offset 18) and a Mach-O's
+// magic and its fat header's count.
+const headLen = 20
+
+// head is up to the first headLen bytes of a regular file.
+func (t *Tree) head(e *Entry) ([]byte, error) {
+ rc, err := t.Open(e)
+ if err != nil {
+ return nil, err
+ }
+ defer rc.Close()
+ b := make([]byte, headLen)
+ n, err := io.ReadFull(rc, b)
+ if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
+ return nil, err
+ }
+ return b[:n], nil
+}
+
+// foreignPlatform is "" for a file that is not a program or is built for
+// linux/arm64, else the platform its header names.
+func foreignPlatform(b []byte) string {
+ if len(b) >= 20 && string(b[:4]) == "\x7fELF" {
+ var order binary.ByteOrder = binary.LittleEndian
+ if b[5] == 2 {
+ order = binary.BigEndian
+ }
+ m := elf.Machine(order.Uint16(b[18:20]))
+ if m == elf.EM_AARCH64 {
+ return ""
+ }
+ return elfPlatform(m)
+ }
+ if len(b) < 4 {
+ return ""
+ }
+ switch string(b[:4]) {
+ case "\xfe\xed\xfa\xcf", "\xcf\xfa\xed\xfe":
+ return "macOS"
+ case "\xca\xfe\xba\xbe", "\xbe\xba\xfe\xca":
+ // A fat Mach-O and a Java class file start alike. A fat file's next
+ // four bytes count its architectures, a few at most; a class file's
+ // are its version, 45 or more in the second pair.
+ if len(b) < 8 {
+ return ""
+ }
+ var n uint32
+ if b[0] == 0xca {
+ n = binary.BigEndian.Uint32(b[4:8])
+ } else {
+ n = binary.LittleEndian.Uint32(b[4:8])
+ }
+ if n >= 1 && n <= 30 {
+ return "macOS"
+ }
+ }
+ return ""
+}
+
+// elfPlatform names an ELF machine the way a platform is written.
+func elfPlatform(m elf.Machine) string {
+ switch m {
+ case elf.EM_X86_64:
+ return "linux/amd64"
+ case elf.EM_386:
+ return "linux/386"
+ case elf.EM_ARM:
+ return "linux/arm"
+ case elf.EM_PPC64:
+ return "linux/ppc64"
+ case elf.EM_S390:
+ return "linux/s390x"
+ case elf.EM_RISCV:
+ return "linux/riscv64"
+ }
+ return "linux/" + strings.ToLower(strings.TrimPrefix(m.String(), "EM_"))
+}
diff --git a/internal/rootfsimage/progarch_test.go b/internal/rootfsimage/progarch_test.go
new file mode 100644
index 000000000..9aa617acf
--- /dev/null
+++ b/internal/rootfsimage/progarch_test.go
@@ -0,0 +1,97 @@
+package rootfsimage
+
+import (
+ "archive/tar"
+ "encoding/binary"
+ "errors"
+ "strings"
+ "testing"
+)
+
+// elfHeader is the 64 bytes of a little-endian ELF64 header with the given
+// e_machine (offset 18).
+func elfHeader(machine uint16) string {
+ h := make([]byte, 64)
+ copy(h, "\x7fELF")
+ h[4], h[5], h[6] = 2, 1, 1 // 64-bit, little-endian, version 1
+ binary.LittleEndian.PutUint16(h[16:], 2) // ET_EXEC
+ binary.LittleEndian.PutUint16(h[18:], machine)
+ return string(h)
+}
+
+const (
+ emX8664 = 62
+ emAArch64 = 183
+ emARM = 40
+)
+
+func archTree(t *testing.T, es ...tEntry) *Tree {
+ t.Helper()
+ tr := NewTree(t.TempDir())
+ apply(t, tr, layerTar(t, es...))
+ return tr
+}
+
+func TestCheckProgramsArch(t *testing.T) {
+ ok := archTree(t,
+ tEntry{name: "usr/bin/ruby", typ: tar.TypeReg, mode: 0o755, body: elfHeader(emAArch64)},
+ tEntry{name: "usr/lib/libfoo.so", typ: tar.TypeReg, mode: 0o644, body: elfHeader(emX8664)}, // no exec bit
+ tEntry{name: "app/run", typ: tar.TypeReg, mode: 0o755, body: "#!/bin/sh\nexec ruby app.rb\n"},
+ tEntry{name: "app/tiny", typ: tar.TypeReg, mode: 0o755, body: "x"},
+ tEntry{name: "app/empty", typ: tar.TypeReg, mode: 0o755},
+ tEntry{name: "app/ruby", typ: tar.TypeSymlink, link: "/usr/bin/ruby"},
+ )
+ if err := CheckProgramsArch(ok); err != nil {
+ t.Errorf("a clean tree: %v", err)
+ }
+
+ for _, c := range []struct {
+ name string
+ body string
+ platform string
+ }{
+ {"x86-64 ELF", elfHeader(emX8664), "linux/amd64"},
+ {"32-bit ARM ELF", elfHeader(emARM), "linux/arm"},
+ {"Mach-O 64 (arm64)", "\xcf\xfa\xed\xfe\x0c\x00\x00\x01" + strings.Repeat("\x00", 24), "macOS"},
+ {"Mach-O 64 (big-endian magic)", "\xfe\xed\xfa\xcf" + strings.Repeat("\x00", 28), "macOS"},
+ {"Mach-O fat", "\xca\xfe\xba\xbe\x00\x00\x00\x02" + strings.Repeat("\x00", 24), "macOS"},
+ {"Mach-O fat (swapped)", "\xbe\xba\xfe\xca\x02\x00\x00\x00" + strings.Repeat("\x00", 24), "macOS"},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ tr := archTree(t, tEntry{name: "usr/bin/ruby", typ: tar.TypeReg, mode: 0o755, body: c.body})
+ var pe ProgramArchError
+ if err := CheckProgramsArch(tr); !errors.As(err, &pe) || pe != (ProgramArchError{"usr/bin/ruby", c.platform}) {
+ t.Errorf("err = %v, want ProgramArchError{usr/bin/ruby, %s}", err, c.platform)
+ }
+ })
+ }
+}
+
+func TestCheckProgramsArchLeavesJavaClassesAlone(t *testing.T) {
+ // A Java class file starts with the same four bytes as a fat Mach-O; its
+ // next four are a version, never a small architecture count.
+ class := "\xca\xfe\xba\xbe\x00\x00\x00\x34" + strings.Repeat("\x00", 24)
+ tr := archTree(t, tEntry{name: "app/Main.class", typ: tar.TypeReg, mode: 0o755, body: class})
+ if err := CheckProgramsArch(tr); err != nil {
+ t.Errorf("a Java class file: %v", err)
+ }
+}
+
+func TestCheckProgramsArchNamesTheFirstInPathOrder(t *testing.T) {
+ tr := archTree(t,
+ tEntry{name: "usr/bin/b", typ: tar.TypeReg, mode: 0o755, body: elfHeader(emX8664)},
+ tEntry{name: "app/a", typ: tar.TypeReg, mode: 0o755, body: "\xcf\xfa\xed\xfe" + strings.Repeat("\x00", 28)},
+ )
+ var pe ProgramArchError
+ if err := CheckProgramsArch(tr); !errors.As(err, &pe) || pe != (ProgramArchError{"app/a", "macOS"}) {
+ t.Errorf("err = %v, want ProgramArchError{app/a, macOS}", err)
+ }
+}
+
+func TestProgramArchErrorSpeaksTheDeck(t *testing.T) {
+ got := ProgramArchError{"usr/bin/ruby", "linux/amd64"}.Error()
+ want := "usr/bin/ruby is a program built for linux/amd64, and boxes run linux/arm64. build the directory's programs for linux/arm64"
+ if got != want {
+ t.Errorf("%q\nwant %q", got, want)
+ }
+}
diff --git a/internal/rootfsimage/tree.go b/internal/rootfsimage/tree.go
index 528be1adf..7dababd0d 100644
--- a/internal/rootfsimage/tree.go
+++ b/internal/rootfsimage/tree.go
@@ -110,8 +110,8 @@ func NewTree(spool string) *Tree {
}
// Open opens a source: a directory holding oci-layout is an OCI layout
-// (OpenLayout), a regular file a save archive (OpenArchive). Anything else
-// is NotAnImageError, a directory without oci-layout included for now.
+// (OpenLayout), any other directory a plain directory (OpenDir), a regular
+// file a save archive (OpenArchive). Anything else is NotAnImageError.
func Open(src, spool string) (*Tree, error) {
fi, err := os.Stat(src)
if err != nil {
@@ -122,6 +122,7 @@ func Open(src, spool string) (*Tree, error) {
if lfi, err := os.Stat(filepath.Join(src, "oci-layout")); err == nil && lfi.Mode().IsRegular() {
return OpenLayout(src, spool)
}
+ return OpenDir(src, spool)
case fi.Mode().IsRegular():
return OpenArchive(src, spool)
}