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

59 lines
1.7 KiB
Go

// Package rootpath maps paths inside an image to their place in the
// unpacked rootfs on the host, the way the sandbox will see them.
package rootpath
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
)
// Resolve maps an absolute in-image path to its host location
// under rootfs, following symlinks the way the sandbox would: relative
// to the link's directory, absolute ones relative to rootfs, and ".."
// above the root stays at the root, exactly as in a chroot — so a
// hostile layer can rewrite the image, never the host. Components that
// do not exist yet are appended verbatim.
func Resolve(rootfs, name string) (string, error) {
root := filepath.Clean(rootfs)
resolved := root
remaining := strings.Split(strings.Trim(path.Clean("/"+name), "/"), "/")
for hops := 0; len(remaining) > 0; {
component := remaining[0]
remaining = remaining[1:]
switch component {
case "", ".":
continue
case "..":
if resolved != root {
resolved = filepath.Dir(resolved)
}
continue
}
next := filepath.Join(resolved, component)
fi, err := os.Lstat(next)
if err != nil || fi.Mode()&os.ModeSymlink == 0 {
resolved = next
continue
}
if hops++; hops > 64 {
return "", fmt.Errorf("%s: too many levels of symbolic links", name)
}
link, err := os.Readlink(next)
if err != nil {
return "", err
}
linkParts := strings.Split(strings.Trim(path.Clean(link), "/"), "/")
if path.IsAbs(link) {
resolved = root
}
remaining = append(linkParts, remaining...)
}
if resolved != root && !strings.HasPrefix(resolved, root+string(filepath.Separator)) {
return "", fmt.Errorf("%s: escapes the rootfs", name)
}
return resolved, nil
}