Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,27 @@ 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
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

runtime-macos:
name: Runtime (${{ matrix.name }})
needs: build-macos
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -604,3 +604,4 @@ include mk/lint.mk
include mk/verify.mk
include mk/format.mk
include mk/help.mk
include mk/oci.mk
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ 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
Expand Down Expand Up @@ -143,6 +150,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 `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.
Expand Down
96 changes: 96 additions & 0 deletions cmd/oci/common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// 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" {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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
}
60 changes: 60 additions & 0 deletions cmd/oci/common_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// 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)
}
}
61 changes: 61 additions & 0 deletions cmd/oci/flock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// 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 {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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()
}
Loading
Loading