Files
miandmhoennig ea30cbe79c
werkator build succeeded after 00:04
One import per image at a time, and run answers 126/127 for a command that cannot start (0.11.0) (#13)
Found on mih09 with Werkator 1.37.0 (repository werkrevisor, PR#5): a CI build and a pull-request review started together and both imported the same new rootfs.

## Imports of one image wait for each other

`Store.Import` stages in `<image>.tmp` and removes that directory first, so the next import cleans up after an interrupted one.
A second import at the same time removed the first one's files: one failed with `latest.tmp/image.json: no such file or directory`, the other's sandbox could not execute `/bin/sh`.
An import now holds an exclusive `flock` on `$WERKDOCK_HOME/locks/import-<hash>.lock`, across goroutines and processes; the kernel drops it when the holder dies.
The import that waited refuses with `already exists`, as an import after it would.

`TestConcurrentImportsOfOneImageLeaveOneCompleteImage`: four imports of a 3000-file archive at once leave one success, three refusals and one complete image. It failed 3 of 3 runs without the lock.

## `werkdock run` answers 127 or 126

`engine.CheckCommand` looks the command up like execvp inside the rootfs (symlinks resolved in the rootfs, PATH in order, a non-executable candidate skipped for a later one).
`run` refuses a command that cannot start with docker's 127 (not found) or 126 (not executable) before anything is created; until now bwrap's 1 came back.
Paths under a mount, the implicit tmpfs areas included, are not checked. `create`/`start` and the Docker API are unchanged.
The resolver moved from `store` to its own package `rootpath`, shared with `engine`.

Smoke-tested with the built binary: `run smoke missing` exits 127, a file without execute bit 126, a second import 125 with `already exists`.

Version 0.11.0 with its CHANGELOG section.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #13
2026-10-05 17:29:20 +02:00

209 lines
5.2 KiB
Go

package store
import (
"archive/tar"
"bufio"
"bytes"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"werkdock/internal/rootpath"
)
// applyLayer extracts one OCI/docker layer tar onto rootfs, honoring the
// whiteout convention: `.wh.NAME` removes NAME from the lower layers,
// `.wh..wh..opq` empties the directory it sits in. Whiteouts are applied
// before the layer's own entries, so an opaque directory refilled by the
// same layer ends up with exactly that layer's content.
//
// Every path is resolved inside rootfs the way the sandbox will see it:
// a lower layer's symlink (/bin -> usr/bin) is followed within rootfs,
// never on the host, and anything escaping rootfs is refused. Ownership
// is dropped, device nodes are skipped; the sandbox maps uid 0 to the
// caller anyway.
func applyLayer(rootfs, layerPath string) error {
if err := forEachEntry(layerPath, func(hdr *tar.Header, _ io.Reader) error {
return applyWhiteout(rootfs, hdr.Name)
}); err != nil {
return err
}
return forEachEntry(layerPath, func(hdr *tar.Header, body io.Reader) error {
if strings.HasPrefix(path.Base(hdr.Name), whiteoutPrefix) {
return nil
}
return extractEntry(rootfs, hdr, body)
})
}
const (
whiteoutPrefix = ".wh."
opaqueWhiteout = ".wh..wh..opq"
)
func applyWhiteout(rootfs, name string) error {
base := path.Base(name)
if !strings.HasPrefix(base, whiteoutPrefix) {
return nil
}
dir, err := rootpath.Resolve(rootfs, path.Dir(name))
if err != nil {
return err
}
if base == opaqueWhiteout {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
for _, e := range entries {
if err := os.RemoveAll(filepath.Join(dir, e.Name())); err != nil {
return err
}
}
return nil
}
return os.RemoveAll(filepath.Join(dir, strings.TrimPrefix(base, whiteoutPrefix)))
}
func extractEntry(rootfs string, hdr *tar.Header, body io.Reader) error {
name := path.Clean("/" + hdr.Name)
if name == "/" {
return nil
}
parent, err := rootpath.Resolve(rootfs, path.Dir(name))
if err != nil {
return err
}
if err := os.MkdirAll(parent, 0o755); err != nil {
return err
}
target := filepath.Join(parent, path.Base(name))
mode := os.FileMode(hdr.Mode) & (os.ModePerm | os.ModeSetuid | os.ModeSetgid | os.ModeSticky)
switch hdr.Typeflag {
case tar.TypeDir:
if fi, err := os.Lstat(target); err == nil && !fi.IsDir() {
if err := os.RemoveAll(target); err != nil {
return err
}
}
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
// Directories stay owner-writable so later layers can add to
// them; the rootfs is bound read-only into the sandbox anyway.
return os.Chmod(target, mode|0o700)
case tar.TypeReg, tar.TypeRegA:
if err := removeIfExists(target); err != nil {
return err
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600)
if err != nil {
return err
}
if _, err := io.Copy(f, body); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Chmod(target, mode)
case tar.TypeSymlink:
if err := removeIfExists(target); err != nil {
return err
}
return os.Symlink(hdr.Linkname, target)
case tar.TypeLink:
src, err := rootpath.Resolve(rootfs, "/"+hdr.Linkname)
if err != nil {
return err
}
if err := removeIfExists(target); err != nil {
return err
}
return os.Link(src, target)
case tar.TypeChar, tar.TypeBlock, tar.TypeFifo:
return nil // device nodes: /dev comes from the host; fifos are not image content
case tar.TypeXGlobalHeader:
return nil
default:
return fmt.Errorf("%s: unsupported tar entry type %q", hdr.Name, hdr.Typeflag)
}
}
func removeIfExists(target string) error {
if _, err := os.Lstat(target); err == nil {
return os.RemoveAll(target)
}
return nil
}
// forEachEntry streams a layer, transparently decompressing gzip
// in-process and zstd through the zstd binary.
func forEachEntry(layerPath string, fn func(*tar.Header, io.Reader) error) error {
f, err := os.Open(layerPath)
if err != nil {
return err
}
defer f.Close()
r, closeFn, err := decompress(f)
if err != nil {
return fmt.Errorf("%s: %w", layerPath, err)
}
defer closeFn()
tr := tar.NewReader(r)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return fmt.Errorf("%s: %w", layerPath, err)
}
if err := fn(hdr, tr); err != nil {
return fmt.Errorf("%s: %s: %w", filepath.Base(layerPath), hdr.Name, err)
}
}
}
var (
gzipMagic = []byte{0x1f, 0x8b}
zstdMagic = []byte{0x28, 0xb5, 0x2f, 0xfd}
)
func decompress(f *os.File) (io.Reader, func(), error) {
br := bufio.NewReader(f)
head, _ := br.Peek(4)
switch {
case bytes.HasPrefix(head, gzipMagic):
gz, err := gzip.NewReader(br)
if err != nil {
return nil, nil, err
}
return gz, func() { gz.Close() }, nil
case bytes.HasPrefix(head, zstdMagic):
cmd := exec.Command("zstd", "-dc")
cmd.Stdin = br
cmd.Stderr = os.Stderr
out, err := cmd.StdoutPipe()
if err != nil {
return nil, nil, err
}
if err := cmd.Start(); err != nil {
return nil, nil, fmt.Errorf("zstd layer needs the zstd binary: %w", err)
}
return out, func() { out.Close(); _ = cmd.Wait() }, nil
default:
return br, func() {}, nil
}
}