rootfsimage: directory sources, mount points, program architecture (rootfs Plan 1 Task 5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+864
−2
approved_rootfs_copy_test.go+2 −0internal/rootfsimage/dir.go+119 −0internal/rootfsimage/dir_other.go+17 −0internal/rootfsimage/dir_test.go+233 −0internal/rootfsimage/dir_unix.go+23 −0internal/rootfsimage/errors.go+17 −0internal/rootfsimage/mounts.go+75 −0internal/rootfsimage/mounts_test.go+166 −0internal/rootfsimage/progarch.go+112 −0internal/rootfsimage/progarch_test.go+97 −0internal/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) |
| } |