diff --git a/go.mod b/go.mod index 45fe9be..7396a7e 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,10 @@ go 1.21 require ( github.com/hugelgupf/go-shlex v0.0.0-20200702092117-c80c9d0918fa - github.com/u-root/gobusybox/src v0.0.0-20240209041341-8c409c9832aa + github.com/u-root/gobusybox/src v0.0.0-20240212035024-44ff0bf359ad github.com/u-root/u-root v0.12.0 github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e + golang.org/x/sync v0.6.0 golang.org/x/sys v0.16.0 golang.org/x/tools v0.17.0 ) @@ -28,6 +29,5 @@ require ( github.com/vishvananda/netns v0.0.4 // indirect golang.org/x/mod v0.14.0 // indirect golang.org/x/net v0.20.0 // indirect - golang.org/x/sync v0.6.0 // indirect src.elv.sh v0.16.0-rc1.0.20220116211855-fda62502ad7f // indirect ) diff --git a/go.sum b/go.sum index ddc1bd4..e66c4f5 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,8 @@ github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/u-root/gobusybox/src v0.0.0-20240209041341-8c409c9832aa h1:+0QRJrUq4ouVyrqwpKP18OXp3cbCdfQ7wvtihc/7r2M= -github.com/u-root/gobusybox/src v0.0.0-20240209041341-8c409c9832aa/go.mod h1:vN1IwhlCo7gTDTJDUs6WCKM4/C2uiq5w0XvZCqLtb5s= +github.com/u-root/gobusybox/src v0.0.0-20240212035024-44ff0bf359ad h1:lUSEFqsEuc+c+sTI5jVEC0wWw0FOuXZbrYGZbxQL19E= +github.com/u-root/gobusybox/src v0.0.0-20240212035024-44ff0bf359ad/go.mod h1:vN1IwhlCo7gTDTJDUs6WCKM4/C2uiq5w0XvZCqLtb5s= github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs= github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI= github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e h1:BA9O3BmlTmpjbvajAwzWx4Wo2TRVdpPXZEeemGQcajw= diff --git a/uroot/uroot.go b/uroot/uroot.go index c85326d..7272ba9 100644 --- a/uroot/uroot.go +++ b/uroot/uroot.go @@ -17,6 +17,7 @@ import ( "path/filepath" "strings" + "github.com/hugelgupf/go-shlex" "github.com/u-root/gobusybox/src/pkg/bb/findpkg" "github.com/u-root/gobusybox/src/pkg/golang" "github.com/u-root/mkuimage/cpio" @@ -231,6 +232,256 @@ type Opts struct { DefaultShell string } +// Modifier modifies uimage options. +type Modifier func(*Opts) error + +// OptionsFor will creates Opts from the given modifiers. +func OptionsFor(mods ...Modifier) (*Opts, error) { + o := &Opts{ + Env: golang.Default(), + } + if err := o.Apply(mods...); err != nil { + return nil, err + } + return o, nil +} + +// Create creates an initramfs from the given options o. +func (o *Opts) Create(logger ulog.Logger) error { + return CreateInitramfs(logger, *o) +} + +// Apply modifies o with the given modifiers. +func (o *Opts) Apply(mods ...Modifier) error { + for _, mod := range mods { + if mod != nil { + if err := mod(o); err != nil { + return err + } + } + } + return nil +} + +// WithSkipLDD sets SkipLDD to true. If true, initramfs creation skips using +// ldd to pick up dependencies from the local file system when resolving +// ExtraFiles. +// +// Useful if you have all deps revision controlled and wish to ensure builds +// are repeatable, and/or if the local machine's binaries use instructions +// unavailable on the emulated CPU. +// +// If you turn this on but do not manually list all deps, affected binaries +// will misbehave. +func WithSkipLDD() Modifier { + return func(o *Opts) error { + o.SkipLDD = true + return nil + } +} + +// WithEnv alters the Go build environment (e.g. build tags, GOARCH, GOOS env vars). +func WithEnv(gopts ...golang.Opt) Modifier { + return func(o *Opts) error { + if o.Env == nil { + o.Env = golang.Default(gopts...) + } else { + o.Env.Apply(gopts...) + } + return nil + } +} + +// WithFiles adds files to the archive. +// +// Shared library dependencies will automatically also be added to the archive +// using ldd, unless WithSkipLDD is set. +// +// The following formats are allowed in the list: +// +// - "/home/chrisko/foo:root/bar" adds the file from absolute path +// /home/chrisko/foo on the host at the relative root/bar in the archive. +// - "/home/foo" is equivalent to "/home/foo:home/foo". +// - "uroot_test.go" is equivalent to "uroot_test.go:uroot_test.go". +func WithFiles(file ...string) Modifier { + return func(o *Opts) error { + o.ExtraFiles = append(o.ExtraFiles, file...) + return nil + } +} + +// WithCommands adds Go commands to compile and add to the archive. +// +// b is the method of building -- as a busybox or a binary. +// +// Currently allowed formats for cmd: +// +// - package imports; e.g. github.com/u-root/u-root/cmds/ls +// - globs of package imports; e.g. github.com/u-root/u-root/cmds/* +// - paths to package directories; e.g. $GOPATH/src/github.com/u-root/u-root/cmds/ls +// - globs of paths to package directories; e.g. ./cmds/* +// +// Directories may be relative or absolute, with or without globs. +// Globs are resolved using filepath.Glob. +func WithCommands(buildOpts *golang.BuildOpts, b builder.Builder, cmd ...string) Modifier { + return func(o *Opts) error { + o.AddCommands(Commands{ + Builder: b, + Packages: cmd, + BuildOpts: buildOpts, + }) + return nil + } +} + +// WithBusyboxCommands adds Go commands to compile in a busybox and add to the +// archive. +// +// If there were already busybox commands added to the archive, the given cmd +// will be merged with them. +// +// Allowed formats for cmd are documented in [WithCommands]. +func WithBusyboxCommands(cmd ...string) Modifier { + return func(o *Opts) error { + o.AddBusyboxCommands(cmd...) + return nil + } +} + +// WithBinaryCommands adds Go commands to compile as individual binaries and +// add to the archive. +// +// Allowed formats for cmd are documented in [WithCommands]. +func WithBinaryCommands(cmd ...string) Modifier { + return WithCommands(nil, builder.Binary, cmd...) +} + +// WithOutput sets the archive output file. +func WithOutput(w initramfs.WriteOpener) Modifier { + return func(o *Opts) error { + o.OutputFile = w + return nil + } +} + +// WithCPIOOutput sets the archive output file to be a CPIO created at the given path. +func WithCPIOOutput(path string) Modifier { + if path == "" { + return nil + } + return WithOutput(&initramfs.CPIOFile{Path: path}) +} + +// WithBase is an existing initramfs to include in the resulting initramfs. +func WithBase(base initramfs.ReadOpener) Modifier { + return func(o *Opts) error { + o.BaseArchive = base + return nil + } +} + +// WithBaseFile is an existing initramfs read from a CPIO file at the given +// path to include in the resulting initramfs. +func WithBaseFile(path string) Modifier { + if path == "" { + return nil + } + return WithBase(&initramfs.CPIOFile{Path: path}) +} + +// WithBaseArchive is an existing initramfs to include in the resulting initramfs. +func WithBaseArchive(archive *cpio.Archive) Modifier { + return WithBase(&initramfs.Archive{Archive: archive}) +} + +// WithUinitCommand is command to link to /bin/uinit with args. +// +// cmd will be tokenized by a very basic shlex.Split. +// +// This can be an absolute path or the name of a command included in +// Commands. +// +// The u-root init will always attempt to fork/exec a uinit program, +// and append arguments from both the kernel command-line +// (uroot.uinitargs) as well as those specified in cmd. +// +// If this is empty, no uinit symlink will be created, but a user may +// still specify a command called uinit or include a /bin/uinit file. +func WithUinitCommand(cmd string) Modifier { + if cmd == "" { + return nil + } + return func(opts *Opts) error { + args := shlex.Split(cmd) + if len(args) > 0 { + opts.UinitCmd = args[0] + } + if len(args) > 1 { + opts.UinitArgs = args[1:] + } + return nil + } +} + +// WithUinit is command to link to /bin/uinit with args. +// +// This can be an absolute path or the name of a command included in +// Commands. +// +// The u-root init will always attempt to fork/exec a uinit program, +// and append arguments from both the kernel command-line +// (uroot.uinitargs) as well as those specified in cmd. +func WithUinit(arg0 string, args ...string) Modifier { + return func(opts *Opts) error { + opts.UinitCmd = arg0 + opts.UinitArgs = args + return nil + } +} + +// WithInit sets the name of a command to link /init to. +// +// This can be an absolute path or the name of a command included in +// Commands. +func WithInit(arg0 string) Modifier { + return func(opts *Opts) error { + opts.InitCmd = arg0 + return nil + } +} + +// WithShell sets the default shell to start after init, which is a symlink +// from /bin/sh. +// +// This can be an absolute path or the name of a command included in +// Commands. +func WithShell(arg0 string) Modifier { + return func(opts *Opts) error { + opts.DefaultShell = arg0 + return nil + } +} + +// WithTempDir sets a temporary directory to use for building commands. +func WithTempDir(dir string) Modifier { + if dir == "" { + return nil + } + return func(o *Opts) error { + o.TempDir = dir + return nil + } +} + +// Create creates an initramfs from mods specifications. +func Create(l ulog.Logger, mods ...Modifier) error { + o, err := OptionsFor(mods...) + if err != nil { + return err + } + return o.Create(l) +} + // CreateInitramfs creates an initramfs built to opts' specifications. func CreateInitramfs(logger ulog.Logger, opts Opts) error { if _, err := os.Stat(opts.TempDir); os.IsNotExist(err) { diff --git a/uroot/uroot_test.go b/uroot/uroot_test.go index bd62057..d1b3e82 100644 --- a/uroot/uroot_test.go +++ b/uroot/uroot_test.go @@ -20,6 +20,15 @@ import ( "github.com/u-root/uio/ulog/ulogtest" ) +func archive(tb testing.TB, r ...cpio.Record) *cpio.Archive { + tb.Helper() + a, err := cpio.ArchiveFromRecords(r) + if err != nil { + tb.Fatal(err) + } + return a +} + func TestCreateInitramfs(t *testing.T) { dir := t.TempDir() syscall.Umask(0) @@ -474,3 +483,436 @@ func TestCreateInitramfs(t *testing.T) { }) } } + +func TestCreateInitramfsWithAPI(t *testing.T) { + dir := t.TempDir() + syscall.Umask(0) + + tmp777 := filepath.Join(dir, "tmp777") + _ = os.MkdirAll(tmp777, 0o777) + tmp400 := filepath.Join(dir, "tmp400") + _ = os.MkdirAll(tmp400, 0o400) + + somedir := filepath.Join(dir, "dir") + _ = os.MkdirAll(somedir, 0o777) + somefile := filepath.Join(dir, "dir", "somefile") + somefile2 := filepath.Join(dir, "dir", "somefile2") + _ = os.WriteFile(somefile, []byte("foobar"), 0o777) + _ = os.WriteFile(somefile2, []byte("spongebob"), 0o777) + + cwd, _ := os.Getwd() + + l := ulogtest.Logger{TB: t} + + for i, tt := range []struct { + name string + opts []Modifier + noOutput bool + errs []error + validators []itest.ArchiveValidator + }{ + { + name: "BB archive", + opts: []Modifier{ + WithEnv(golang.DisableCGO()), + WithTempDir(dir), + WithInit("init"), + WithShell("ls"), + WithBusyboxCommands( + "github.com/u-root/u-root/cmds/core/init", + "github.com/u-root/u-root/cmds/core/ls", + ), + }, + validators: []itest.ArchiveValidator{ + itest.HasFile{Path: "bbin/bb"}, + itest.HasRecord{R: cpio.Symlink("bbin/init", "bb")}, + itest.HasRecord{R: cpio.Symlink("bbin/ls", "bb")}, + itest.HasRecord{R: cpio.Symlink("bin/defaultsh", "../bbin/ls")}, + itest.HasRecord{R: cpio.Symlink("bin/sh", "../bbin/ls")}, + }, + }, + { + name: "no temp dir", + opts: []Modifier{ + WithInit("init"), + }, + errs: []error{os.ErrNotExist}, + validators: []itest.ArchiveValidator{ + itest.IsEmpty{}, + }, + }, + { + name: "no commands", + opts: []Modifier{ + WithTempDir(dir), + }, + validators: []itest.ArchiveValidator{ + itest.MissingFile{Path: "bbin/bb"}, + }, + }, + { + name: "files", + opts: []Modifier{ + WithTempDir(dir), + WithFiles( + somefile+":etc/somefile", + somefile2+":etc/somefile2", + somefile, + // Empty is ignored. + "", + "uroot_test.go", + filepath.Join(cwd, "uroot_test.go"), + // Parent directory is created. + somefile+":somedir/somefile", + ), + }, + validators: []itest.ArchiveValidator{ + itest.MissingFile{Path: "bbin/bb"}, + itest.HasContent{Path: "etc/somefile", Content: "foobar"}, + itest.HasContent{Path: somefile, Content: "foobar"}, + itest.HasContent{Path: "etc/somefile2", Content: "spongebob"}, + // TODO: This behavior is weird. + itest.HasFile{Path: "uroot_test.go"}, + itest.HasFile{Path: filepath.Join(cwd, "uroot_test.go")}, + itest.HasDir{Path: "somedir"}, + itest.HasContent{Path: "somedir/somefile", Content: "foobar"}, + }, + }, + { + name: "files conflict", + opts: []Modifier{ + WithTempDir(dir), + WithFiles( + somefile+":etc/somefile", + somefile2+":etc/somefile", + ), + }, + errs: []error{os.ErrExist}, + validators: []itest.ArchiveValidator{ + itest.IsEmpty{}, + }, + }, + { + name: "file does not exist", + opts: []Modifier{ + WithTempDir(dir), + WithFiles(filepath.Join(dir, "doesnotexist") + ":etc/somefile"), + }, + errs: []error{os.ErrNotExist}, + validators: []itest.ArchiveValidator{ + itest.IsEmpty{}, + }, + }, + { + name: "files invalid syntax 1", + opts: []Modifier{ + WithTempDir(dir), + WithFiles(":etc/somefile"), + }, + errs: []error{os.ErrInvalid}, + validators: []itest.ArchiveValidator{ + itest.IsEmpty{}, + }, + }, + { + name: "files invalid syntax 2", + opts: []Modifier{ + WithTempDir(dir), + WithFiles(somefile + ":"), + }, + errs: []error{os.ErrInvalid}, + validators: []itest.ArchiveValidator{ + itest.IsEmpty{}, + }, + }, + { + name: "files are directories", + opts: []Modifier{ + WithTempDir(dir), + WithFiles(somedir + ":etc/foo/bar"), + }, + validators: []itest.ArchiveValidator{ + itest.HasDir{Path: "etc"}, + itest.HasDir{Path: "etc/foo"}, + itest.HasDir{Path: "etc/foo/bar"}, + itest.HasContent{Path: "etc/foo/bar/somefile", Content: "foobar"}, + itest.HasContent{Path: "etc/foo/bar/somefile2", Content: "spongebob"}, + }, + }, + { + name: "files are directories SkipLDD", + opts: []Modifier{ + WithTempDir(dir), + WithFiles(somedir + ":etc/foo/bar"), + WithSkipLDD(), + }, + validators: []itest.ArchiveValidator{ + itest.HasDir{Path: "etc"}, + itest.HasDir{Path: "etc/foo"}, + itest.HasDir{Path: "etc/foo/bar"}, + itest.HasContent{Path: "etc/foo/bar/somefile", Content: "foobar"}, + itest.HasContent{Path: "etc/foo/bar/somefile2", Content: "spongebob"}, + }, + }, + { + name: "file conflicts with init", + opts: []Modifier{ + WithTempDir(dir), + WithInit("/bin/systemd"), + WithFiles(somefile + ":init"), + }, + errs: []error{os.ErrExist, errInitSymlink}, + validators: []itest.ArchiveValidator{ + itest.IsEmpty{}, + }, + }, + { + name: "file conflicts with uinit flags", + opts: []Modifier{ + WithTempDir(dir), + WithUinitCommand("huh -foo -bar"), + WithFiles(somefile + ":etc/uinit.flags"), + }, + errs: []error{os.ErrExist, errUinitArgs}, + validators: []itest.ArchiveValidator{ + itest.IsEmpty{}, + }, + }, + { + name: "file conflicts with uinit", + opts: []Modifier{ + WithTempDir(dir), + WithUinit("/bin/systemd"), + WithFiles(somefile + ":bin/uinit"), + }, + errs: []error{os.ErrExist, errUinitSymlink}, + validators: []itest.ArchiveValidator{ + itest.IsEmpty{}, + }, + }, + { + name: "file conflicts with sh", + opts: []Modifier{ + WithTempDir(dir), + WithShell("/bin/systemd"), + WithFiles(somefile + ":bin/sh"), + }, + errs: []error{os.ErrExist, errDefaultshSymlink}, + validators: []itest.ArchiveValidator{ + itest.IsEmpty{}, + }, + }, + { + name: "file conflicts with defaultsh", + opts: []Modifier{ + WithTempDir(dir), + WithShell("/bin/systemd"), + WithFiles(somefile + ":bin/defaultsh"), + }, + errs: []error{os.ErrExist, errDefaultshSymlink}, + validators: []itest.ArchiveValidator{ + itest.IsEmpty{}, + }, + }, + { + name: "file does not conflict if default files not specified", + opts: []Modifier{ + WithTempDir(dir), + // No DefaultShell, Init, or UinitCmd. + WithFiles( + somefile+":bin/defaultsh", + somefile+":bin/sh", + somefile+":bin/uinit", + somefile+":etc/uinit.flags", + somefile+":init", + ), + }, + validators: []itest.ArchiveValidator{ + itest.HasContent{Path: "bin/defaultsh", Content: "foobar"}, + itest.HasContent{Path: "bin/sh", Content: "foobar"}, + itest.HasContent{Path: "bin/uinit", Content: "foobar"}, + itest.HasContent{Path: "etc/uinit.flags", Content: "foobar"}, + itest.HasContent{Path: "init", Content: "foobar"}, + }, + }, + { + name: "init specified, but not in commands", + opts: []Modifier{ + WithTempDir(dir), + WithEnv(golang.DisableCGO()), + WithInit("foobar"), + WithBinaryCommands( + "github.com/u-root/u-root/cmds/core/ls", + ), + }, + errs: []error{errSymlink, errInitSymlink}, + validators: []itest.ArchiveValidator{ + itest.IsEmpty{}, + }, + }, + /* TODO: case broken. + { + name: "init not resolvable", + opts: Opts{ + TempDir: dir, + InitCmd: "init", + }, + errs: []error{errSymlink, errInitSymlink}, + validators: []itest.ArchiveValidator{ + itest.IsEmpty{}, + }, + }, + */ + { + name: "init symlinked to absolute path", + opts: []Modifier{ + WithTempDir(dir), + WithInit("/bin/systemd"), + }, + validators: []itest.ArchiveValidator{ + itest.HasRecord{R: cpio.Symlink("init", "bin/systemd")}, + }, + }, + { + name: "multi-mode archive", + opts: []Modifier{ + WithTempDir(dir), + WithEnv(golang.DisableCGO()), + WithInit("init"), + WithShell("ls"), + WithBusyboxCommands( + "github.com/u-root/u-root/cmds/core/init", + "github.com/u-root/u-root/cmds/core/ls", + ), + WithBinaryCommands( + "github.com/u-root/u-root/cmds/core/cp", + "github.com/u-root/u-root/cmds/core/dd", + ), + }, + validators: []itest.ArchiveValidator{ + itest.HasRecord{R: cpio.Symlink("init", "bbin/init")}, + + // bb mode. + itest.HasFile{Path: "bbin/bb"}, + itest.HasRecord{R: cpio.Symlink("bbin/init", "bb")}, + itest.HasRecord{R: cpio.Symlink("bbin/ls", "bb")}, + itest.HasRecord{R: cpio.Symlink("bin/defaultsh", "../bbin/ls")}, + itest.HasRecord{R: cpio.Symlink("bin/sh", "../bbin/ls")}, + + // binary mode. + itest.HasFile{Path: "bin/cp"}, + itest.HasFile{Path: "bin/dd"}, + }, + }, + { + name: "glob fail", + opts: []Modifier{ + WithTempDir(dir), + WithEnv(golang.DisableCGO()), + WithBinaryCommands("github.com/u-root/u-root/cmds/notexist/*"), + }, + errs: []error{errResolvePackage}, + validators: []itest.ArchiveValidator{ + itest.IsEmpty{}, + }, + }, + { + name: "tmp not writable", + opts: []Modifier{ + WithEnv(golang.DisableCGO()), + WithTempDir(tmp400), + WithBinaryCommands("github.com/u-root/u-root/cmds/core/..."), + }, + errs: []error{os.ErrPermission}, + validators: []itest.ArchiveValidator{ + itest.IsEmpty{}, + }, + }, + { + name: "cpio no path given", + opts: []Modifier{ + WithTempDir(dir), + WithInit("/bin/systemd"), + WithOutput(&initramfs.CPIOFile{}), + }, + noOutput: true, + errs: []error{initramfs.ErrNoPath}, + }, + { + name: "dir no path given", + opts: []Modifier{ + WithTempDir(dir), + WithInit("/bin/systemd"), + WithOutput(&initramfs.Dir{}), + }, + noOutput: true, + errs: []error{initramfs.ErrNoPath}, + }, + { + name: "dir failed to create", + opts: []Modifier{ + WithTempDir(dir), + WithInit("/bin/systemd"), + WithOutput(&initramfs.Dir{Path: filepath.Join(tmp400, "foobar")}), + }, + noOutput: true, + errs: []error{os.ErrPermission}, + }, + { + name: "cpio failed to create", + opts: []Modifier{ + WithTempDir(dir), + WithInit("/bin/systemd"), + WithOutput(&initramfs.CPIOFile{Path: filepath.Join(tmp400, "foobar")}), + }, + noOutput: true, + errs: []error{os.ErrPermission}, + }, + { + name: "cpio basefile no path given", + opts: []Modifier{ + WithTempDir(dir), + WithInit("/bin/systemd"), + WithOutput(&initramfs.CPIOFile{}), + }, + noOutput: true, + errs: []error{initramfs.ErrNoPath}, + }, + { + name: "base archive", + opts: []Modifier{ + WithTempDir(dir), + WithInit("/bin/systemd"), + WithBaseArchive(archive(t, + cpio.StaticFile("etc/foo", "bar", 0o777), + )), + }, + validators: []itest.ArchiveValidator{ + itest.HasRecord{R: cpio.Symlink("init", "bin/systemd")}, + itest.HasContent{Path: "etc/foo", Content: "bar"}, + }, + }, + } { + t.Run(fmt.Sprintf("Test %d [%s]", i, tt.name), func(t *testing.T) { + archive := cpio.InMemArchive() + if !tt.noOutput { + tt.opts = append(tt.opts, WithOutput(&initramfs.Archive{Archive: archive})) + } + err := Create(l, tt.opts...) + for _, want := range tt.errs { + if !errors.Is(err, want) { + t.Errorf("CreateInitramfs = %v, want %v", err, want) + } + } + if err != nil && len(tt.errs) == 0 { + t.Errorf("CreateInitramfs = %v, want %v", err, nil) + } + + for _, v := range tt.validators { + if err := v.Validate(archive); err != nil { + t.Errorf("validator failed: %v / archive:\n%s", err, archive) + } + } + }) + } +}