From 6c200f0cd7b558d4bf3cc87ecb3fcefac1e69b6f Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Tue, 6 Oct 2026 13:11:58 +0200 Subject: [PATCH 1/4] Remove the Go OCI image tool cmd/oci pulls images into an OCI layout that nothing in the tree reads: no command unpacks or runs what it stores. Delete it with go.mod, go.sum, mk/oci.mk, its Linux CI job, and docs/oci-images.md. It was the only Go code, so nothing left needs a Go toolchain. The references go with it: the README and docs/usage.md sections; the docs/testing.md row and section; the mk/oci.mk entry in proof-scope.py; the --user help clause and docs/usage.md sentence naming elfuse-oci as the resolver of symbolic names; a test comment naming its run command; the launch.c comment naming a planned OCI run helper; the image entrypoint, OCI layer, and container workload examples in test-launch-flags.sh, chown-overlay.c, and test-sysroot-tmp-remove.c; and the check-skill-refs.py test vector named after cmd/elfuse-container, an earlier OCI tool. --- .github/workflows/build.yml | 21 -- Makefile | 1 - README.md | 9 - cmd/oci/common.go | 96 ------- cmd/oci/common_test.go | 60 ---- cmd/oci/flock.go | 61 ---- cmd/oci/helpers_test.go | 222 --------------- cmd/oci/main.go | 74 ----- cmd/oci/main_command_test.go | 80 ------ cmd/oci/main_test.go | 24 -- cmd/oci/pull.go | 189 ------------- cmd/oci/pull_test.go | 273 ------------------ cmd/oci/store.go | 477 -------------------------------- cmd/oci/store_test.go | 332 ---------------------- docs/oci-images.md | 82 ------ docs/testing.md | 14 - docs/usage.md | 57 +--- go.mod | 20 -- go.sum | 38 --- mk/oci.mk | 41 --- scripts/check-skill-refs.py | 4 +- scripts/proof-scope.py | 1 - src/core/launch.c | 3 +- src/main.c | 3 +- src/syscall/chown-overlay.c | 7 +- tests/test-launch-flags.sh | 8 +- tests/test-sysroot-tmp-remove.c | 4 +- 27 files changed, 14 insertions(+), 2187 deletions(-) delete mode 100644 cmd/oci/common.go delete mode 100644 cmd/oci/common_test.go delete mode 100644 cmd/oci/flock.go delete mode 100644 cmd/oci/helpers_test.go delete mode 100644 cmd/oci/main.go delete mode 100644 cmd/oci/main_command_test.go delete mode 100644 cmd/oci/main_test.go delete mode 100644 cmd/oci/pull.go delete mode 100644 cmd/oci/pull_test.go delete mode 100644 cmd/oci/store.go delete mode 100644 cmd/oci/store_test.go delete mode 100644 docs/oci-images.md delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 mk/oci.mk diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7208d6432..91f8ab731 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -126,27 +126,6 @@ jobs: retention-days: 7 if-no-files-found: warn - oci-linux: - name: elfuse-oci (Linux) - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@v7 - - - name: Set up Go - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - - - name: Build, lint and test elfuse-oci - run: | - set -euo pipefail - go version - make elfuse-oci - make oci-lint - make oci-test - runtime-macos: name: Runtime (${{ matrix.name }}) needs: build-macos diff --git a/Makefile b/Makefile index 3eb5788ec..eeb088a39 100644 --- a/Makefile +++ b/Makefile @@ -793,4 +793,3 @@ include mk/lint.mk include mk/verify.mk include mk/format.mk include mk/help.mk -include mk/oci.mk diff --git a/README.md b/README.md index 9de803edc..18f574227 100644 --- a/README.md +++ b/README.md @@ -44,13 +44,6 @@ linker resolved against an external sysroot via `--sysroot`. - Self-contained test matrix that cross-checks elfuse against QEMU and exercises a separate Rosetta acceptance suite -## OCI Images - -`elfuse-oci` is a separate Go binary that pulls OCI images into a local -OCI image layout. It does not add container isolation. See -[docs/usage.md](docs/usage.md#oci-images) and -[docs/oci-images.md](docs/oci-images.md). - ## Positioning `elfuse` is intentionally narrow. It runs single Linux binaries (and @@ -158,8 +151,6 @@ The build signs `build/elfuse` before use. Override the signing identity with - [docs/testing.md](docs/testing.md): build prerequisites, the `make check` flow, the QEMU and Rosetta cross-check matrices, and fixture handling. -- [docs/oci-images.md](docs/oci-images.md): the `elfuse-oci` store, - pull behavior, and validation. - [docs/filenames.md](docs/filenames.md): how a guest filename becomes a name on disk and back: case folding and normalization on the sysroot volume, the escape encoding, and the length limits both systems impose. diff --git a/cmd/oci/common.go b/cmd/oci/common.go deleted file mode 100644 index 40379b53e..000000000 --- a/cmd/oci/common.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2026 elfuse contributors -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - v1 "github.com/google/go-containerregistry/pkg/v1" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" -) - -var version = "dev" - -var defaultPlatform = ocispec.Platform{OS: "linux", Architecture: "arm64"} - -func parsePlatform(s string) (ocispec.Platform, error) { - parts := strings.Split(s, "/") - if len(parts) < 2 || len(parts) > 3 || parts[0] == "" || parts[1] == "" || len(parts) == 3 && parts[2] == "" { - return ocispec.Platform{}, fmt.Errorf("invalid --platform %q: expected os/arch[/variant]", s) - } - p, err := v1.ParsePlatform(s) - if err != nil { - return ocispec.Platform{}, fmt.Errorf("invalid --platform: %w", err) - } - if p.OS != "linux" { - return ocispec.Platform{}, fmt.Errorf("invalid --platform %q: OS must be linux", s) - } - switch p.Architecture { - case "aarch64": - p.Architecture = "arm64" - case "x86_64": - p.Architecture = "amd64" - } - if p.Architecture != "arm64" && p.Architecture != "amd64" { - return ocispec.Platform{}, fmt.Errorf("invalid --platform %q: architecture must be arm64 or amd64", s) - } - if p.Architecture == "arm64" && p.Variant == "v8" { - p.Variant = "" - } - if p.Variant != "" { - return ocispec.Platform{}, fmt.Errorf("invalid --platform %q: variants are not supported", s) - } - if p.OSVersion != "" || len(p.OSFeatures) != 0 || len(p.Features) != 0 { - return ocispec.Platform{}, fmt.Errorf("invalid --platform %q: expected os/arch[/variant]", s) - } - return ocispec.Platform{OS: p.OS, Architecture: p.Architecture, Variant: p.Variant}, nil -} - -func platformString(p ocispec.Platform) string { - return v1.Platform{OS: p.OS, Architecture: p.Architecture, Variant: p.Variant}.String() -} - -func defaultStore() (string, error) { - if s := os.Getenv("ELFUSE_OCI_STORE"); s != "" { - return s, nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("no --store given and HOME is unset: %w", err) - } - return filepath.Join(home, ".local", "share", "elfuse", "oci"), nil -} - -type commonFlags struct { - Store string `help:"OCI store directory" env:"ELFUSE_OCI_STORE" type:"path"` - Platform string `help:"Target platform os/arch[/variant]" default:"linux/arm64"` -} - -func (cf *commonFlags) values() (string, ocispec.Platform, error) { - root := cf.Store - if root == "" { - var err error - root, err = defaultStore() - if err != nil { - return "", ocispec.Platform{}, err - } - } - p, err := parsePlatform(cf.Platform) - if err != nil { - return "", ocispec.Platform{}, err - } - return root, p, nil -} - -func (cf *commonFlags) openStore() (*store, ocispec.Platform, error) { - root, platform, err := cf.values() - if err != nil { - return nil, ocispec.Platform{}, err - } - s, err := openStore(root) - return s, platform, err -} diff --git a/cmd/oci/common_test.go b/cmd/oci/common_test.go deleted file mode 100644 index daa65a014..000000000 --- a/cmd/oci/common_test.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2026 elfuse contributors -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "strings" - "testing" -) - -func TestParsePlatform(t *testing.T) { - for _, tc := range []struct { - in string - want string - ok bool - }{ - {"linux/arm64", "linux/arm64", true}, - {"linux/amd64", "linux/amd64", true}, - {"linux/arm64/v8", "linux/arm64", true}, - {"linux/aarch64", "linux/arm64", true}, - {"linux/x86_64", "linux/amd64", true}, - {"linux//", "", false}, - {"/arm64", "", false}, - {"linux/arm64/", "", false}, - {"linux/arm64/v8/extra", "", false}, - {"darwin/arm64", "", false}, - {"linux/riscv64", "", false}, - {"linux/arm/v7", "", false}, - {"linux/arm64/v9", "", false}, - {"linux/amd64/v3", "", false}, - } { - p, err := parsePlatform(tc.in) - if tc.ok != (err == nil) { - t.Errorf("parsePlatform(%q) err = %v, want ok=%v", tc.in, err, tc.ok) - continue - } - if tc.ok && platformString(p) != tc.want { - t.Errorf("parsePlatform(%q) = %q, want %q", tc.in, platformString(p), tc.want) - } - } - if _, err := parsePlatform("darwin/arm64"); err == nil || !strings.Contains(err.Error(), "must be linux") { - t.Errorf("non-linux error should explain the OS rule: %v", err) - } - if _, err := parsePlatform("linux/riscv64"); err == nil || !strings.Contains(err.Error(), "arm64 or amd64") { - t.Errorf("unsupported-arch error should name the two guests: %v", err) - } -} - -func TestDefaultStoreFromEnv(t *testing.T) { - t.Setenv("ELFUSE_OCI_STORE", "/x/y") - got, err := defaultStore() - if err != nil || got != "/x/y" { - t.Fatalf("defaultStore = %q, %v", got, err) - } - t.Setenv("ELFUSE_OCI_STORE", "") - got, err = defaultStore() - if err != nil || !strings.HasSuffix(got, "/.local/share/elfuse/oci") { - t.Fatalf("defaultStore fallback = %q, %v", got, err) - } -} diff --git a/cmd/oci/flock.go b/cmd/oci/flock.go deleted file mode 100644 index ae42df472..000000000 --- a/cmd/oci/flock.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright 2026 elfuse contributors -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "context" - "errors" - "fmt" - "os" - "syscall" - "time" -) - -const flockPoll = 50 * time.Millisecond - -type flockFile struct { - f *os.File -} - -// Poll LOCK_NB so cancellation bounds the wait. -func acquireFlock(ctx context.Context, path string) (*flockFile, error) { - f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) - if err != nil { - return nil, err - } - for { - if err := ctx.Err(); err != nil { - f.Close() - return nil, fmt.Errorf("lock %s: %w", path, err) - } - err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) - if err == nil { - l := &flockFile{f: f} - // Do not return a lock acquired after cancellation. - if cerr := ctx.Err(); cerr != nil { - l.Close() - return nil, fmt.Errorf("lock %s: %w", path, cerr) - } - return l, nil - } - if errors.Is(err, syscall.EINTR) { - continue - } - if !errors.Is(err, syscall.EWOULDBLOCK) { - f.Close() - return nil, fmt.Errorf("lock %s: %w", path, err) - } - select { - case <-ctx.Done(): - f.Close() - return nil, fmt.Errorf("lock %s: %w", path, ctx.Err()) - case <-time.After(flockPoll): - } - } -} - -func (l *flockFile) Close() error { - _ = syscall.Flock(int(l.f.Fd()), syscall.LOCK_UN) - return l.f.Close() -} diff --git a/cmd/oci/helpers_test.go b/cmd/oci/helpers_test.go deleted file mode 100644 index 627109fb6..000000000 --- a/cmd/oci/helpers_test.go +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright 2026 elfuse contributors -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "context" - "encoding/json" - "io" - "os" - "path/filepath" - "strings" - "testing" - - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/types" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" -) - -type tarEntry struct { - Name string - Body string -} - -func buildLayerTar(t *testing.T, entries []tarEntry) []byte { - t.Helper() - var b bytes.Buffer - tw := tar.NewWriter(&b) - for _, e := range entries { - hdr := &tar.Header{Name: e.Name, Mode: 0o644, Size: int64(len(e.Body)), Typeflag: tar.TypeReg} - if err := tw.WriteHeader(hdr); err != nil { - t.Fatal(err) - } - if _, err := tw.Write([]byte(e.Body)); err != nil { - t.Fatal(err) - } - } - if err := tw.Close(); err != nil { - t.Fatal(err) - } - return b.Bytes() -} - -func gzipBytes(t *testing.T, b []byte) []byte { - t.Helper() - var z bytes.Buffer - zw := gzip.NewWriter(&z) - if _, err := zw.Write(b); err != nil { - t.Fatal(err) - } - if err := zw.Close(); err != nil { - t.Fatal(err) - } - return z.Bytes() -} - -type testImage struct { - platform ocispec.Platform - layers [][]tarEntry -} - -func pushTestImage(t *testing.T, s *store, img testImage) string { - t.Helper() - if img.platform.OS == "" { - img.platform = defaultPlatform - } - if len(img.layers) == 0 { - img.layers = [][]tarEntry{{{Name: "hello", Body: "world"}}} - } - - var layers []v1.Descriptor - var diffIDs []v1.Hash - for _, entries := range img.layers { - raw := buildLayerTar(t, entries) - gz := gzipBytes(t, raw) - layers = append(layers, pushBlob(t, s, types.OCILayer, gz)) - diffID, _, err := v1.SHA256(bytes.NewReader(raw)) - if err != nil { - t.Fatal(err) - } - diffIDs = append(diffIDs, diffID) - } - - cfg := v1.ConfigFile{ - Architecture: img.platform.Architecture, - OS: img.platform.OS, - Variant: img.platform.Variant, - RootFS: v1.RootFS{Type: "layers", DiffIDs: diffIDs}, - } - cfgDesc := pushBlob(t, s, types.OCIConfigJSON, mustJSON(t, cfg)) - manifest := v1.Manifest{ - SchemaVersion: 2, - MediaType: types.OCIManifestSchema1, - Config: cfgDesc, - Layers: layers, - } - return pushBlob(t, s, types.OCIManifestSchema1, mustJSON(t, manifest)).Digest.String() -} - -func pushBlob(t *testing.T, s *store, media types.MediaType, b []byte) v1.Descriptor { - t.Helper() - desc, err := s.writeBlobBytes(context.Background(), media, b) - if err != nil { - t.Fatal(err) - } - return desc -} - -func storeWithImage(t *testing.T, ref string, img testImage) (*store, string) { - t.Helper() - s := tempStore(t) - if img.platform.OS == "" { - img.platform = defaultPlatform - } - digest := pushTestImage(t, s, img) - pinImage(t, s, ref, img.platform, digest) - return s, digest -} - -func pinImage(t *testing.T, s *store, ref string, platform ocispec.Platform, digest string) { - t.Helper() - if err := pinImageError(s, ref, platform, digest); err != nil { - t.Fatal(err) - } -} - -func pinImageError(s *store, ref string, platform ocispec.Platform, digest string) error { - ctx := context.Background() - hash, err := v1.NewHash(digest) - if err != nil { - return err - } - b, err := s.blobBytes(hash) - if err != nil { - return err - } - parsed, err := normalizeRef(ref) - if err != nil { - return err - } - desc := v1.Descriptor{MediaType: types.OCIManifestSchema1, Digest: hash, Size: int64(len(b))} - return s.withLock(ctx, func() error { - return s.pinLocked(ctx, parsed.Name(), platform, desc) - }) -} - -func mustJSON(t *testing.T, v any) []byte { - t.Helper() - b, err := json.Marshal(v) - if err != nil { - t.Fatal(err) - } - return b -} - -func writeShellStub(t *testing.T, name, body string) string { - t.Helper() - p := filepath.Join(t.TempDir(), name) - if err := os.WriteFile(p, []byte("#!/bin/sh\n"+body), 0o755); err != nil { - t.Fatal(err) - } - return p -} - -func prependPath(t *testing.T, dir string) { - t.Helper() - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) -} - -func tempStore(t *testing.T) *store { - t.Helper() - ctx := context.Background() - s, err := openStore(t.TempDir()) - if err != nil { - t.Fatal(err) - } - if err := s.ensureLayout(ctx); err != nil { - t.Fatal(err) - } - return s -} - -func captureOutput(t *testing.T, fn func()) (stdout, stderr string) { - t.Helper() - or, ow, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - er, ew, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - oldOut, oldErr := os.Stdout, os.Stderr - os.Stdout, os.Stderr = ow, ew - outCh := make(chan string, 1) - errCh := make(chan string, 1) - go func() { var b bytes.Buffer; _, _ = io.Copy(&b, or); outCh <- b.String() }() - go func() { var b bytes.Buffer; _, _ = io.Copy(&b, er); errCh <- b.String() }() - defer func() { - os.Stdout, os.Stderr = oldOut, oldErr - ow.Close() - ew.Close() - or.Close() - er.Close() - }() - fn() - ow.Close() - ew.Close() - return <-outCh, <-errCh -} - -func mustContain(t *testing.T, got string, wants ...string) { - t.Helper() - for _, want := range wants { - if !strings.Contains(got, want) { - t.Fatalf("output missing %q:\n%s", want, got) - } - } -} diff --git a/cmd/oci/main.go b/cmd/oci/main.go deleted file mode 100644 index 6177803eb..000000000 --- a/cmd/oci/main.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright 2026 elfuse contributors -// SPDX-License-Identifier: Apache-2.0 - -// Command elfuse-oci manages OCI images outside the elfuse runtime. -package main - -import ( - "errors" - "fmt" - "io" - "os" - - "github.com/alecthomas/kong" -) - -func main() { - if err := run(os.Args[1:]); err != nil { - fmt.Fprintf(os.Stderr, "elfuse-oci: %s\n", err) - os.Exit(1) - } -} - -type cli struct { - Pull pullCommand `cmd:"" help:"Pull an image into the local store"` -} - -func newParser(stdout, stderr io.Writer, target *cli) (*kong.Kong, error) { - return kong.New(target, - kong.Name("elfuse-oci"), - kong.Description("Manage OCI images for elfuse."), - kong.Writers(stdout, stderr), - kong.ConfigureHelp(kong.HelpOptions{Compact: true}), - ) -} - -type parserExit struct{ code int } - -func run(args []string) (err error) { - if len(args) == 1 { - switch args[0] { - case "version", "-V", "--version": - fmt.Println("elfuse-oci " + version) - return nil - case "help": - args[0] = "--help" - } - } - var target cli - parser, err := newParser(os.Stdout, os.Stderr, &target) - if err != nil { - return err - } - parser.Exit = func(code int) { panic(parserExit{code}) } - defer func() { - if v := recover(); v != nil { - exit, ok := v.(parserExit) - if !ok { - panic(v) - } - if exit.code != 0 { - err = fmt.Errorf("parser exited with status %d", exit.code) - } - } - }() - ctx, err := parser.Parse(args) - if err != nil { - var parseErr *kong.ParseError - if errors.As(err, &parseErr) { - _ = parseErr.Context.PrintUsage(false) - } - return err - } - return ctx.Run() -} diff --git a/cmd/oci/main_command_test.go b/cmd/oci/main_command_test.go deleted file mode 100644 index 3df634b39..000000000 --- a/cmd/oci/main_command_test.go +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright 2026 elfuse contributors -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "bytes" - "strings" - "testing" -) - -func TestUsageAndErrors(t *testing.T) { - var err error - stdout, stderr := captureOutput(t, func() { err = run(nil) }) - if err == nil { - t.Fatal("missing command must fail") - } - mustContain(t, stdout, "Usage: elfuse-oci ", "pull") - if stderr != "" { - t.Fatalf("parse error wrote to stderr: %q", stderr) - } - - stdout, stderr = captureOutput(t, func() { err = run([]string{"bogus"}) }) - if err == nil || !strings.Contains(err.Error(), "unexpected argument bogus") { - t.Fatalf("unknown command error = %v", err) - } - mustContain(t, stdout, "Usage: elfuse-oci ", "pull") - if stderr != "" { - t.Fatalf("unknown command wrote to stderr: %q", stderr) - } - - stdout, stderr = captureOutput(t, func() { err = run([]string{"pull", "--nope", "x"}) }) - if err == nil || !strings.Contains(err.Error(), "unknown flag --nope") { - t.Fatalf("unknown flag error = %v", err) - } - mustContain(t, stdout, "Usage: elfuse-oci pull", "--platform", "--store", "--timeout") - if stderr != "" { - t.Fatalf("unknown flag wrote to stderr: %q", stderr) - } -} - -func TestVersionOnStdout(t *testing.T) { - var err error - stdout, stderr := captureOutput(t, func() { err = run([]string{"version"}) }) - if err != nil { - t.Fatal(err) - } - if !strings.HasPrefix(stdout, "elfuse-oci ") || stderr != "" { - t.Fatalf("version stdout = %q stderr = %q", stdout, stderr) - } -} - -func TestSubcommandHelpIsSuccess(t *testing.T) { - var err error - stdout, stderr := captureOutput(t, func() { err = run([]string{"pull", "-h"}) }) - if err != nil { - t.Fatal(err) - } - mustContain(t, stdout, "Usage: elfuse-oci pull", "--platform", "--store", "--timeout") - if stderr != "" { - t.Fatalf("help wrote to stderr: %q", stderr) - } -} - -func TestParserWritesToConfiguredStreams(t *testing.T) { - var target cli - var stdout bytes.Buffer - var stderr bytes.Buffer - parser, err := newParser(&stdout, &stderr, &target) - if err != nil { - t.Fatal(err) - } - _, err = parser.Parse([]string{"pull", "--nope", "x"}) - if err == nil { - t.Fatal("unknown flag must fail") - } - if stdout.Len() != 0 || stderr.Len() != 0 { - t.Fatalf("parse wrote stdout %q stderr %q", stdout.String(), stderr.String()) - } -} diff --git a/cmd/oci/main_test.go b/cmd/oci/main_test.go deleted file mode 100644 index 36407feb5..000000000 --- a/cmd/oci/main_test.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2026 elfuse contributors -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "fmt" - "os" - "testing" -) - -// TestMain isolates the tests from the user's store; os.Exit skips defers. -func TestMain(m *testing.M) { - sandbox, err := os.MkdirTemp("", "elfuse-oci-test-store-") - if err != nil { - panic(err) - } - os.Setenv("ELFUSE_OCI_STORE", sandbox) - code := m.Run() - if err := os.RemoveAll(sandbox); err != nil { - fmt.Fprintf(os.Stderr, "elfuse-oci test: sandbox store left behind: %v\n", err) - } - os.Exit(code) -} diff --git a/cmd/oci/pull.go b/cmd/oci/pull.go deleted file mode 100644 index be773848d..000000000 --- a/cmd/oci/pull.go +++ /dev/null @@ -1,189 +0,0 @@ -// Copyright 2026 elfuse contributors -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "context" - "fmt" - "os" - "time" - - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/name" - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/remote" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" -) - -type pullCommand struct { - commonFlags - Timeout time.Duration `help:"Fail the pull after this duration; zero disables the limit" default:"0"` - Ref string `arg:"" name:"ref" help:"Image reference"` -} - -func (c *pullCommand) Run() error { - if c.Timeout < 0 { - return fmt.Errorf("pull: --timeout %s is negative", c.Timeout) - } - s, platform, err := c.commonFlags.openStore() - if err != nil { - return err - } - ctx := context.Background() - if c.Timeout > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, c.Timeout) - defer cancel() - } - return pullImage(ctx, s, c.Ref, platform) -} - -func normalizeRef(ref string) (name.Reference, error) { - return name.ParseReference(ref, name.WeakValidation) -} - -func imagePlatform(img v1.Image) (v1.Platform, error) { - config, err := img.ConfigFile() - if err != nil { - return v1.Platform{}, err - } - return v1.Platform{ - OS: config.OS, - Architecture: config.Architecture, - Variant: config.Variant, - }, nil -} - -func indexChildImage(index v1.ImageIndex, child v1.Descriptor) (v1.Image, v1.Platform, error) { - if !child.MediaType.IsImage() { - return nil, v1.Platform{}, fmt.Errorf("unexpected index child media type %s", child.MediaType) - } - img, err := index.Image(child.Digest) - if err != nil { - return nil, v1.Platform{}, err - } - actual, err := imagePlatform(img) - return img, actual, err -} - -func normalizeImagePlatform(platform v1.Platform) v1.Platform { - if platform.Architecture == "arm64" && platform.Variant == "v8" { - platform.Variant = "" - } - return platform -} - -func imagePlatformMatches(actual, requested v1.Platform) bool { - actual = normalizeImagePlatform(actual) - requested = normalizeImagePlatform(requested) - return actual.OS == requested.OS && - actual.Architecture == requested.Architecture && - actual.Variant == requested.Variant -} - -func selectIndexImage(index v1.ImageIndex, platform v1.Platform) (v1.Image, error) { - manifest, err := index.IndexManifest() - if err != nil { - return nil, err - } - for _, child := range manifest.Manifests { - if child.Platform == nil || !imagePlatformMatches(*child.Platform, platform) { - continue - } - img, actual, err := indexChildImage(index, child) - if err != nil { - return nil, err - } - if !imagePlatformMatches(actual, platform) { - return nil, fmt.Errorf("image platform is %s, not %s", actual, platform) - } - return img, nil - } - for _, child := range manifest.Manifests { - if child.Platform != nil { - continue - } - img, actual, err := indexChildImage(index, child) - if err != nil { - return nil, err - } - if imagePlatformMatches(actual, platform) { - return img, nil - } - } - return nil, fmt.Errorf("no image for platform %s", platform) -} - -func selectImage(desc *remote.Descriptor, platform v1.Platform) (v1.Image, error) { - if desc.MediaType.IsIndex() { - index, err := desc.ImageIndex() - if err != nil { - return nil, err - } - return selectIndexImage(index, platform) - } - if !desc.MediaType.IsImage() { - return nil, fmt.Errorf("unexpected media type %s", desc.MediaType) - } - img, err := desc.Image() - if err != nil { - return nil, err - } - actual, err := imagePlatform(img) - if err != nil { - return nil, err - } - if !imagePlatformMatches(actual, platform) { - return nil, fmt.Errorf("image platform is %s, not %s", actual, platform) - } - return img, nil -} - -type imageFetcher func(context.Context, name.Reference, v1.Platform) (v1.Image, error) - -func fetchRemoteImage(ctx context.Context, ref name.Reference, platform v1.Platform) (v1.Image, error) { - desc, err := remote.Get(ref, - remote.WithContext(ctx), - remote.WithAuthFromKeychain(authn.DefaultKeychain), - remote.WithPlatform(platform), - ) - if err != nil { - return nil, err - } - return selectImage(desc, platform) -} - -func pullImage(ctx context.Context, s *store, ref string, platform ocispec.Platform) error { - return pullImageWithFetcher(ctx, s, ref, platform, fetchRemoteImage) -} - -func pullImageWithFetcher(ctx context.Context, s *store, ref string, platform ocispec.Platform, fetch imageFetcher) error { - parsed, err := normalizeRef(ref) - if err != nil { - return fmt.Errorf("pull %s: %w", ref, err) - } - fmt.Fprintf(os.Stderr, "Pulling %s...\n", parsed.Name()) - if err := s.ensureLayout(ctx); err != nil { - return err - } - p := v1.Platform{OS: platform.OS, Architecture: platform.Architecture, Variant: platform.Variant} - img, err := fetch(ctx, parsed, p) - if err != nil { - return fmt.Errorf("pull %s: %w", ref, err) - } - manifest, err := s.publishImage(ctx, img, platform) - if err != nil { - return fmt.Errorf("pull %s: %w", ref, err) - } - if err := s.withLock(ctx, func() error { - if err := s.pinLocked(ctx, parsed.Name(), platform, manifest); err != nil { - return err - } - return nil - }); err != nil { - return fmt.Errorf("pull %s: %w", ref, err) - } - fmt.Fprintf(os.Stderr, "Pulled %s -> %s\n", parsed.Name(), manifest.Digest) - return nil -} diff --git a/cmd/oci/pull_test.go b/cmd/oci/pull_test.go deleted file mode 100644 index 2566a173a..000000000 --- a/cmd/oci/pull_test.go +++ /dev/null @@ -1,273 +0,0 @@ -// Copyright 2026 elfuse contributors -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/google/go-containerregistry/pkg/authn" - "github.com/google/go-containerregistry/pkg/name" - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/empty" - "github.com/google/go-containerregistry/pkg/v1/mutate" - "github.com/google/go-containerregistry/pkg/v1/types" -) - -func platformImage(t *testing.T, platform v1.Platform) v1.Image { - t.Helper() - img, err := mutate.ConfigFile(empty.Image, &v1.ConfigFile{ - Architecture: platform.Architecture, - OS: platform.OS, - Variant: platform.Variant, - RootFS: v1.RootFS{Type: "layers"}, - }) - if err != nil { - t.Fatal(err) - } - return mutate.MediaType(img, types.OCIManifestSchema1) -} - -func imageDigest(t *testing.T, img v1.Image) v1.Hash { - t.Helper() - digest, err := img.Digest() - if err != nil { - t.Fatal(err) - } - return digest -} - -func TestNormalizeRef(t *testing.T) { - sha := "sha256:" + strings.Repeat("0", 64) - for _, tc := range []struct{ in, want string }{ - {"alpine", "index.docker.io/library/alpine:latest"}, - {"alpine:3", "index.docker.io/library/alpine:3"}, - {"foo/bar", "index.docker.io/foo/bar:latest"}, - {"docker.io/alpine", "index.docker.io/library/alpine:latest"}, - {"ghcr.io/foo/bar:v1", "ghcr.io/foo/bar:v1"}, - {"ghcr.io/foo/bar", "ghcr.io/foo/bar:latest"}, - {"localhost/foo", "localhost/foo:latest"}, - {"localhost:5000/foo", "localhost:5000/foo:latest"}, - {"alpine@" + sha, "index.docker.io/library/alpine@" + sha}, - {"registry.example.com:8443/a/b:t", "registry.example.com:8443/a/b:t"}, - } { - got, err := normalizeRef(tc.in) - if err != nil || got.Name() != tc.want { - t.Errorf("normalizeRef(%q) = %v, %v; want %q", tc.in, got, err, tc.want) - } - } - for _, bad := range []string{"", "Alpine", "alpine:", "a@sha256:short"} { - if _, err := normalizeRef(bad); err == nil { - t.Errorf("normalizeRef(%q) must fail", bad) - } - } -} - -func TestPullFlagParsing(t *testing.T) { - var target cli - parser, err := newParser(os.Stdout, os.Stderr, &target) - if err != nil { - t.Fatal(err) - } - ctx, err := parser.Parse([]string{"pull", "--timeout", "5s", "x:1", "--platform", "linux/amd64"}) - if err != nil { - t.Fatal(err) - } - if ctx.Command() != "pull " || target.Pull.Ref != "x:1" || target.Pull.Timeout.String() != "5s" || target.Pull.Platform != "linux/amd64" { - t.Fatalf("parsed command %q: %+v", ctx.Command(), target.Pull) - } - if _, err := parser.Parse([]string{"pull", "a", "b"}); err == nil { - t.Fatal("two positionals must fail") - } -} - -func TestSelectIndexImage(t *testing.T) { - arm := v1.Platform{OS: "linux", Architecture: "arm64"} - amd := v1.Platform{OS: "linux", Architecture: "amd64"} - armImage := platformImage(t, arm) - amdImage := platformImage(t, amd) - index := mutate.AppendManifests(empty.Index, - mutate.IndexAddendum{Add: amdImage}, - mutate.IndexAddendum{Add: armImage}, - ) - got, err := selectIndexImage(index, arm) - if err != nil { - t.Fatal(err) - } - if imageDigest(t, got) != imageDigest(t, armImage) { - t.Fatal("the later matching platformless image was not selected") - } - - declared := mutate.AppendManifests(empty.Index, - mutate.IndexAddendum{Add: amdImage, Descriptor: v1.Descriptor{Platform: &amd}}, - ) - if _, err := selectIndexImage(declared, arm); err == nil || !strings.Contains(err.Error(), "no image") { - t.Fatalf("no-match error = %v", err) - } -} - -func TestSelectIndexImagePlatformRules(t *testing.T) { - arm := v1.Platform{OS: "linux", Architecture: "arm64"} - armV8 := v1.Platform{OS: "linux", Architecture: "arm64", Variant: "v8"} - amd := v1.Platform{OS: "linux", Architecture: "amd64"} - - v8Image := platformImage(t, armV8) - v8Index := mutate.AppendManifests(empty.Index, - mutate.IndexAddendum{Add: v8Image, Descriptor: v1.Descriptor{Platform: &armV8}}, - ) - if _, err := selectIndexImage(v8Index, arm); err != nil { - t.Fatalf("arm64/v8 selection: %v", err) - } - - amdV3 := v1.Platform{OS: "linux", Architecture: "amd64", Variant: "v3"} - v3Index := mutate.AppendManifests(empty.Index, - mutate.IndexAddendum{Add: platformImage(t, amdV3), Descriptor: v1.Descriptor{Platform: &amdV3}}, - ) - if _, err := selectIndexImage(v3Index, amd); err == nil || !strings.Contains(err.Error(), "no image") { - t.Fatalf("amd64/v3 selection error = %v", err) - } - - mismatch := mutate.AppendManifests(empty.Index, - mutate.IndexAddendum{Add: platformImage(t, amd), Descriptor: v1.Descriptor{Platform: &arm}}, - ) - if _, err := selectIndexImage(mismatch, arm); err == nil || !strings.Contains(err.Error(), "image platform") { - t.Fatalf("config mismatch error = %v", err) - } - - nested := mutate.AppendManifests(empty.Index, - mutate.IndexAddendum{Add: empty.Index, Descriptor: v1.Descriptor{Platform: &arm}}, - ) - if _, err := selectIndexImage(nested, arm); err == nil || !strings.Contains(err.Error(), "index child") { - t.Fatalf("nested-index error = %v", err) - } -} - -func TestDefaultKeychainUsesCredentialHelper(t *testing.T) { - helper := writeShellStub(t, "docker-credential-elfuse-test", ` -test "$1" = get || exit 1 -read server -test "$server" = registry.example.com || exit 1 -printf '%s\n' '{"Username":"review-user","Secret":"review-secret"}' -`) - prependPath(t, filepath.Dir(helper)) - configDir := t.TempDir() - config := []byte(`{"credHelpers":{"registry.example.com":"elfuse-test"}}`) - if err := os.WriteFile(filepath.Join(configDir, "config.json"), config, 0o600); err != nil { - t.Fatal(err) - } - t.Setenv("DOCKER_CONFIG", configDir) - ref, err := name.ParseReference("registry.example.com/private/image:1") - if err != nil { - t.Fatal(err) - } - authenticator, err := authn.DefaultKeychain.Resolve(ref.Context()) - if err != nil { - t.Fatal(err) - } - auth, err := authenticator.Authorization() - if err != nil { - t.Fatal(err) - } - if auth.Username != "review-user" || auth.Password != "review-secret" { - t.Fatalf("helper credentials = %q/%q", auth.Username, auth.Password) - } -} - -func TestConcurrentPullsDoNotSerializeFetch(t *testing.T) { - s := tempStore(t) - image := platformImage(t, v1.Platform{OS: "linux", Architecture: "arm64"}) - entered := make(chan struct{}) - release := make(chan struct{}) - fetch := func(ctx context.Context, ref name.Reference, _ v1.Platform) (v1.Image, error) { - if strings.Contains(ref.Name(), "slow:1") { - close(entered) - select { - case <-release: - case <-ctx.Done(): - return nil, ctx.Err() - } - } - return image, nil - } - slowErr := make(chan error, 1) - go func() { - slowErr <- pullImageWithFetcher(context.Background(), s, "slow:1", defaultPlatform, fetch) - }() - <-entered - fastErr := make(chan error, 1) - go func() { - fastErr <- pullImageWithFetcher(context.Background(), s, "fast:1", defaultPlatform, fetch) - }() - select { - case err := <-fastErr: - if err != nil { - t.Fatal(err) - } - case <-time.After(3 * time.Second): - t.Fatal("fast pull blocked on the slow pull's registry transfer") - } - close(release) - if err := <-slowErr; err != nil { - t.Fatal(err) - } - index, err := s.rootIndex() - if err != nil { - t.Fatal(err) - } - if len(index.Manifests) != 2 { - t.Fatalf("root descriptors = %d, want 2", len(index.Manifests)) - } -} - -func TestPullRegistryRoundTrip(t *testing.T) { - if os.Getenv("ELFUSE_OCI_NETTEST") == "" { - t.Skip("set ELFUSE_OCI_NETTEST=1 to pull from a real registry") - } - s := tempStore(t) - var err error - captureOutput(t, func() { - err = run([]string{"pull", "--store", s.root, "alpine:3"}) - }) - if err != nil { - t.Fatal(err) - } - index, err := s.rootIndex() - if err != nil { - t.Fatal(err) - } - if len(index.Manifests) != 1 { - t.Fatalf("root descriptors = %d, want 1", len(index.Manifests)) - } - nestedBytes, err := s.blobBytes(index.Manifests[0].Digest) - if err != nil { - t.Fatal(err) - } - var nested v1.IndexManifest - if err := json.Unmarshal(nestedBytes, &nested); err != nil { - t.Fatal(err) - } - if len(nested.Manifests) != 1 || nested.Manifests[0].Digest.String() == "" { - t.Fatalf("nested index = %+v", nested) - } - manifest := nested.Manifests[0].Digest - if _, err := os.Stat(filepath.Join(s.root, "blobs", manifest.Algorithm, manifest.Hex)); err != nil { - t.Errorf("manifest blob %s: %v", manifest, err) - } -} - -func TestPullCommandRefusesNegativeTimeout(t *testing.T) { - cmd := pullCommand{ - commonFlags: commonFlags{Store: t.TempDir(), Platform: platformString(defaultPlatform)}, - Timeout: -1, - Ref: "x:1", - } - if err := cmd.Run(); err == nil || !strings.Contains(err.Error(), "negative") { - t.Fatalf("error = %v", err) - } -} diff --git a/cmd/oci/store.go b/cmd/oci/store.go deleted file mode 100644 index cde7e3db0..000000000 --- a/cmd/oci/store.go +++ /dev/null @@ -1,477 +0,0 @@ -// Copyright 2026 elfuse contributors -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "bytes" - "context" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "sort" - "strings" - "time" - - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/layout" - "github.com/google/go-containerregistry/pkg/v1/types" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" -) - -const ( - markerName = ".elfuse-oci-store" - markerContents = "1\n" - metadataLockName = ".lock" - refNameAnnotation = "org.opencontainers.image.ref.name" -) - -type store struct { - root string -} - -func openStore(root string) (*store, error) { - root = filepath.Clean(root) - if err := os.MkdirAll(root, 0o700); err != nil { - return nil, err - } - fi, err := os.Stat(root) - if err != nil { - return nil, err - } - if !fi.IsDir() { - return nil, fmt.Errorf("store: %s is not a directory", root) - } - if err := os.Chmod(root, 0o700); err != nil { - return nil, err - } - return &store{root: root}, nil -} - -func (s *store) lockPath() string { return filepath.Join(s.root, metadataLockName) } - -func (s *store) withLock(ctx context.Context, fn func() error) error { - l, err := acquireFlock(ctx, s.lockPath()) - if err != nil { - return fmt.Errorf("store: %w", err) - } - defer l.Close() - return fn() -} - -func (s *store) ensureLayout(ctx context.Context) error { - return s.withLock(ctx, func() error { return s.ensureLayoutLocked(ctx) }) -} - -func isMetadataTemp(name string) bool { - for _, target := range []string{markerName, "oci-layout", "index.json"} { - if strings.HasPrefix(name, "."+target+".tmp.") { - return true - } - } - return false -} - -func (s *store) ensureLayoutLocked(ctx context.Context) error { - if err := ctx.Err(); err != nil { - return err - } - marker, err := os.ReadFile(filepath.Join(s.root, markerName)) - if os.IsNotExist(err) { - if _, legacyErr := os.Lstat(filepath.Join(s.root, "refs.json")); legacyErr == nil { - return fmt.Errorf("store: legacy refs.json layout; remove the store and pull again") - } - entries, readErr := os.ReadDir(s.root) - if readErr != nil { - return readErr - } - for _, entry := range entries { - if entry.Name() == metadataLockName { - continue - } - if !isMetadataTemp(entry.Name()) || entry.IsDir() { - return fmt.Errorf("store: %s is not an elfuse OCI store", s.root) - } - if err := os.Remove(filepath.Join(s.root, entry.Name())); err != nil { - return err - } - } - if err := replaceFile(ctx, s.root, markerName, []byte(markerContents), 0o600); err != nil { - return err - } - marker = []byte(markerContents) - } else if err != nil { - return err - } - if string(marker) != markerContents { - return fmt.Errorf("store: unsupported format marker %q", strings.TrimSpace(string(marker))) - } - if err := ensureJSONFile(ctx, filepath.Join(s.root, "oci-layout"), []byte("{\"imageLayoutVersion\":\"1.0.0\"}\n")); err != nil { - return err - } - empty := v1.IndexManifest{SchemaVersion: 2, MediaType: types.OCIImageIndex} - b, err := json.MarshalIndent(empty, "", " ") - if err != nil { - return err - } - b = append(b, '\n') - return ensureJSONFile(ctx, filepath.Join(s.root, "index.json"), b) -} - -func ensureJSONFile(ctx context.Context, path string, initial []byte) error { - if err := ctx.Err(); err != nil { - return err - } - b, err := os.ReadFile(path) - if os.IsNotExist(err) { - return replaceFile(ctx, filepath.Dir(path), filepath.Base(path), initial, 0o600) - } - if err != nil { - return err - } - if err := ctx.Err(); err != nil { - return err - } - var value any - if err := json.Unmarshal(b, &value); err != nil { - return fmt.Errorf("store: corrupt %s: %w", filepath.Base(path), err) - } - return nil -} - -func replaceFile(ctx context.Context, dir, name string, content []byte, mode os.FileMode) error { - if err := ctx.Err(); err != nil { - return err - } - tmp := fmt.Sprintf(".%s.tmp.%d.%d", name, os.Getpid(), time.Now().UnixNano()) - tmpPath := filepath.Join(dir, tmp) - f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) - if err != nil { - return err - } - cleanup := func() { _ = os.Remove(tmpPath) } - if _, err := f.Write(content); err != nil { - f.Close() - cleanup() - return err - } - if err := ctx.Err(); err != nil { - f.Close() - cleanup() - return err - } - if err := f.Sync(); err != nil { - f.Close() - cleanup() - return err - } - if err := f.Close(); err != nil { - cleanup() - return err - } - if err := ctx.Err(); err != nil { - cleanup() - return err - } - if err := os.Rename(tmpPath, filepath.Join(dir, name)); err != nil { - cleanup() - return err - } - d, err := os.Open(dir) - if err != nil { - return err - } - defer d.Close() - return d.Sync() -} - -func (s *store) rootIndex() (v1.IndexManifest, error) { - var index v1.IndexManifest - b, err := os.ReadFile(filepath.Join(s.root, "index.json")) - if err != nil { - return index, err - } - if err := json.Unmarshal(b, &index); err != nil { - return index, fmt.Errorf("store: corrupt index.json: %w", err) - } - if index.SchemaVersion != 2 { - return index, fmt.Errorf("store: index.json schema version is %d", index.SchemaVersion) - } - return index, nil -} - -func (s *store) blobBytes(hash v1.Hash) ([]byte, error) { - b, err := layout.Path(s.root).Bytes(hash) - if err != nil { - return nil, fmt.Errorf("store: read blob %s: %w", hash, err) - } - return b, nil -} - -type contextReader struct { - ctx context.Context - r io.Reader -} - -func (r contextReader) Read(p []byte) (int, error) { - if err := r.ctx.Err(); err != nil { - return 0, err - } - return r.r.Read(p) -} - -func ensurePrivateDir(path string) error { - if err := os.MkdirAll(path, 0o700); err != nil { - return err - } - return os.Chmod(path, 0o700) -} - -func syncDirectory(path string) error { - d, err := os.Open(path) - if err != nil { - return err - } - err = d.Sync() - if closeErr := d.Close(); err == nil { - err = closeErr - } - return err -} - -func (s *store) writeBlob(ctx context.Context, desc v1.Descriptor, r io.ReadCloser) error { - defer r.Close() - if err := ctx.Err(); err != nil { - return err - } - dir := filepath.Join(s.root, "blobs", desc.Digest.Algorithm) - if err := ensurePrivateDir(dir); err != nil { - return err - } - if err := os.Chmod(filepath.Join(s.root, "blobs"), 0o700); err != nil { - return err - } - path := filepath.Join(dir, desc.Digest.Hex) - if f, err := os.Open(path); err == nil { - fi, statErr := f.Stat() - if err := ctx.Err(); err != nil { - f.Close() - return err - } - hasher, hashErr := v1.Hasher(desc.Digest.Algorithm) - if hashErr == nil { - _, hashErr = io.Copy(hasher, contextReader{ctx: ctx, r: f}) - } - closeErr := f.Close() - got := v1.Hash{Algorithm: desc.Digest.Algorithm} - if hashErr == nil { - got.Hex = hex.EncodeToString(hasher.Sum(nil)) - } - if statErr != nil || hashErr != nil || closeErr != nil || got != desc.Digest || fi.Size() != desc.Size { - return fmt.Errorf("store: corrupt blob %s", desc.Digest) - } - return nil - } else if !os.IsNotExist(err) { - return err - } - tmp, err := os.CreateTemp(dir, ".blob-*") - if err != nil { - return err - } - tmpPath := tmp.Name() - defer os.Remove(tmpPath) - hasher, err := v1.Hasher(desc.Digest.Algorithm) - if err != nil { - tmp.Close() - return err - } - n, err := io.Copy(io.MultiWriter(tmp, hasher), contextReader{ctx: ctx, r: r}) - if err != nil { - tmp.Close() - return err - } - got := v1.Hash{Algorithm: desc.Digest.Algorithm, Hex: hex.EncodeToString(hasher.Sum(nil))} - if n != desc.Size || got != desc.Digest { - tmp.Close() - return fmt.Errorf("store: blob %s content mismatch", desc.Digest) - } - if err := ctx.Err(); err != nil { - tmp.Close() - return err - } - if err := tmp.Sync(); err != nil { - tmp.Close() - return err - } - if err := tmp.Chmod(0o400); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := ctx.Err(); err != nil { - return err - } - if err := os.Rename(tmpPath, path); err != nil { - return err - } - for _, path := range []string{dir, filepath.Join(s.root, "blobs"), s.root} { - if err := syncDirectory(path); err != nil { - return err - } - } - return nil -} - -func (s *store) writeBlobBytes(ctx context.Context, media types.MediaType, b []byte) (v1.Descriptor, error) { - hash, size, err := v1.SHA256(bytes.NewReader(b)) - if err != nil { - return v1.Descriptor{}, err - } - desc := v1.Descriptor{MediaType: media, Digest: hash, Size: size} - return desc, s.writeBlob(ctx, desc, io.NopCloser(bytes.NewReader(b))) -} - -func (s *store) publishImage(ctx context.Context, img v1.Image, platform ocispec.Platform) (v1.Descriptor, error) { - if err := ctx.Err(); err != nil { - return v1.Descriptor{}, err - } - layers, err := img.Layers() - if err != nil { - return v1.Descriptor{}, err - } - for _, layer := range layers { - if err := ctx.Err(); err != nil { - return v1.Descriptor{}, err - } - digest, err := layer.Digest() - if err != nil { - return v1.Descriptor{}, err - } - size, err := layer.Size() - if err != nil { - return v1.Descriptor{}, err - } - media, err := layer.MediaType() - if err != nil { - return v1.Descriptor{}, err - } - r, err := layer.Compressed() - if err != nil { - return v1.Descriptor{}, err - } - if err := s.writeBlob(ctx, v1.Descriptor{MediaType: media, Digest: digest, Size: size}, r); err != nil { - return v1.Descriptor{}, err - } - } - config, err := img.RawConfigFile() - if err != nil { - return v1.Descriptor{}, err - } - configHash, err := img.ConfigName() - if err != nil { - return v1.Descriptor{}, err - } - if err := s.writeBlob(ctx, v1.Descriptor{MediaType: types.OCIConfigJSON, Digest: configHash, Size: int64(len(config))}, io.NopCloser(bytes.NewReader(config))); err != nil { - return v1.Descriptor{}, err - } - manifest, err := img.RawManifest() - if err != nil { - return v1.Descriptor{}, err - } - digest, err := img.Digest() - if err != nil { - return v1.Descriptor{}, err - } - media, err := img.MediaType() - if err != nil { - return v1.Descriptor{}, err - } - desc := v1.Descriptor{MediaType: media, Digest: digest, Size: int64(len(manifest)), Platform: toV1Platform(platform)} - if err := s.writeBlob(ctx, desc, io.NopCloser(bytes.NewReader(manifest))); err != nil { - return v1.Descriptor{}, err - } - return desc, nil -} - -func toV1Platform(p ocispec.Platform) *v1.Platform { - return &v1.Platform{OS: p.OS, Architecture: p.Architecture, Variant: p.Variant} -} - -func samePlatform(a *v1.Platform, b ocispec.Platform) bool { - return a != nil && a.OS == b.OS && a.Architecture == b.Architecture && a.Variant == b.Variant -} - -func (s *store) pinLocked(ctx context.Context, ref string, platform ocispec.Platform, manifest v1.Descriptor) error { - if err := ctx.Err(); err != nil { - return err - } - index, err := s.rootIndex() - if err != nil { - return err - } - if err := ctx.Err(); err != nil { - return err - } - nested := v1.IndexManifest{SchemaVersion: 2, MediaType: types.OCIImageIndex} - for _, desc := range index.Manifests { - if desc.Annotations[refNameAnnotation] != ref { - continue - } - b, err := s.blobBytes(desc.Digest) - if err != nil { - return err - } - if err := json.Unmarshal(b, &nested); err != nil { - return fmt.Errorf("store: parse index for %s: %w", ref, err) - } - } - kept := nested.Manifests[:0] - for _, desc := range nested.Manifests { - if !samePlatform(desc.Platform, platform) { - kept = append(kept, desc) - } - } - manifest.Platform = toV1Platform(platform) - nested.Manifests = append(kept, manifest) - sort.Slice(nested.Manifests, func(i, j int) bool { - return platformKey(nested.Manifests[i].Platform) < platformKey(nested.Manifests[j].Platform) - }) - nestedBytes, err := json.MarshalIndent(nested, "", " ") - if err != nil { - return err - } - nestedBytes = append(nestedBytes, '\n') - nestedDesc, err := s.writeBlobBytes(ctx, types.OCIImageIndex, nestedBytes) - if err != nil { - return err - } - nestedDesc.Annotations = map[string]string{refNameAnnotation: ref} - root := index.Manifests[:0] - for _, desc := range index.Manifests { - if desc.Annotations[refNameAnnotation] != ref { - root = append(root, desc) - } - } - index.Manifests = append(root, nestedDesc) - sort.Slice(index.Manifests, func(i, j int) bool { - return index.Manifests[i].Annotations[refNameAnnotation] < index.Manifests[j].Annotations[refNameAnnotation] - }) - b, err := json.MarshalIndent(index, "", " ") - if err != nil { - return err - } - b = append(b, '\n') - return replaceFile(ctx, s.root, "index.json", b, 0o600) -} - -func platformKey(platform *v1.Platform) string { - if platform == nil { - return "" - } - return platform.String() -} diff --git a/cmd/oci/store_test.go b/cmd/oci/store_test.go deleted file mode 100644 index 86fa6be60..000000000 --- a/cmd/oci/store_test.go +++ /dev/null @@ -1,332 +0,0 @@ -// Copyright 2026 elfuse contributors -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "os" - "path/filepath" - "sync" - "testing" - - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/types" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" -) - -type cancelReadCloser struct { - cancel context.CancelFunc - done bool -} - -func (r *cancelReadCloser) Read(p []byte) (int, error) { - if r.done { - return 0, io.EOF - } - r.done = true - r.cancel() - p[0] = 'x' - return 1, nil -} - -func (r *cancelReadCloser) Close() error { return nil } - -func TestPinsPerPlatform(t *testing.T) { - s := tempStore(t) - arm := ocispec.Platform{OS: "linux", Architecture: "arm64"} - amd := ocispec.Platform{OS: "linux", Architecture: "amd64"} - armDigest := pushTestImage(t, s, testImage{platform: arm}) - amdDigest := pushTestImage(t, s, testImage{platform: amd}) - pinImage(t, s, "alpine:3", arm, armDigest) - pinImage(t, s, "docker.io/library/alpine:3", amd, amdDigest) - for _, tc := range []struct { - platform ocispec.Platform - want string - }{{arm, armDigest}, {amd, amdDigest}} { - index, err := s.rootIndex() - if err != nil { - t.Fatal(err) - } - b, err := s.blobBytes(index.Manifests[0].Digest) - if err != nil { - t.Fatal(err) - } - var nested v1.IndexManifest - if err := json.Unmarshal(b, &nested); err != nil { - t.Fatal(err) - } - found := "" - for _, desc := range nested.Manifests { - if samePlatform(desc.Platform, tc.platform) { - found = desc.Digest.String() - } - } - if found != tc.want { - t.Fatalf("pin for %s = %q, want %q", platformString(tc.platform), found, tc.want) - } - } -} - -func TestRootIndexNamesNestedIndex(t *testing.T) { - s, digest := storeWithImage(t, "alpine:3", testImage{}) - index, err := s.rootIndex() - if err != nil { - t.Fatal(err) - } - if len(index.Manifests) != 1 { - t.Fatalf("root descriptors = %d", len(index.Manifests)) - } - desc := index.Manifests[0] - if desc.MediaType != types.OCIImageIndex || desc.Annotations[refNameAnnotation] != "index.docker.io/library/alpine:3" { - t.Fatalf("root descriptor = %+v", desc) - } - b, err := s.blobBytes(desc.Digest) - if err != nil { - t.Fatal(err) - } - var nested v1.IndexManifest - if err := json.Unmarshal(b, &nested); err != nil { - t.Fatal(err) - } - if len(nested.Manifests) != 1 || nested.Manifests[0].Digest.String() != digest || !samePlatform(nested.Manifests[0].Platform, defaultPlatform) { - t.Fatalf("nested index = %+v", nested) - } -} - -func TestPinConcurrentWritersKeepAllEntries(t *testing.T) { - s := tempStore(t) - digest := pushTestImage(t, s, testImage{}) - const count = 8 - var wg sync.WaitGroup - errCh := make(chan error, count) - for i := 0; i < count; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - errCh <- pinImageError(s, "img"+string(rune('a'+i))+":1", defaultPlatform, digest) - }(i) - } - wg.Wait() - close(errCh) - for err := range errCh { - if err != nil { - t.Fatal(err) - } - } - index, err := s.rootIndex() - if err != nil { - t.Fatal(err) - } - if len(index.Manifests) != count { - t.Fatalf("root descriptors = %d, want %d", len(index.Manifests), count) - } -} - -func TestBlobLayout(t *testing.T) { - s := tempStore(t) - desc := pushBlob(t, s, types.OCIConfigJSON, []byte("{}")) - fi, err := os.Stat(filepath.Join(s.root, "blobs", "sha256", desc.Digest.Hex)) - if err != nil { - t.Fatal(err) - } - if fi.Mode().Perm()&0o222 != 0 { - t.Fatalf("blob mode %v has write bits", fi.Mode()) - } - path := filepath.Join(s.root, "blobs", "sha256", desc.Digest.Hex) - if err := os.Chmod(path, 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("[]"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := s.writeBlobBytes(context.Background(), types.OCIConfigJSON, []byte("{}")); err == nil { - t.Fatal("an existing blob with the wrong content must fail") - } - for _, name := range []string{markerName, "oci-layout", "index.json"} { - if _, err := os.Stat(filepath.Join(s.root, name)); err != nil { - t.Errorf("%s: %v", name, err) - } - } -} - -func TestWriteBlobStopsOnCancellation(t *testing.T) { - s := tempStore(t) - body := []byte("xx") - hash, size, err := v1.SHA256(bytes.NewReader(body)) - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(context.Background()) - err = s.writeBlob(ctx, v1.Descriptor{MediaType: types.OCIConfigJSON, Digest: hash, Size: size}, &cancelReadCloser{cancel: cancel}) - if !errors.Is(err, context.Canceled) { - t.Fatalf("write error = %v", err) - } - path := filepath.Join(s.root, "blobs", hash.Algorithm, hash.Hex) - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("canceled blob exists: %v", err) - } -} - -func TestPinLockedStopsOnCancellation(t *testing.T) { - s := tempStore(t) - digest := pushTestImage(t, s, testImage{}) - hash, err := v1.NewHash(digest) - if err != nil { - t.Fatal(err) - } - body, err := s.blobBytes(hash) - if err != nil { - t.Fatal(err) - } - indexPath := filepath.Join(s.root, "index.json") - before, err := os.ReadFile(indexPath) - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - desc := v1.Descriptor{MediaType: types.OCIManifestSchema1, Digest: hash, Size: int64(len(body))} - if err := s.pinLocked(ctx, "index.docker.io/library/canceled:1", defaultPlatform, desc); !errors.Is(err, context.Canceled) { - t.Fatalf("pin error = %v", err) - } - after, err := os.ReadFile(indexPath) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(after, before) { - t.Fatal("canceled pin changed index.json") - } -} - -func TestStoreRefusesLegacyAndForeignDirectories(t *testing.T) { - for _, name := range []string{"refs.json", "foreign"} { - root := t.TempDir() - if err := os.WriteFile(filepath.Join(root, name), []byte("{}"), 0o644); err != nil { - t.Fatal(err) - } - s, err := openStore(root) - if err != nil { - t.Fatal(err) - } - if err := s.withLock(context.Background(), func() error { - return s.ensureLayoutLocked(context.Background()) - }); err == nil { - t.Fatalf("directory containing %s must be refused", name) - } - } -} - -func TestStoreAcceptsSymlinkAndRemovesMetadataTemps(t *testing.T) { - parent := t.TempDir() - target := filepath.Join(parent, "target") - if err := os.Mkdir(target, 0o700); err != nil { - t.Fatal(err) - } - for _, name := range []string{"..elfuse-oci-store.tmp.1.1", ".oci-layout.tmp.1.1", ".index.json.tmp.1.1"} { - if err := os.WriteFile(filepath.Join(target, name), []byte("stale"), 0o600); err != nil { - t.Fatal(err) - } - } - link := filepath.Join(parent, "store") - if err := os.Symlink(target, link); err != nil { - t.Fatal(err) - } - s, err := openStore(link) - if err != nil { - t.Fatal(err) - } - if err := s.ensureLayout(context.Background()); err != nil { - t.Fatal(err) - } - for _, name := range []string{"..elfuse-oci-store.tmp.1.1", ".oci-layout.tmp.1.1", ".index.json.tmp.1.1"} { - if _, err := os.Stat(filepath.Join(target, name)); !os.IsNotExist(err) { - t.Fatalf("stale temporary file %s remains: %v", name, err) - } - } -} - -func TestStoreFilesArePrivate(t *testing.T) { - s := tempStore(t) - desc := pushBlob(t, s, types.OCIConfigJSON, []byte("{}")) - paths := map[string]os.FileMode{ - s.root: 0o700, - filepath.Join(s.root, "blobs"): 0o700, - filepath.Join(s.root, "blobs", desc.Digest.Algorithm): 0o700, - filepath.Join(s.root, metadataLockName): 0o600, - filepath.Join(s.root, markerName): 0o600, - filepath.Join(s.root, "oci-layout"): 0o600, - filepath.Join(s.root, "index.json"): 0o600, - filepath.Join(s.root, "blobs", desc.Digest.Algorithm, desc.Digest.Hex): 0o400, - } - for path, want := range paths { - fi, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - if got := fi.Mode().Perm(); got != want { - t.Errorf("%s mode = %o, want %o", path, got, want) - } - } -} - -func TestPinSortAcceptsPlatformlessExistingChild(t *testing.T) { - s := tempStore(t) - digest := pushTestImage(t, s, testImage{}) - hash, err := v1.NewHash(digest) - if err != nil { - t.Fatal(err) - } - nested := v1.IndexManifest{ - SchemaVersion: 2, - MediaType: types.OCIImageIndex, - Manifests: []v1.Descriptor{{MediaType: types.OCIManifestSchema1, Digest: hash}}, - } - nestedDesc, err := s.writeBlobBytes(context.Background(), types.OCIImageIndex, mustJSON(t, nested)) - if err != nil { - t.Fatal(err) - } - nestedDesc.Annotations = map[string]string{refNameAnnotation: "index.docker.io/library/nil:1"} - root := v1.IndexManifest{SchemaVersion: 2, MediaType: types.OCIImageIndex, Manifests: []v1.Descriptor{nestedDesc}} - if err := replaceFile(context.Background(), s.root, "index.json", append(mustJSON(t, root), '\n'), 0o600); err != nil { - t.Fatal(err) - } - pinImage(t, s, "nil:1", defaultPlatform, digest) -} - -func TestWithLockEndsWithContext(t *testing.T) { - s := tempStore(t) - if filepath.Dir(s.lockPath()) != s.root { - t.Fatalf("lock path %s is outside store", s.lockPath()) - } - held, err := acquireFlock(context.Background(), s.lockPath()) - if err != nil { - t.Fatal(err) - } - defer held.Close() - ctx, cancel := context.WithTimeout(context.Background(), 3*flockPoll) - defer cancel() - err = s.withLock(ctx, func() error { return nil }) - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("error = %v", err) - } - held.Close() - if err := s.withLock(context.Background(), func() error { return nil }); err != nil { - t.Fatal(err) - } -} - -func TestWithLockRefusesExpiredContext(t *testing.T) { - s := tempStore(t) - ctx, cancel := context.WithCancel(context.Background()) - cancel() - ran := false - err := s.withLock(ctx, func() error { ran = true; return nil }) - if !errors.Is(err, context.Canceled) || ran { - t.Fatalf("error = %v, ran = %v", err, ran) - } -} diff --git a/docs/oci-images.md b/docs/oci-images.md deleted file mode 100644 index 5a59fbb8e..000000000 --- a/docs/oci-images.md +++ /dev/null @@ -1,82 +0,0 @@ -# OCI Images - -An OCI image packages a filesystem as ordered, content-addressed layers plus a -JSON configuration. A manifest names the configuration and layers, and an -image index can select among manifests for different operating systems and -architectures. An OCI image layout stores those objects in a directory without -requiring a registry or a container daemon. This is separate from the OCI -runtime specification, which describes how a container is started. - -`elfuse-oci` is a separate Go command that pulls images into such a local -layout. At this point in the stack it does not unpack or run them, and it does -not add namespaces, cgroups, or other container isolation. Command syntax is in -[usage.md](usage.md#oci-images). - -## Store - -The store is an [OCI image layout](https://github.com/opencontainers/image-spec/blob/main/image-layout.md): - -```text -/ - .lock - .elfuse-oci-store - oci-layout - index.json - blobs// -``` - -The marker records the elfuse store format. `oci-layout`, `index.json`, and -`blobs/` follow the OCI image-layout specification. `.lock` is internal store -metadata and serializes layout initialization and pin-table updates. Registry -transfers and content-addressed blob writes happen outside that critical -section, so an unrelated pull does not wait for another image's download. - -`index.json` contains one descriptor for each canonical image reference. Its -`org.opencontainers.image.ref.name` annotation holds the reference. That -descriptor names another OCI index whose children are the manifests pulled -for each platform. Pulling `alpine:3` and -`docker.io/library/alpine:3` updates the same entry. - -Blobs are written and synced before either index names them. On a fresh store, -the blob directory hierarchy is also synced up to the store root before the -pin table is replaced. `index.json` is replaced atomically. Existing blobs are -streamed through their digest before reuse instead of being loaded into -memory. Stale temporary metadata files left by an interrupted replacement are -removed while `.lock` is held. - -The store and blob directories use mode 0700. Metadata and the lock use 0600, -and immutable blobs use 0400, so another local user cannot read a private -image through default permissions. - -The store is a cache. A directory containing `refs.json` has an incompatible -format and is rejected; remove it and pull the image again. - -A failed pull can leave unreferenced blobs, and pulling a moved tag can leave -the old blobs unreferenced. This version has no pruning command. To reclaim -that space, stop all pulls, remove the entire store directory, and pull the -needed images again. Do not remove individual blobs because an OCI index may -still reference them. - -## Pull - -`go-containerregistry` parses references, reads Docker or Podman credentials, -selects a platform, and fetches the image. A bare manifest is checked against -the platform in its config before publication. - -The supported targets are `linux/arm64` and `linux/amd64`. The aliases -`linux/aarch64`, `linux/arm64/v8`, and `linux/x86_64` resolve to those two -keys. The default is `linux/arm64`. Selection compares the normalized OS, -architecture, and variant exactly; for example, an `amd64/v3` image is not -pinned as baseline `amd64`. - -The pull timeout covers the registry request, store publication, and the wait -for another writer. The default value, zero, does not set a deadline. - -## Validation - -The offline tests create manifests and layers in temporary stores. They cover -reference normalization, exact platform selection, index structure, blob -validation, credential-helper resolution, concurrent pulls, stale temporary -files, private permissions, legacy-store refusal, lock cancellation, CLI -parsing, and the race detector. Set `ELFUSE_OCI_NETTEST=1` to add a Docker Hub -round trip. diff --git a/docs/testing.md b/docs/testing.md index f0285c354..9d4188cdb 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -671,7 +671,6 @@ Suggested minimum validation: | Change area | Recommended validation | |-------------|------------------------| -| `cmd/oci/` | `make oci-lint && make oci-test` | | CLI, logging, docs-only build rules | `make elfuse` | | Filename codec, case-exact walk, sysroot resolvers | `make check` (runs the codec unit tests, name lanes, and byte-exact oracle lane), plus `make test-sysroot-name-soak` for resolver concurrency. A red golden vector in `test-casefold-host` means the on-disk format moved: see `docs/filenames.md` before touching `tests/casefold-vectors.h` | | General syscall or runtime logic | `make elfuse && make check && make test-matrix-elfuse-aarch64` | @@ -680,16 +679,3 @@ Suggested minimum validation: | Broad behavioral changes | `make elfuse && make check && make test-matrix` | | Debugger or ptrace flow | `make elfuse && make test-gdbstub` | | ACSL contracts, `src/proved/`, `mk/verify.mk`, the mutation harness | `make verify && make verify-mutants`, in that order and never beside a runtime lane: both fan out, and the timing lanes fail under the load they create. Add `make check` only when the change touches code rather than annotations | - -## OCI Image CLI - -The Go CLI has separate format, vet, and race-test targets: - -```sh -make oci-lint -make oci-test -ELFUSE_OCI_NETTEST=1 make oci-test -``` - -The default suite constructs image data in temporary stores and does not use a -registry. `ELFUSE_OCI_NETTEST=1` adds a pull from Docker Hub. diff --git a/docs/usage.md b/docs/usage.md index ce27632e4..13b387251 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -62,9 +62,7 @@ something other than what was asked for. `--user UID[:GID]` sets the identity the guest reports through `getuid` and `getgid`. It does not change the host process credentials: elfuse translates the guest's syscalls, so the number the guest sees is elfuse's to choose. The spec is -numeric, and a bare `UID` sets the group to the same value. Symbolic names are -resolved against the image `/etc/passwd` and `/etc/group` one layer up, by -`elfuse-oci`. +numeric, and a bare `UID` sets the group to the same value. `--fakeroot` cannot be combined with a non-root `--user`. Fakeroot starts the guest as uid/gid 0, and the setuid permission check grants every id switch on that basis, @@ -355,56 +353,3 @@ Off by default, and useful when a guest misbehaves rather than in normal use: - `ELFUSE_DISABLE_TLBI_RANGE=1`: refuse `FEAT_TLBIRANGE` and fall back to per-page and broadcast invalidation, which separates a stale-TLB bug from the range-encoding path. - -## OCI Images - -`build/elfuse-oci` is separate from the C runtime. It pulls images into a local -OCI image layout and does not unpack them. - -### Build - -```sh -make elfuse-oci -``` - -The Go command is opt-in. Plain `make`, `make check`, and `make lint` do not -probe the Go module graph or download Go dependencies. Use `make oci-test` and -`make oci-lint` explicitly for its test and lint lanes. The C runtime build -does not require Go. - -### Quick Start - -```sh -build/elfuse-oci pull debian:stable-slim -``` - -### Commands - -| Command | Meaning | -|---------|---------| -| `pull ` | Fetch one platform of an image into the store | -| `help`, `version` | Print help or the elfuse-oci version | - -An abbreviated reference receives the Docker Hub registry, the `library` -repository when needed, and the `latest` tag when no tag is present. Digest -references are accepted. Pull options may appear before or after ``. - -### Flags - -| Option | Commands | Meaning | -|--------|----------|---------| -| `--store DIR` | `pull` | Store directory; default `$ELFUSE_OCI_STORE`, then `~/.local/share/elfuse/oci` | -| `--platform OS/ARCH[/VARIANT]` | `pull` | Target `linux/arm64` or `linux/amd64`; default `linux/arm64` | -| `--timeout DURATION` | `pull` | Bound the pull and lock wait; zero sets no deadline | - -### Environment - -| Variable | Meaning | -|----------|---------| -| `ELFUSE_OCI_STORE` | Default store directory | -| `DOCKER_CONFIG` | Alternate directory containing Docker `config.json` credentials | -| `REGISTRY_AUTH_FILE` | Podman-compatible credential file used when Docker config is absent | -| `XDG_RUNTIME_DIR` | Base directory for Podman's `containers/auth.json` fallback | - -Credential helpers and inline entries are handled by go-containerregistry. -With no matching entry, the pull is anonymous. diff --git a/go.mod b/go.mod deleted file mode 100644 index 8f24b08ad..000000000 --- a/go.mod +++ /dev/null @@ -1,20 +0,0 @@ -module github.com/sysprog21/elfuse - -go 1.25.0 - -require ( - github.com/alecthomas/kong v1.16.1 - github.com/google/go-containerregistry v0.21.7 - github.com/opencontainers/image-spec v1.1.1 -) - -require ( - github.com/docker/cli v29.5.3+incompatible // indirect - github.com/docker/docker-credential-helpers v0.9.3 // indirect - github.com/klauspost/compress v1.18.6 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/sirupsen/logrus v1.9.4 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect - gotest.tools/v3 v3.5.2 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 1c61142e3..000000000 --- a/go.sum +++ /dev/null @@ -1,38 +0,0 @@ -github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= -github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/kong v1.16.1 h1:ixhCt93XkJ98kGposQ54+bl0IK6XwqB40AsMynU7Z8E= -github.com/alecthomas/kong v1.16.1/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= -github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= -github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs= -github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= -github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= -github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= -github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= -github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= -github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= -gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/mk/oci.mk b/mk/oci.mk deleted file mode 100644 index 58a905f7e..000000000 --- a/mk/oci.mk +++ /dev/null @@ -1,41 +0,0 @@ -GO ?= go - -OCI_BIN := $(BUILD_DIR)/elfuse-oci -OCI_SRCS := $(shell find cmd/oci -type f -name '*.go' ! -name '*_test.go' 2>/dev/null) -OCI_VERSION_FILE := $(BUILD_DIR)/.elfuse-oci-version - -.PHONY: elfuse-oci oci-test oci-vet oci-fmt-check oci-lint oci-version-force - -elfuse-oci: $(OCI_BIN) - -# Rebuild only when command-line VERSION changes. -oci-version-force: - -$(OCI_VERSION_FILE): oci-version-force | $(BUILD_DIR) - $(Q)tmp="$@.$$$$.tmp"; \ - printf '%s\n' "$(VERSION)" > "$$tmp"; \ - cmp -s "$$tmp" "$@" 2>/dev/null || mv "$$tmp" "$@"; \ - rm -f "$$tmp" - -# The directory prerequisite catches an incomplete OCI_SRCS list. -$(OCI_BIN): go.mod go.sum cmd/oci $(OCI_SRCS) $(VERSION_DEPS) \ - $(OCI_VERSION_FILE) | $(BUILD_DIR) - @echo " GO $@" - $(Q)rm -f $@ - $(Q)$(GO) build -ldflags "-X main.version=$(VERSION)" -o $@ ./cmd/oci - -## Run elfuse-oci tests; set ELFUSE_OCI_NETTEST=1 for registry round-trip -oci-test: - $(Q)$(GO) test -race ./cmd/oci/ - -oci-vet: - $(Q)GOOS=darwin GOARCH=arm64 $(GO) vet ./cmd/oci/ - $(Q)GOOS=linux $(GO) vet ./cmd/oci/ - -# Use the selected Go toolchain's gofmt. -oci-fmt-check: - $(Q)set -e; out=$$("$$($(GO) env GOROOT)/bin/gofmt" -l cmd/oci); if [ -n "$$out" ]; then \ - echo "gofmt needed on:"; echo "$$out"; exit 1; fi - -## Check elfuse-oci formatting and vet Darwin and Linux builds -oci-lint: oci-fmt-check oci-vet diff --git a/scripts/check-skill-refs.py b/scripts/check-skill-refs.py index f499c17be..3906850ed 100755 --- a/scripts/check-skill-refs.py +++ b/scripts/check-skill-refs.py @@ -106,7 +106,7 @@ ) # An inline span holding a slash is a path, so the cross-reference pass does -# not read cmd/elfuse-container as a missing skill. A bare span stays visible. +# not read src/elfuse-limits.h as a missing skill. A bare span stays visible. INLINE_PATH_RE = re.compile(r"`[^`]*/[^`]*`") DOCPATH_RE = re.compile(r"docs/[a-z0-9-]+\.md") @@ -500,7 +500,7 @@ def check_file(path, paths, targets, skills, errors): ), ( "repo path that looks like a skill name", - "The Go `cmd/elfuse-container` CLI.", + "The `src/elfuse-limits.h` header.", None, ), ("valid include-style path", "Include it as `core/guest.h`.", None), diff --git a/scripts/proof-scope.py b/scripts/proof-scope.py index be67c36f9..d7add4328 100755 --- a/scripts/proof-scope.py +++ b/scripts/proof-scope.py @@ -352,7 +352,6 @@ def inert_name(name): "mk/lint.mk", "mk/format.mk", "mk/help.mk", - "mk/oci.mk", } # The stub headers reach every proof and no include closure can see them: they diff --git a/src/core/launch.c b/src/core/launch.c index bbe068b7c..41fb93a7a 100644 --- a/src/core/launch.c +++ b/src/core/launch.c @@ -112,8 +112,7 @@ int elfuse_launch(const launch_args_t *args) * stub exposes the aarch64 shim's register/memory view, which is the wrong * architecture for a Rosetta-translated x86_64 guest. main() rejects it up * front via a static ELF probe, but enforcing it in elfuse_launch (once - * bring-up has set g.is_rosetta) makes every caller inherit the constraint, - * including the planned OCI run helper. + * bring-up has set g.is_rosetta) makes every caller inherit the constraint. */ if (args->gdb_port > 0 && g.is_rosetta) { log_error(LAUNCH_GDB_X86_64_MSG); diff --git a/src/main.c b/src/main.c index 1f82b0c89..085be995d 100644 --- a/src/main.c +++ b/src/main.c @@ -327,8 +327,7 @@ int main(int argc, char **argv) " --gdb-stop-on-entry Halt before the first guest " "instruction\n" " --user UID[:GID] Run the guest as UID (and GID; " - "defaults to UID). Numeric; elfuse-oci resolves symbolic " - "names\n" + "defaults to UID). Numeric only\n" " --workdir DIR Guest-absolute initial working " "directory (resolved under --sysroot)\n" " --env KEY=VALUE Set a guest environment variable; " diff --git a/src/syscall/chown-overlay.c b/src/syscall/chown-overlay.c index afa61220b..aa37be79e 100644 --- a/src/syscall/chown-overlay.c +++ b/src/syscall/chown-overlay.c @@ -205,10 +205,9 @@ int chown_overlay_send(int ipc_sock) size_t n = atomic_load_explicit(&entry_count, memory_order_acquire); - /* The cap is well above any realistic chown-heavy workload (dpkg, OCI layer - * commit). Hitting it indicates a runaway table or a bug, not a legitimate - * parent; refuse to truncate-on-success and fail the fork so the caller - * sees the problem. + /* The cap is well above any realistic chown-heavy workload (dpkg). Hitting + * it indicates a runaway table or a bug, not a legitimate parent; refuse to + * truncate-on-success and fail the fork so the caller sees the problem. */ if (n > CHOWN_OVERLAY_MAX_ENTRIES) { pthread_rwlock_unlock(&overlay_lock); diff --git a/tests/test-launch-flags.sh b/tests/test-launch-flags.sh index 69d4d1ed8..7fd370e4f 100755 --- a/tests/test-launch-flags.sh +++ b/tests/test-launch-flags.sh @@ -151,8 +151,8 @@ if [ -z "$ENV_DUMP" ] || [ ! -f "$ENV_DUMP" ]; then report_skip "environment lanes (no env-dump guest binary)" else - # The `elfuse-oci run` spelling, and the only one that makes the guest's - # environment a function of the flags alone. + # Only --clear-env makes the guest's environment a function of the flags + # alone. env_exact "--clear-env alone yields an empty environment" "" --clear-env # One vector through every branch of the merge at once. @@ -226,8 +226,8 @@ else want index $base_index)" fi - # "--" ends elfuse's own parsing, so an image entrypoint beginning with a - # flag reaches the guest as argv instead of steering the launcher. + # "--" ends elfuse's own parsing, so a guest command beginning with a flag + # reaches the guest as argv instead of steering the launcher. status=0 after_dashdash="$("$ELFUSE" --clear-env -- "$ENV_DUMP" --env A=1 \ 2> "$scratch/guest-env-stderr")" || status=$? diff --git a/tests/test-sysroot-tmp-remove.c b/tests/test-sysroot-tmp-remove.c index e4263f89d..9c7118c31 100644 --- a/tests/test-sysroot-tmp-remove.c +++ b/tests/test-sysroot-tmp-remove.c @@ -172,8 +172,8 @@ int main(int argc, char **argv) /* The other half of the contract: a guest's own /tmp file, which the create * resolver places in the sysroot, must remain removable. This is the common - * case any container workload depends on; a fix that made the host-visible - * entry invisible must not have broken it. + * case any workload depends on; a fix that made the host-visible entry + * invisible must not have broken it. */ TEST("a guest-created /tmp file round-trips"); snprintf(p, sizeof(p), "%s/made", dir); From cf1d55d7acf11265dd57fd7edf7c172b68d892e2 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Tue, 6 Oct 2026 13:25:39 +0200 Subject: [PATCH 2/4] Add elfuse-oci based on crane and umoci scripts/elfuse-oci.py pulls, runs, lists, and cleans linux/arm64 OCI images under elfuse. crane downloads an image and umoci unpacks it into a case-sensitive APFS volume, so names that differ only in case do not collide, and the volume is sealed read-only once per manifest digest. Each run attaches it with its own shadow file, which takes the run's writes. Its own errors and any OSError print as one line. A watcher process detaches a run's volume once elfuse exits. run execs elfuse only after the watcher waits on its pid, and fails if the watcher could not start. run requires the command to be a file in the image with an execute bit, a name with a slash included, because elfuse falls back to the host for a path the image lacks, and refuses an image whose Entrypoint, Cmd, or Env is not a list of strings. clean refuses an image that hdiutil lists without its device, as it does while attaching or detaching one. A pull or run whose volume is still attached after a forced detach leaves its directory for clean: removing it would delete the files on the volume and the file backing it. A pull gives the owner full access to a directory only while it adjusts the tree, then restores the image's mode. scripts/test-elfuse-oci.py holds table tests for the parts that need no image. --- scripts/elfuse-oci.py | 629 +++++++++++++++++++++++++++++++++++++ scripts/test-elfuse-oci.py | 206 ++++++++++++ 2 files changed, 835 insertions(+) create mode 100755 scripts/elfuse-oci.py create mode 100755 scripts/test-elfuse-oci.py diff --git a/scripts/elfuse-oci.py b/scripts/elfuse-oci.py new file mode 100755 index 000000000..c8e33469f --- /dev/null +++ b/scripts/elfuse-oci.py @@ -0,0 +1,629 @@ +#!/usr/bin/env python3 +"""Pull, run, list, and clean OCI images under elfuse. + +Each image is unpacked once into its own case-sensitive APFS volume, sealed +read-only, and attached per run with a shadow file that takes the run's +writes. crane downloads the image, umoci unpacks it, and hdiutil creates, +seals, and attaches the volumes. + +The store is $ELFUSE_OCI_STORE (default ~/.local/share/elfuse/oci). It holds +the sealed images under images/, one record per pulled reference under refs/, +each run's mount point and shadow file under run//, and each pull's work +under tmp/. +""" + +import contextlib +import fcntl +import glob +import hashlib +import json +import os +import plistlib +import select +import shutil +import signal +import stat +import subprocess +import sys +import time + +PLATFORM = "linux/arm64" +# A shadow mount writes about 23 MiB of APFS metadata at this capacity. +VOLUME_SIZE = "64g" +DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +STORE_MARKER = ".elfuse-oci-store" +WATCHER_DETACH_SECONDS = 10 +MAXSYMLINKS = 40 +ATTACH = ["hdiutil", "attach", "-quiet", "-nobrowse", "-noverify"] +USAGE = """\ +usage: elfuse-oci.py pull REF + elfuse-oci.py run [--entrypoint CMD] REF [ARG...] + elfuse-oci.py clean + elfuse-oci.py list +The store is $ELFUSE_OCI_STORE (default ~/.local/share/elfuse/oci).""" + + +class Error(Exception): + """A failure main reports as one line, without a traceback.""" + + +def tool(*args, **kwargs): + """Run a command, raising Error if it is missing or exits nonzero.""" + try: + return subprocess.run(args, check=True, **kwargs) + except FileNotFoundError: + raise Error(f"{args[0]} not found (brew install crane umoci)") from None + except subprocess.CalledProcessError as e: + raise Error(f"{args[0]} {args[1]} exited {e.returncode}") from None + + +def output(*args): + """Run a command and return its stdout as bytes.""" + return tool(*args, stdout=subprocess.PIPE).stdout + + +def detach(mnt): + """Detach a volume only this process uses, by force if it must.""" + if not os.path.ismount(mnt): + return + for force in ([], ["-force"]): + if subprocess.run(["hdiutil", "detach", "-quiet", *force, mnt]).returncode == 0: + return + + +def relative_target(link_relpath, target): + """Return an absolute symlink target rewritten relative to the link. + + link_relpath is the link's path inside the rootfs. Anything following an + absolute link natively resolves it from the host root, so the target is + stored relative to the link, as elfuse's symlinkat does (docs/filenames.md, + "Symlink targets"). Like elfuse, .. is normalized away first and clamped at + the root, so the result cannot climb out of it. + """ + rel = os.path.normpath(target).lstrip("/") + return "../" * link_relpath.count("/") + rel or "." + + +def resolve_in_root(root, guest_path, follow_last=True): + """Return the host path of guest_path inside root. + + Links are followed as the guest would follow them: an absolute target + restarts at root, and .. stops there. More than MAXSYMLINKS links raise + Error. + """ + parts = [p for p in guest_path.split("/") if p not in ("", ".")] + resolved = [] + links = MAXSYMLINKS + while parts: + name = parts.pop(0) + if name == "..": + if resolved: + resolved.pop() + continue + host = os.path.join(root, *resolved, name) + if (parts or follow_last) and os.path.islink(host): + links -= 1 + if links < 0: + raise Error(f"too many links in {guest_path}") + target = os.readlink(host) + if target.startswith("/"): + resolved = [] + parts = [p for p in target.split("/") if p not in ("", ".")] + parts + continue + resolved.append(name) + return os.path.join(root, *resolved) + + +def config_strings(config, key): + """Return config[key], or [] when unset. + + Raise Error unless it is a list of strings, the type the OCI image spec + gives Entrypoint, Cmd, and Env. + """ + value = config.get(key) or [] + if not isinstance(value, list) or not all(isinstance(v, str) for v in value): + raise Error(f"run: the image's {key} is not a list of strings") + return value + + +def launch_argv(entrypoint, cmd, override, args): + """Return the guest argv from the image's Entrypoint and Cmd. + + Arguments given to run replace Cmd. Docker's rule: an --entrypoint + override, even an empty one, replaces Entrypoint and drops Cmd. + """ + if override is None: + return entrypoint + (args or cmd) + return ([override] if override else []) + args + + +def merge_env(image_env): + """Return the image's Env as a dict, defaulting PATH and HOME. + + An entry without = or with an empty name is dropped. PATH falls back to + DEFAULT_PATH when unset or empty, and HOME to /root when unset. + """ + env = dict(entry.split("=", 1) for entry in image_env if entry.find("=") > 0) + if not env.get("PATH"): + env["PATH"] = DEFAULT_PATH + env.setdefault("HOME", "/root") + return env + + +def find_command(cmd, path, workdir, is_exec): + """Return cmd as the absolute guest path elfuse takes for argv[0]. + + elfuse resolves argv[0] before changing to the working directory, so a + name with a slash is taken relative to workdir, and a bare name is looked + up in path, the guest PATH, where an empty or relative entry is relative + to workdir too. is_exec tests a candidate guest path. Raise Error when no + candidate passes, since elfuse falls back to the host for a path the image + lacks. + """ + if "/" in cmd: + dirs, missing = [""], "is not an executable file in the image" + else: + dirs, missing = path.split(":"), f"not found in PATH {path}" + for d in dirs: + candidate = os.path.join(workdir, d, cmd) + if is_exec(candidate): + return candidate + raise Error(f"run: {cmd} {missing}") + + +def is_guest_exec(rootfs, guest_path): + """Return whether guest_path is a file in rootfs a root guest may execute. + + Any execute bit is enough, as in elfuse's execve check for root + (check_exec_permission in src/syscall/exec.c). + """ + host = resolve_in_root(rootfs, guest_path) + return os.path.isfile(host) and bool(os.stat(host).st_mode & 0o111) + + +def parse_run_args(argv): + """Return (override, ref, guest_args) from run's arguments. + + override is the --entrypoint value, or None without the option. + """ + override = None + if argv[:1] == ["--entrypoint"]: + if len(argv) == 1: + raise Error("run: --entrypoint needs a value") + override, argv = argv[1], argv[2:] + if not argv: + raise Error("run: missing image reference") + if argv[0].startswith("-"): + raise Error(f"run: unknown option {argv[0]}") + ref, *guest_args = argv + return override, ref, guest_args + + +def store_devices(hdiutil_info, store): + """Return the devices of the images this store attached. + + hdiutil_info is parsed hdiutil info -plist output. The store attaches its + sealed images from images/ and its build volumes from tmp/. hdiutil lists + an image without its device while it is attaching or detaching it, so + such an image raises Error rather than passing for detached. + """ + prefixes = (os.path.join(store, "images", ""), os.path.join(store, "tmp", "")) + devices = [] + for image in hdiutil_info.get("images", []): + image_path = image.get("image-path", "") + if image_path.startswith(prefixes): + dev = (image.get("system-entities") or [{}])[0].get("dev-entry") + if dev is None: + raise Error(f"{image_path} is still attaching or detaching") + devices.append(dev) + return devices + + +def read_hdiutil_info(): + """Return the parsed output of hdiutil info -plist.""" + return plistlib.loads(output("hdiutil", "info", "-plist")) + + +def store_root(): + """Return $ELFUSE_OCI_STORE, or the default store path, unresolved.""" + default = os.path.expanduser("~/.local/share/elfuse/oci") + return os.environ.get("ELFUSE_OCI_STORE") or default + + +def open_store(): + """Return the store's resolved path, making the store if needed. + + The path is resolved so it matches the image paths hdiutil reports. A + directory becomes a store only while it is empty, and clean refuses a + non-empty one without the marker, so a mistyped ELFUSE_OCI_STORE cannot + point clean at unrelated files. The store is private to its owner, since + an image may come from a registry only its owner can read. + """ + os.makedirs(store_root(), exist_ok=True) + store = os.path.realpath(store_root()) + marker = os.path.join(store, STORE_MARKER) + if not os.path.isfile(marker): + if set(os.listdir(store)) - {STORE_MARKER}: + raise Error(f"{store} is not empty and not an elfuse-oci store") + os.chmod(store, 0o700) + open(marker, "w").close() + return store + + +def share_store(store): + """Take the store's lock shared and return its descriptor. + + The lock is held for the life of this process and its forks; clean takes + it exclusively. + """ + lock = os.open(os.path.join(store, STORE_MARKER), os.O_RDONLY) + fcntl.flock(lock, fcntl.LOCK_SH) + return lock + + +def fresh_dir(path, command): + """Create path under a per-pid directory that must not exist yet. + + One that exists was left by a killed command whose pid this process + reuses, and only clean removes it. + """ + pid_dir = os.path.dirname(path) + if os.path.lexists(pid_dir): + raise Error(f"{pid_dir} is left from a killed {command}; clean removes it") + os.makedirs(path) + + +def image_path(store, digest): + """Return the path of the sealed image for a manifest digest.""" + return os.path.join(store, "images", digest.split(":", 1)[1] + ".dmg") + + +def ref_path(store, ref): + """Return the path of ref's record, named by the SHA-256 of ref.""" + return os.path.join(store, "refs", hashlib.sha256(ref.encode()).hexdigest()) + + +def blob(layout, digest): + """Return the path of a blob in an OCI image layout.""" + return os.path.join(layout, "blobs", *digest.split(":", 1)) + + +def load_json(path): + """Return the parsed contents of a JSON file.""" + with open(path) as f: + return json.load(f) + + +def fixup_rootfs(root): + """Bring a umoci tree to what a Linux unpack shows the guest. + + Absolute symlink targets are rewritten relative to the link. /etc/hosts + and /etc/resolv.conf are removed, since Docker mounts its own over them, + and without them elfuse falls back to the host's. A directory whose owner + lacks read, write, or search gets those bits only while this runs. + """ + original_modes = {} + dirs = [root] + while dirs: + path = dirs.pop() + mode = stat.S_IMODE(os.lstat(path).st_mode) + if mode & stat.S_IRWXU != stat.S_IRWXU: + original_modes[path] = mode + os.chmod(path, mode | stat.S_IRWXU) + for entry in list(os.scandir(path)): + if entry.is_dir(follow_symlinks=False): + dirs.append(entry.path) + elif entry.is_symlink(): + target = os.readlink(entry.path) + if target.startswith("/"): + os.unlink(entry.path) + link_relpath = os.path.relpath(entry.path, root) + os.symlink(relative_target(link_relpath, target), entry.path) + for name in ("hosts", "resolv.conf"): + path = resolve_in_root(root, f"/etc/{name}", follow_last=False) + if os.path.lexists(path) and not stat.S_ISDIR(os.lstat(path).st_mode): + os.unlink(path) + # Children first: a parent restored first can deny the search their + # chmod needs. + for path, mode in reversed(original_modes.items()): + os.chmod(path, mode) + + +def build(store, ref, digest): + """Unpack ref at digest into a fresh volume and publish it sealed.""" + tmp = os.path.join(store, "tmp", str(os.getpid())) + mnt = os.path.join(tmp, "mnt") + layout = os.path.join(tmp, "layout") + volume = os.path.join(tmp, "vol.sparseimage") + sealed = os.path.join(tmp, "sealed.dmg") + fresh_dir(mnt, "pull") + os.makedirs(os.path.join(store, "images"), exist_ok=True) + try: + print(f"Downloading {ref} -> {digest}", file=sys.stderr) + # A fresh layout per pull keeps index.json to the one entry umoci + # resolves; crane appends to an existing one. + flags = ["--platform", PLATFORM, "--format", "oci", "--annotate-ref"] + source = f"{ref.split('@', 1)[0]}@{digest}" + tool("crane", "pull", *flags, source, layout, stdout=sys.stderr) + desc = load_json(os.path.join(layout, "index.json"))["manifests"][0] + name = desc["annotations"]["org.opencontainers.image.ref.name"] + config = load_json(blob(layout, desc["digest"]))["config"]["digest"] + + print(f"Unpacking {ref} -> {digest}", file=sys.stderr) + fs = ["-fs", "Case-sensitive APFS", "-type", "SPARSE", "-size", VOLUME_SIZE] + tool("hdiutil", "create", "-quiet", *fs, "-volname", "elfuse-oci", volume) + tool(*ATTACH, "-mountpoint", mnt, volume) + os.mkdir(os.path.join(mnt, ".fseventsd")) + for marker in (".metadata_never_index", ".fseventsd/no_log"): + open(os.path.join(mnt, marker), "w").close() + shutil.copyfile(blob(layout, config), os.path.join(mnt, "config.json")) + + rootfs = os.path.join(mnt, "rootfs") + image = f"{layout}:{name}" + try: + unpack = subprocess.run( + ["umoci", "raw", "unpack", "--rootless", "--image", image, rootfs], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + except FileNotFoundError: + raise Error("umoci not found (brew install crane umoci)") from None + if unpack.returncode: + sys.stderr.write(unpack.stdout) + raise Error(f"unpack {ref} failed") + fixup_rootfs(rootfs) + print(f"Sealing {ref} -> {digest}", file=sys.stderr) + detach(mnt) + + tool("hdiutil", "convert", "-quiet", "-format", "ULFO", "-o", sealed, volume) + os.chmod(sealed, 0o400) + # link(2) never replaces: a concurrent build that published the same + # digest first wins, and this copy is dropped with tmp. + with contextlib.suppress(FileExistsError): + os.link(sealed, image_path(store, digest)) + finally: + detach(mnt) + if not os.path.ismount(mnt): + shutil.rmtree(tmp, ignore_errors=True) + + +def pull(store, ref): + """Resolve ref, build its image unless already sealed, and record it. + + Return the manifest digest ref resolved to. + """ + print(f"Resolving {ref}", file=sys.stderr) + digest = output("crane", "digest", "--platform", PLATFORM, ref).decode().strip() + if not os.path.exists(image_path(store, digest)): + build(store, ref, digest) + path = ref_path(store, ref) + tmp = os.path.join(store, "tmp", f"{os.getpid()}.ref") + os.makedirs(os.path.dirname(path), exist_ok=True) + os.makedirs(os.path.dirname(tmp), exist_ok=True) + with open(tmp, "w") as f: + json.dump({"ref": ref, "digest": digest}, f) + os.rename(tmp, path) + return digest + + +def start_watcher(pid, mnt, run_dir, lock): + """Detach the run's volume once pid, about to become elfuse, exits. + + The watcher is reparented to launchd so a guest wait() inside elfuse never + sees it, waits on NOTE_EXIT so a SIGKILLed elfuse is cleaned up too, and + holds no inherited descriptor but the store lock, so a caller reading the + run's output is not kept waiting on it. Return only once the watcher waits + on pid, and raise Error if it failed before that, so no run starts without + one. + """ + ready_r, ready_w = os.pipe() + child = os.fork() + if child: + os.close(ready_w) + os.waitpid(child, 0) + ready = os.read(ready_r, 1) + os.close(ready_r) + if not ready: + raise Error("run: the volume watcher did not start") + return + try: + if os.fork(): + os._exit(0) + os.setsid() + os.chdir("/") + for sig in (signal.SIGHUP, signal.SIGINT, signal.SIGTERM): + signal.signal(sig, signal.SIG_IGN) + null = os.open(os.devnull, os.O_RDWR) + for fd in range(3): + os.dup2(null, fd) + for fd in map(int, os.listdir("/dev/fd")): + if fd > 2 and fd not in (lock, ready_w): + with contextlib.suppress(OSError): + os.close(fd) + exit_event = select.kevent( + pid, + filter=select.KQ_FILTER_PROC, + flags=select.KQ_EV_ADD | select.KQ_EV_ONESHOT, + fflags=select.KQ_NOTE_EXIT, + ) + kq = select.kqueue() + # A pid already gone returns an EV_ERROR event, and a run that died + # fails the write; both fall through to the detach. + gone = kq.control([exit_event], 1, 0) + with contextlib.suppress(OSError): + os.write(ready_w, b"x") + os.close(ready_w) + if not gone: + kq.control(None, 1, None) + # A guest process that outlived elfuse keeps the volume busy, so it + # stays attached, and the store locked, until that process exits. + detach_cmd = ["hdiutil", "detach", "-quiet", mnt] + while os.path.ismount(mnt) and subprocess.run(detach_cmd).returncode: + time.sleep(1) + shutil.rmtree(run_dir, ignore_errors=True) + finally: + os._exit(0) + + +def find_elfuse(): + """Return this checkout's build/elfuse if executable, else elfuse on PATH.""" + root = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + built = os.path.join(root, "build", "elfuse") + found = built if os.access(built, os.X_OK) else shutil.which("elfuse") + if not found: + raise Error("elfuse not found; run make elfuse") + return found + + +def cmd_run(argv): + """Run an image's command under elfuse, pulling the image if needed. + + argv holds run's arguments. On success this process execs elfuse and + does not return. + """ + override, ref, args = parse_run_args(argv) + elfuse = find_elfuse() + store = open_store() + lock = share_store(store) + digest = "" + with contextlib.suppress(FileNotFoundError): + digest = load_json(ref_path(store, ref))["digest"] + if not digest or not os.path.exists(image_path(store, digest)): + digest = pull(store, ref) + + run_dir = os.path.join(store, "run", str(os.getpid())) + root = os.path.join(run_dir, "root") + rootfs = os.path.join(root, "rootfs") + fresh_dir(root, "run") + try: + shadow = ["-noautofsck", "-shadow", os.path.join(run_dir, "shadow")] + tool(*ATTACH, *shadow, "-mountpoint", root, image_path(store, digest)) + config = load_json(os.path.join(root, "config.json")).get("config") or {} + entrypoint = config_strings(config, "Entrypoint") + cmd = config_strings(config, "Cmd") + guest_argv = launch_argv(entrypoint, cmd, override, args) + if not guest_argv: + raise Error(f"run: {ref} has no command; pass one") + env = merge_env(config_strings(config, "Env")) + workdir = config.get("WorkingDir") or "/" + # Every host-side access below resolves the image's links inside the + # rootfs, so none can reach host files. + os.makedirs(resolve_in_root(rootfs, workdir), exist_ok=True) + + guest_argv[0] = find_command( + guest_argv[0], env["PATH"], workdir, lambda p: is_guest_exec(rootfs, p) + ) + start_watcher(os.getpid(), root, run_dir, lock) + except BaseException: + detach(root) + if not os.path.ismount(root): + shutil.rmtree(run_dir, ignore_errors=True) + raise + command = [elfuse, "--sysroot", rootfs, "--user", "0:0"] + command += ["--workdir", workdir, "--clear-env"] + for key, value in env.items(): + command += ["--env", f"{key}={value}"] + try: + os.execv(elfuse, command + ["--"] + guest_argv) + except OSError as e: + raise Error(f"cannot run {elfuse}: {e.strerror}") from None + + +def cmd_clean(): + """Detach the images this store attached and remove everything it holds. + + That includes what killed pulls and runs left behind. A non-empty + directory without the marker is refused, and so is a store a pull or run + still holds after WATCHER_DETACH_SECONDS. + """ + if not os.path.isdir(store_root()) or not os.listdir(store_root()): + print(f"Nothing to clean at {store_root()}") + return + store = os.path.realpath(store_root()) + if not os.path.isfile(os.path.join(store, STORE_MARKER)): + raise Error(f"{store} is not an elfuse-oci store; not cleaning it") + lock = os.open(os.path.join(store, STORE_MARKER), os.O_RDONLY) + for _ in range(WATCHER_DETACH_SECONDS * 5): + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + time.sleep(0.2) + else: + raise Error(f"{store} is in use by a pull or run") + # Nothing is detached with -force: a volume a process still uses stays. + for dev in store_devices(read_hdiutil_info(), store): + subprocess.run(["hdiutil", "detach", "-quiet", dev], stderr=subprocess.DEVNULL) + still_attached = store_devices(read_hdiutil_info(), store) + if still_attached: + raise Error(f"cannot detach {' '.join(still_attached)}; still in use") + for name in ("images", "refs", "run", "tmp"): + path = os.path.join(store, name) + shutil.rmtree(path, ignore_errors=True) + if os.path.lexists(path): + raise Error(f"cannot remove {path}") + print(f"Removed images and runs under {store}") + + +def listing(store): + """Return (reference, digest, size) rows for the store's sealed images. + + One row per reference whose record names a sealed image, sorted, then one + row per image no record names. + """ + refs_by_digest = {} + for path in glob.glob(os.path.join(store, "refs", "*")): + record = load_json(path) + refs_by_digest.setdefault(record["digest"], []).append(record["ref"]) + rows = [] + for path in glob.glob(os.path.join(store, "images", "*.dmg")): + digest = "sha256:" + os.path.basename(path).removesuffix(".dmg") + size = os.path.getsize(path) + refs = refs_by_digest.get(digest, [""]) + rows += [(ref, digest, size) for ref in refs] + return sorted(rows, key=lambda row: (row[0] == "", row)) + + +def cmd_list(): + """Print the store's images as a table. + + A directory without the marker holds none, and list never creates a store. + """ + store = store_root() + rows = [] + if os.path.isfile(os.path.join(store, STORE_MARKER)): + share_store(store) + rows = listing(store) + table = [("REFERENCE", "DIGEST", "SIZE")] + table += [(ref, digest, f"{size / 2**20:.1f} MiB") for ref, digest, size in rows] + widths = [max(map(len, column)) for column in zip(*table)] + for row in table: + print(" ".join(f"{cell:{width}}" for cell, width in zip(row, widths)).rstrip()) + + +def main(argv): + """Dispatch the command in argv and return the exit status.""" + command = argv[0] if argv else "" + try: + if command == "pull" and len(argv) == 2: + store = open_store() + share_store(store) + print(f"Pulled {argv[1]} -> {pull(store, argv[1])}", file=sys.stderr) + elif command == "run": + cmd_run(argv[1:]) + elif command == "clean" and len(argv) == 1: + cmd_clean() + elif command == "list" and len(argv) == 1: + cmd_list() + else: + print(USAGE, file=sys.stderr) + return 2 + except (Error, OSError) as e: + print(f"elfuse-oci: {e}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/test-elfuse-oci.py b/scripts/test-elfuse-oci.py new file mode 100755 index 000000000..51522672c --- /dev/null +++ b/scripts/test-elfuse-oci.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Table tests for the parts of scripts/elfuse-oci.py that need no image. + +Exit status is 1 when any case fails. +""" + +import importlib.util +import json +import os +import shutil +import stat +import subprocess +import sys +import tempfile +import time + + +def load_elfuse_oci(): + """Import scripts/elfuse-oci.py, whose name is not a Python identifier.""" + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "elfuse-oci.py") + spec = importlib.util.spec_from_file_location("elfuse_oci", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +oci = load_elfuse_oci() + + +def main(): + """Run every case and return the exit status.""" + + def find(cmd, path): + on_path = {"/bin/hello", "/w/tools/hello", "/w/hello", "/w/bin/x"}.__contains__ + return oci.find_command(cmd, path, "/w", on_path) + + def attached(*paths): + images = [ + {"image-path": p, "system-entities": [{"dev-entry": p}]} for p in paths + ] + return oci.store_devices({"images": images}, "/s") + + def half_attached(entities, image_path="/s/images/a.dmg"): + images = [{"image-path": image_path, "system-entities": entities}] + return oci.store_devices({"images": images}, "/s") + + tree = tempfile.mkdtemp() + for d in ("r/etc", "r/usr/lib", "outside"): + os.makedirs(os.path.join(tree, d)) + for link, target in ( + ("r/lib", "usr/lib"), + ("r/abs", "/usr"), + ("r/up", "../../../../../../../outside"), + ("r/loop", "loop"), + ("r/usr/lib/self", "."), + ): + os.symlink(target, os.path.join(tree, link)) + root = os.path.join(tree, "r") + for name, mode in (("x", 0o610), ("y", 0o644)): + path = os.path.join(root, name) + open(path, "w").close() + os.chmod(path, mode) + + def resolve(path, follow_last=True): + return os.path.relpath(oci.resolve_in_root(root, path, follow_last), tree) + + store = os.path.join(tree, "s") + os.makedirs(os.path.join(store, "refs")) + os.makedirs(os.path.join(store, "images")) + for name, ref in (("h1", "b:1"), ("h2", "a:1")): + with open(os.path.join(store, "refs", name), "w") as f: + json.dump({"ref": ref, "digest": "sha256:aa"}, f) + for name, size in (("aa.dmg", 3), ("bb.dmg", 5), (".DS_Store", 1)): + with open(os.path.join(store, "images", name), "w") as f: + f.write("x" * size) + + rootfs = os.path.join(tree, "f") + os.makedirs(os.path.join(rootfs, "usr/lib/deep")) + os.symlink("/usr", os.path.join(rootfs, "usr/lib/deep/abs")) + os.symlink("/usr/lib", os.path.join(rootfs, "lib")) + # umoci keeps modes that deny the owner search (locked) or write (inner). + locked = os.path.join(rootfs, "locked") + inner = os.path.join(locked, "inner") + os.makedirs(inner) + os.symlink("/usr", os.path.join(inner, "abs")) + os.chmod(inner, 0o500) + os.chmod(locked, 0o600) + try: + oci.fixup_rootfs(rootfs) + finally: + locked_modes = [] + for d in (locked, inner): + locked_modes.append(stat.S_IMODE(os.stat(d).st_mode)) + os.chmod(d, 0o700) + + def pull_status(store_path): + env = dict(os.environ, ELFUSE_OCI_STORE=store_path) + cmd = [sys.executable, oci.__file__, "pull", "x"] + done = subprocess.run(cmd, env=env, capture_output=True, text=True) + return done.returncode, done.stderr + + not_a_dir = os.path.join(root, "y") + + def watcher_leaves_dir(exited_first): + run_dir = tempfile.mkdtemp(dir=tree) + target = subprocess.Popen(["cat"], stdin=subprocess.PIPE) + if exited_first: + target.communicate() + oci.start_watcher(target.pid, os.path.join(run_dir, "root"), run_dir, -1) + target.communicate() + for _ in range(500): + if not os.path.exists(run_dir): + return False + time.sleep(0.01) + return True + + cases = [ + (oci.relative_target, ("usr/bin/tool", "/bin/hello"), "../../bin/hello"), + (oci.relative_target, ("a/b", "/usr/../../x"), "../x"), + (oci.relative_target, ("a/b/c", "/"), "../../"), + (oci.relative_target, ("top", "/"), "."), + (resolve, ("/lib/x",), "r/usr/lib/x"), + (resolve, ("/abs/lib",), "r/usr/lib"), + (resolve, ("/up/x",), "r/outside/x"), + (resolve, ("/usr/../etc",), "r/etc"), + (resolve, ("/lib/self/self/x",), "r/usr/lib/x"), + (resolve, ("/lib", False), "r/lib"), + (resolve, ("/loop/x",), oci.Error), + (oci.launch_argv, (["/ep"], ["c"], None, []), ["/ep", "c"]), + (oci.launch_argv, (["/ep"], ["c"], None, ["a"]), ["/ep", "a"]), + (oci.launch_argv, (["/ep"], ["c"], "/other", []), ["/other"]), + (oci.launch_argv, (["/ep"], ["c"], "", ["sh"]), ["sh"]), + ( + oci.merge_env, + (["A=1", "NOEQ", "=x"],), + {"A": "1", "PATH": oci.DEFAULT_PATH, "HOME": "/root"}, + ), + (oci.merge_env, (["PATH=/p", "HOME=/h"],), {"PATH": "/p", "HOME": "/h"}), + (oci.merge_env, (["PATH="],), {"PATH": oci.DEFAULT_PATH, "HOME": "/root"}), + (oci.config_strings, ({"Cmd": ["sh", "-c"]}, "Cmd"), ["sh", "-c"]), + (oci.config_strings, ({"Entrypoint": None}, "Entrypoint"), []), + (oci.config_strings, ({"Entrypoint": "/bin/sh"}, "Entrypoint"), oci.Error), + (oci.config_strings, ({"Env": ["A=1", 2]}, "Env"), oci.Error), + (find, ("hello", "/usr/bin:/bin"), "/bin/hello"), + (find, ("bin/x", "/bin"), "/w/bin/x"), + (find, ("/app/tool", "/bin"), oci.Error), + (find, ("/bin/hello", "/x"), "/bin/hello"), + (find, ("hello", "/usr/bin:tools"), "/w/tools/hello"), + (find, ("hello", "/usr/bin::/bin"), "/w/hello"), + (find, ("nosuch", "/bin"), oci.Error), + (oci.parse_run_args, (["img", "a"],), (None, "img", ["a"])), + (oci.parse_run_args, (["--entrypoint", "", "img"],), ("", "img", [])), + (oci.parse_run_args, (["--entrypoint"],), oci.Error), + (oci.parse_run_args, (["-e", "img"],), oci.Error), + ( + attached, + ("/s/images/a.dmg", "/s/tmp/9/v"), + ["/s/images/a.dmg", "/s/tmp/9/v"], + ), + (attached, ("/s/cache.sparsebundle", "/s2/images/b.dmg", "/s/run/1/x"), []), + (half_attached, ([],), oci.Error), + (half_attached, ([{"content-hint": ""}],), oci.Error), + (half_attached, ([], "/s2/images/b.dmg"), []), + ( + oci.listing, + (store,), + [ + ("a:1", "sha256:aa", 3), + ("b:1", "sha256:aa", 3), + ("", "sha256:bb", 5), + ], + ), + (oci.listing, (os.path.join(tree, "outside"),), []), + (os.readlink, (os.path.join(rootfs, "usr/lib/deep/abs"),), "../../../usr"), + (os.readlink, (os.path.join(rootfs, "lib"),), "usr/lib"), + (list, (locked_modes,), [0o600, 0o500]), + (os.readlink, (os.path.join(inner, "abs"),), "../../usr"), + (oci.is_guest_exec, (root, "/x"), True), + (oci.is_guest_exec, (root, "/y"), False), + ( + pull_status, + (not_a_dir,), + (1, f"elfuse-oci: [Errno 17] File exists: '{not_a_dir}'\n"), + ), + (watcher_leaves_dir, (False,), False), + (watcher_leaves_dir, (True,), False), + (oci.start_watcher, (None, "/nonexistent", "/nonexistent", -1), oci.Error), + ] + failures = 0 + for fn, args, want in cases: + try: + got = fn(*args) + except oci.Error: + got = oci.Error + if got != want: + print(f"test-elfuse-oci: {fn.__name__}{args}: got {got!r}, want {want!r}") + failures += 1 + shutil.rmtree(tree) + if failures: + return 1 + print(f"test-elfuse-oci: {len(cases)} cases passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From d069de72e4a4365a4ef26788907dba8d78e8aa49 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Tue, 6 Oct 2026 15:39:51 +0200 Subject: [PATCH 3/4] Run the OCI front end in CI scripts/ci/oci-smoke.sh drives scripts/elfuse-oci.py under elfuse: the table tests in scripts/test-elfuse-oci.py, a cold pull that seals one 0400 image and a warm run that does not unpack again, list naming the sealed image, the guest exit status, a Debian tool pipeline, per-run isolation, a dynamic interpreter through --entrypoint, a run that does not see a concurrent run's files, clean refusing a non-store and a store in use, a pull without umoci failing with one line and leaving no build behind, an image whose /etc climbs to a host directory, the sealed image unchanged by the runs, and a final clean that empties the store. It pulls the library images through mirror.gcr.io, so it does not depend on Docker Hub's anonymous pull limit. The oci-smoke job builds crane and umoci from source with Go, because the runner user cannot write the Homebrew prefix. It then runs the lane on the self-hosted macOS runner against the elfuse binary build-macos uploads. Each runner gets its own store, since the runners on one host share $HOME. --- .github/workflows/build.yml | 52 ++++++++++ scripts/ci/oci-smoke.sh | 186 ++++++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100755 scripts/ci/oci-smoke.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 91f8ab731..b8dea8361 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,6 +3,7 @@ # build-macos : compile + entitlement check on macOS Apple Silicon # runtime-macos : HVF runtime tests on self-hosted Apple Silicon, # including release, ASAN, UBSAN, and TSAN variants +# oci-smoke : OCI front end lane on self-hosted Apple Silicon # # Runtime and sanitizer tests require Hypervisor.framework, which # GitHub-hosted macOS runners do not expose. Those tests run on self-hosted @@ -408,3 +409,54 @@ jobs: else echo "No externals/test-fixtures to save" fi + + oci-smoke: + name: OCI smoke + needs: build-macos + if: > + github.repository == 'sysprog21/elfuse' && + (github.event_name == 'push' || github.event_name == 'pull_request' || + github.event_name == 'workflow_dispatch' || + github.event_name == 'merge_group') + runs-on: [self-hosted, macOS, arm64] + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Download prebuilt elfuse binary + uses: actions/download-artifact@v7 + with: + name: elfuse-${{ runner.os }}-${{ runner.arch }} + path: build + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: '1.27' + cache: false + + - name: Build crane and umoci + # go install refuses umoci: its go.mod has a replace directive. + env: + CRANE_VERSION: v0.22.1 + UMOCI_VERSION: v0.6.0 + run: | + set -euo pipefail + bin="$RUNNER_TEMP/oci-tools" + GOBIN="$bin" go install \ + "github.com/google/go-containerregistry/cmd/crane@$CRANE_VERSION" + umoci="github.com/opencontainers/umoci@$UMOCI_VERSION" + go mod download "$umoci" + go build -C "$(go env GOMODCACHE)/$umoci" -o "$bin/umoci" ./cmd/umoci + echo "$bin" >> "$GITHUB_PATH" + "$bin/crane" version + "$bin/umoci" --version + + - name: Run the OCI smoke lane + # A downloaded artifact keeps its signature but not its execute bit. + # Runners on one host share $HOME, so each gets its own store. + run: | + set -euo pipefail + chmod +x build/elfuse + ELFUSE_OCI_STORE="$HOME/.cache/elfuse-ci/oci-smoke-$RUNNER_NAME" scripts/ci/oci-smoke.sh diff --git a/scripts/ci/oci-smoke.sh b/scripts/ci/oci-smoke.sh new file mode 100755 index 000000000..5cb10a921 --- /dev/null +++ b/scripts/ci/oci-smoke.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# Drive scripts/elfuse-oci.py end to end under elfuse. Usage: +# ELFUSE_OCI_STORE= scripts/ci/oci-smoke.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +BIN="$ROOT/scripts/elfuse-oci.py" +: "${ELFUSE_OCI_STORE:?set ELFUSE_OCI_STORE to the store directory to use}" +export ELFUSE_OCI_STORE + +# The library images through mirror.gcr.io, so the lane does not depend on +# Docker Hub's anonymous pull limit. +ALPINE=mirror.gcr.io/library/alpine:3 +DEBIAN=mirror.gcr.io/library/debian:stable-slim + +guest="" +registry="" +work="$(mktemp -d)" +on_exit() +{ + rc=$? + # Reap what this lane started before a persistent runner reuses it. + [ -z "$guest" ] || kill "$guest" 2> /dev/null || true + [ -z "$registry" ] || kill "$registry" 2> /dev/null || true + rm -rf "$work" + exit "$rc" +} +trap on_exit EXIT + +fail() +{ + echo "FAIL: $*" >&2 + exit 1 +} + +# Drain stdin so pipefail does not report the producer's SIGPIPE. +expect_grep() +{ + grep -F -- "$1" > /dev/null +} + +run_capture() +{ + local desc="$1" pattern="$2" out + shift 2 + out="$("$@")" || fail "$desc: exited $?: $out" + printf '%s: %s\n' "$desc" "$out" + printf '%s\n' "$out" | expect_grep "$pattern" || fail "$desc: output lacks $pattern" +} + +# Poll a predicate without letting its failures escape set -e. +wait_for() +{ + local timeout="$1" desc="$2" i=0 + shift 2 + while ! "$@" > /dev/null; do + i=$((i + 1)) + [ "$i" -lt $((timeout * 2)) ] || fail "timed out after ${timeout}s waiting for: $desc" + sleep 0.5 + done +} + +runs_gone() +{ + [ -z "$(ls "$ELFUSE_OCI_STORE/run" 2> /dev/null)" ] +} + +# Pull an image whose /etc climbs out of the rootfs to a host directory. The +# unpack deletes the image's /etc/hosts, which must resolve inside the rootfs. +pull_hostile() +{ + local dir="$work/hostile" port up image out + mkdir -p "$dir/host" + echo keep > "$dir/host/hosts" + crane registry serve --address 127.0.0.1:0 > "$dir/registry.log" 2>&1 & + registry=$! + wait_for 30 "local registry" grep -q 'serving on port' "$dir/registry.log" + port="$(sed -n 's/.*serving on port //p' "$dir/registry.log")" + image="127.0.0.1:$port/hostile:1" + up="$(printf '../%.0s' {1..30})" + printf '#mtree\n./etc type=link mode=0777 uid=0 gid=0 link=%s\n' \ + "${up%/}$dir/host" > "$dir/layer.mtree" + tar -cf "$dir/layer.tar" "@$dir/layer.mtree" + crane append --oci-empty-base -f "$dir/layer.tar" -t "$image" > /dev/null 2>&1 \ + || fail "cannot push the hostile image" + + # Without umoci the pull fails with one line and removes its build. + mkdir "$dir/crane-only" + ln -s "$(command -v crane)" "$dir/crane-only/crane" + if out="$(PATH="$dir/crane-only:/usr/bin:/bin:/usr/sbin:/sbin" \ + "$BIN" pull "$image" 2>&1)"; then + fail "a pull without umoci succeeded" + fi + printf '%s\n' "$out" | expect_grep "umoci not found" \ + || fail "a pull without umoci: $out" + [ -z "$(ls "$ELFUSE_OCI_STORE/tmp")" ] || fail "a failed pull left its build" + + "$BIN" pull "$image" + [ "$(cat "$dir/host/hosts")" = keep ] || fail "a pull changed a host file" + kill "$registry" + registry="" +} + +"$ROOT/scripts/test-elfuse-oci.py" +"$BIN" clean > /dev/null + +# A cold pull seals one private image, and a run of it does not unpack again. +out="$("$BIN" pull "$ALPINE" 2>&1)" || fail "cold pull: $out" +printf '%s\n' "$out" | expect_grep Unpacking || fail "the cold pull did not unpack" +set -- "$ELFUSE_OCI_STORE"/images/*.dmg +[ $# -eq 1 ] && [ -f "$1" ] || fail "pull sealed $# images, want 1" +img=$1 +[ "$(stat -f %Lp "$img")" = 400 ] || fail "the sealed image is mode $(stat -f %Lp "$img")" +sum=$(shasum -a 256 "$img") +out="$("$BIN" run "$ALPINE" /bin/echo elfuse-oci-ci-ok 2>&1)" || fail "warm run: $out" +printf 'alpine: %s\n' "$out" +printf '%s\n' "$out" | expect_grep elfuse-oci-ci-ok \ + || fail "the warm run did not print elfuse-oci-ci-ok" +if printf '%s\n' "$out" | expect_grep Unpacking; then + fail "the warm run unpacked again" +fi + +out="$("$BIN" list)" +printf 'list:\n%s\n' "$out" +printf '%s\n' "$out" | awk -v r="$ALPINE" -v d="sha256:$(basename "$img" .dmg)" \ + '$1 == r && $2 == d { found = 1 } END { exit !found }' \ + || fail "list does not show $ALPINE with the sealed image's digest" + +code=0 +"$BIN" run "$ALPINE" /bin/sh -c 'exit 7' || code=$? +[ "$code" -eq 7 ] || fail "guest exit status: got $code, want 7" + +# Debian's usr-merged gzip must resolve inside the image. +want=5af7b95208fdcff454bab3f5eddf567a688a3796c703d4fef91072e38645c062 +got="$("$BIN" run "$DEBIAN" /bin/sh -c 'set -e + seq 1 200000 > /tmp/data.txt + gzip -c /tmp/data.txt > /tmp/data.gz + gunzip -c /tmp/data.gz | cmp - /tmp/data.txt + sha256sum /tmp/data.txt | cut -d" " -f1')" || fail "debian pipeline exited $?: $got" +printf 'debian pipeline sha256: %s\n' "$got" +[ "$got" = "$want" ] || fail "debian pipeline sha256: got $got, want $want" + +# Each run gets its own shadow, without the previous run's /tmp. +run_capture isolation isolated-ok \ + "$BIN" run "$DEBIAN" /bin/sh -c 'test ! -e /tmp/data.txt && echo isolated-ok' + +run_capture interpreter elfuse-interp-ok \ + "$BIN" run --entrypoint /bin/bash "$DEBIAN" -c 'echo elfuse-interp-ok' + +# clean refuses a non-empty directory that is not a store, and a store in use. +other="$work/other" +mkdir "$other" +touch "$other/keep" +if ELFUSE_OCI_STORE="$other" "$BIN" clean > /dev/null 2>&1; then + fail "clean accepted a directory that is not a store" +fi +[ -f "$other/keep" ] || fail "clean removed a file outside a store" +busy="$work/busy" +"$BIN" run "$ALPINE" /bin/sh -c 'touch /tmp/first-run && echo busy && sleep 60' \ + > "$busy" 2>&1 & +guest=$! +wait_for 60 "a running guest" grep -q busy "$busy" +# A run alongside it gets its own shadow too. +run_capture concurrent concurrent-ok \ + "$BIN" run "$ALPINE" /bin/sh -c 'test ! -e /tmp/first-run && echo concurrent-ok' +if "$BIN" clean > /dev/null 2>&1; then + fail "clean ran while a run was in progress" +fi +kill "$guest" +wait "$guest" 2> /dev/null || true +guest="" + +pull_hostile + +# Runs leave the sealed image as pulled, their watchers detach them, and clean +# then empties the store. +[ "$(shasum -a 256 "$img")" = "$sum" ] || fail "a run changed the sealed image" +wait_for 30 "runs to detach" runs_gone +"$BIN" clean > /dev/null +[ -z "$(ls "$ELFUSE_OCI_STORE")" ] || fail "clean left $(ls "$ELFUSE_OCI_STORE")" +attached="$(hdiutil info)" || fail "hdiutil info failed" +if printf '%s\n' "$attached" | expect_grep "$(cd "$ELFUSE_OCI_STORE" && pwd -P)/"; then + fail "an image under the store is still attached" +fi + +echo "OCI smoke OK" From c3c52b448f6e2da6028b7bf4d6bec2e41ad6e6a3 Mon Sep 17 00:00:00 2001 From: Chun-Hung Tseng Date: Tue, 6 Oct 2026 13:43:23 +0200 Subject: [PATCH 4/4] Document the OCI front end docs/oci-images.md describes the store and its safeguards, how a pull seals one case-sensitive volume per manifest digest and what it changes in umoci's tree, how a run attaches that volume with a per-run shadow and why a read-only sysroot alone is not enough, what clean removes and refuses, what list shows, and how the table tests and the smoke lane validate it. docs/usage.md gains the commands, the run option, the store variable, and the local lane recipe, docs/testing.md the validation entry, and README.md the overview paragraph and link. --- README.md | 10 +++ docs/oci-images.md | 148 +++++++++++++++++++++++++++++++++++++++++++++ docs/testing.md | 9 +++ docs/usage.md | 40 ++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 docs/oci-images.md diff --git a/README.md b/README.md index 18f574227..8f3ee2855 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,14 @@ linker resolved against an external sysroot via `--sysroot`. - Self-contained test matrix that cross-checks elfuse against QEMU and exercises a separate Rosetta acceptance suite +## OCI Images + +`scripts/elfuse-oci.py` pulls OCI images with crane, unpacks each once with +umoci into a read-only case-sensitive volume, and runs it under elfuse with a +per-run writable shadow. It does not add container isolation. See +[docs/usage.md](docs/usage.md#oci-images) and +[docs/oci-images.md](docs/oci-images.md). + ## Positioning `elfuse` is intentionally narrow. It runs single Linux binaries (and @@ -151,6 +159,8 @@ The build signs `build/elfuse` before use. Override the signing identity with - [docs/testing.md](docs/testing.md): build prerequisites, the `make check` flow, the QEMU and Rosetta cross-check matrices, and fixture handling. +- [docs/oci-images.md](docs/oci-images.md): the OCI image store, sealed + volumes, per-run shadows, and validation. - [docs/filenames.md](docs/filenames.md): how a guest filename becomes a name on disk and back: case folding and normalization on the sysroot volume, the escape encoding, and the length limits both systems impose. diff --git a/docs/oci-images.md b/docs/oci-images.md new file mode 100644 index 000000000..a57e5e683 --- /dev/null +++ b/docs/oci-images.md @@ -0,0 +1,148 @@ +# OCI Images + +An OCI image packages a filesystem as ordered, content-addressed layers plus a +JSON configuration; a manifest names both, and an image index selects a +manifest per platform. `scripts/elfuse-oci.py` pulls such an image, unpacks it +once into a volume of its own, and runs it under elfuse with that volume as the +sysroot. It adds no namespaces, cgroups, or other container isolation. Command +syntax is in [usage.md](usage.md#oci-images). + +The script is Python 3.9 standard library code driving three tools: crane +fetches images, umoci unpacks them, and hdiutil creates and attaches the +volumes. `brew install crane umoci` provides the first two. + +## Store + +The store is `$ELFUSE_OCI_STORE`, by default `~/.local/share/elfuse/oci`: + +```text +/ + .elfuse-oci-store marker and lock + images/.dmg one sealed volume per manifest digest + refs/ the reference and the digest it last resolved to + run// a running guest's mountpoint and shadow file + tmp//, tmp/.ref a pull in progress +``` + +A directory becomes a store only while it is empty: `pull` and `run` write the +marker into a new or empty directory and refuse any other directory without +one, and `clean` refuses a non-empty directory without the marker. A mistyped +`ELFUSE_OCI_STORE` therefore never lets `clean` delete unrelated files. The +store is mode 0700 and each sealed image 0400, since an image may come from a +registry only its owner can read. + +Every `pull` and `run` holds a shared `flock(2)` on the marker for its whole +life, a run through its watcher (below), and `clean` takes it exclusively. A +pull publishes into `images/` and `refs/` only by `link(2)` or `rename(2)` of a +complete file, so a pull or run that dies leaves its debris in its own +`tmp//`, `tmp/.ref`, or `run//`, where `clean` finds it. A later +process whose pid matches such a directory stops with an error rather than reuse +it. + +## Pull + +`pull` resolves the reference to its `linux/arm64` manifest digest with +`crane digest`. When `images/` already holds that digest, it only records the +reference. Otherwise it builds the image: + +1. `crane pull --format oci` fetches the manifest by digest into a fresh OCI + layout under `tmp//`. Pulling by digest keeps a tag that moves after + resolution from changing the content. The layout is never reused: crane + appends to an existing `index.json`, and umoci refuses a reference that + then resolves to two entries. +2. `hdiutil create` makes a case-sensitive APFS sparse image of 64 GiB + capacity, allocated as written, and `umoci raw unpack --rootless` unpacks + into it. A default macOS volume folds case, which a Linux rootfs does not + expect. +3. The tree is adjusted, below, and the image configuration is copied beside + the rootfs as `config.json`. +4. `hdiutil convert` writes the volume as an LZFSE-compressed read-only image + (ULFO) and publishes it with `link(2)`, which never replaces an existing + file, so of two concurrent pulls of one digest the first to publish wins. + +The adjustments bring umoci's output to what a Linux unpack shows the guest: + +- An absolute symlink target is rewritten relative to the link, as + [filenames.md](filenames.md#symlink-targets) describes for guest links: + anything following it natively would resolve it from the host root. `..` is + clamped at the root, so the result cannot climb out of it. +- `/etc/hosts` and `/etc/resolv.conf` are removed. elfuse resolves them on the + host when the sysroot lacks them, so the guest reads the host's live files, + much as a container reads the files its runtime mounts over these paths. + +Every directory keeps its mode; one its owner cannot read, write, or search gets +those bits only while the tree is adjusted. + +Device nodes become empty regular files, since umoci cannot create them +without root. FIFOs and the set-id and sticky bits are kept as the image has +them. Names that differ only in Unicode normalization still +share one entry, because APFS is normalization-insensitive even when +case-sensitive. + +## Run + +`run` uses the digest `refs/` recorded, so a warm run needs no network; an +unknown reference is pulled first. It attaches the sealed image with a shadow +file under `run//`: reads come from the image, writes go to the shadow +file, and the image itself is never written. A read-only sysroot alone is not +enough: elfuse resolves a guest's system directories, `/etc`, `/root`, `/run`, +and most of `/var` among them, and its `/tmp` only inside the sysroot +([filenames.md](filenames.md#the-sysroot-boundary-decides-whose-rules-apply)), +so guest writes there would fail. Each run starts from the same tree, and +concurrent runs of one image do not see each other's files. + +Mounting writes about 23 MiB of APFS metadata into the shadow file at the +64 GiB capacity, which also bounds what one run can write. + +The launch follows `docker run`: + +- The command is the image Entrypoint followed by its Cmd. Arguments after + the reference replace Cmd; `--entrypoint`, even an empty one, replaces + Entrypoint and drops Cmd. A command without a slash is looked up in the + final `PATH` inside the rootfs, and one with a slash but not absolute is + taken relative to the working directory. Either way the command must be a + file in the image with an execute bit: elfuse would fall back to a host file + of the same path, so the script refuses to launch instead. +- The environment is the image Env, with a default `PATH` when it sets none or + an empty one, and `HOME=/root` unless it sets `HOME`. +- The guest runs as `0:0`; the image User is ignored. +- The working directory is the image WorkingDir, else `/`, and is created if + missing. + +Whenever the script touches the tree itself, to create the working directory +or look up a command, it resolves the image's links inside the rootfs, so no +image link reaches a host file. + +The script then execs `elfuse --sysroot /rootfs --user 0:0 --workdir DIR +--clear-env --env KEY=VALUE ... -- COMMAND`, so signals, the exit status, +and the terminal are elfuse's own. Before the exec it starts a watcher, +reparented to launchd so that elfuse never sees it as a child, which takes over +the store lock, waits for elfuse to exit through kqueue `NOTE_EXIT`, and then +detaches the volume and removes `run//`. A run killed with SIGKILL is +cleaned up the same way. The watcher never detaches by force: while a guest +process that outlived elfuse still uses the volume, it waits. + +## Clean + +`clean` waits up to 10 seconds for the store's lock, which an exited run's +watcher releases once its volume is detached, and refuses a store still in use +by a pull or run. It then detaches every image attached from the store's +`images/` or `tmp/`, including the volumes of runs and pulls that were killed, +and removes `images/`, `refs/`, `run/`, and `tmp/`. It never detaches by force: +a volume a process still uses, or a directory that cannot be removed, fails the +command. Other files in the store directory are left alone. + +## List + +`list` prints a row per reference in `refs/`: the reference, the digest it last +resolved to, and the size of that sealed image. An image no reference resolves +to any more, left when a tag moved and was pulled again, shows as ``. +`list` holds the store's lock shared while it reads and never creates a store. + +## Validation + +`scripts/test-elfuse-oci.py` runs table cases over the parts of +`scripts/elfuse-oci.py` that need no image. The self-hosted `oci-smoke` job runs +`scripts/ci/oci-smoke.sh`: those table tests, then pull, run, list, and clean +under elfuse on Alpine and Debian images, an image whose `/etc` climbs to a host +directory, and `clean` refusing a non-store and a store in use. diff --git a/docs/testing.md b/docs/testing.md index 9d4188cdb..16989e29a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -671,6 +671,7 @@ Suggested minimum validation: | Change area | Recommended validation | |-------------|------------------------| +| `scripts/elfuse-oci.py` | `python3 scripts/test-elfuse-oci.py`, plus `make elfuse && ELFUSE_OCI_STORE= scripts/ci/oci-smoke.sh` on a Hypervisor.framework host | | CLI, logging, docs-only build rules | `make elfuse` | | Filename codec, case-exact walk, sysroot resolvers | `make check` (runs the codec unit tests, name lanes, and byte-exact oracle lane), plus `make test-sysroot-name-soak` for resolver concurrency. A red golden vector in `test-casefold-host` means the on-disk format moved: see `docs/filenames.md` before touching `tests/casefold-vectors.h` | | General syscall or runtime logic | `make elfuse && make check && make test-matrix-elfuse-aarch64` | @@ -679,3 +680,11 @@ Suggested minimum validation: | Broad behavioral changes | `make elfuse && make check && make test-matrix` | | Debugger or ptrace flow | `make elfuse && make test-gdbstub` | | ACSL contracts, `src/proved/`, `mk/verify.mk`, the mutation harness | `make verify && make verify-mutants`, in that order and never beside a runtime lane: both fan out, and the timing lanes fail under the load they create. Add `make check` only when the change touches code rather than annotations | + +## OCI Images + +`python3 scripts/test-elfuse-oci.py` runs table cases over the parts of the OCI +front end that need no image. The self-hosted `oci-smoke` job runs it +at the start of its lane; see [oci-images.md](oci-images.md#validation) for the +lane's coverage and [usage.md](usage.md#running-the-smoke-lane-locally) for the +command. diff --git a/docs/usage.md b/docs/usage.md index 13b387251..651e0a1d1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -353,3 +353,43 @@ Off by default, and useful when a guest misbehaves rather than in normal use: - `ELFUSE_DISABLE_TLBI_RANGE=1`: refuse `FEAT_TLBIRANGE` and fall back to per-page and broadcast invalidation, which separates a stale-TLB bug from the range-encoding path. + +## OCI Images + +`scripts/elfuse-oci.py` runs OCI images under elfuse; +[oci-images.md](oci-images.md) describes the store, the sealed volumes, and the +per-run shadow. It needs crane and umoci (`brew install crane umoci`), and runs +`build/elfuse` from its checkout, else `elfuse` on `PATH`. + +```sh +make elfuse +scripts/elfuse-oci.py run python:3.12-slim python3 -c 'print("hello")' +scripts/elfuse-oci.py clean +``` + +| Command | Effect | +|---------|--------| +| `pull REF` | Seal the `linux/arm64` image of `REF` unless the store holds it | +| `run [--entrypoint CMD] REF [ARG]...` | Run `REF` under elfuse, pulling it first when the store has no record of it | +| `clean` | Detach and remove every image, run, and interrupted pull in the store; refuse a non-empty directory that is not a store, and a store in use | +| `list` | Print each reference in the store with its image digest and size, and each unreferenced image as `` | + +`--entrypoint CMD` replaces the image Entrypoint and drops its Cmd; an empty +`CMD` leaves only the arguments. Every argument after `REF` goes to the guest. +The store is `$ELFUSE_OCI_STORE`, by default `~/.local/share/elfuse/oci`; a new +or empty directory becomes a store. + +crane reads registry credentials from the Docker configuration +(`$DOCKER_CONFIG/config.json`, by default `~/.docker/config.json`) when one +exists, else from Podman's `$REGISTRY_AUTH_FILE` or +`$XDG_RUNTIME_DIR/containers/auth.json`. + +### Running The Smoke Lane Locally + +```sh +make elfuse +ELFUSE_OCI_STORE=/tmp/oci-scratch scripts/ci/oci-smoke.sh +``` + +The lane needs macOS with Hypervisor.framework and network access, and ends by +cleaning the store. Its coverage is in [oci-images.md](oci-images.md#validation).