carlos / platform Public

moves/archive: no hard links, no marker subtree, scoped durability after modes (Task 7 fix round 1)

Build refuses a regular file with more than one name, counted on the
opened file: os.Root constrains names, not inodes, so a link to a file
outside the instance directory would carry that inode to the target.
A .files-gen directory is skipped whole, and checkName refuses the
marker and anything under it.

Extract sets each directory's archived mode through an open descriptor
and fsyncs it, deepest level first, after every file has been fsynced
with its mode; then the staged root, the rename and the parents. No
filesystem-wide flush: fsyncs run on a 16-worker pool on Linux (serial
elsewhere). Every directory must precede its contents in the archive,
so each carries its archived mode.

Split into archive.go (Build), limits.go (shared limits and
validation), verify.go and extract.go. Copy row MV-N86 (pending).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e068c53708ad21ea5cc4eaa8f1d7fe70f5dedfd4 parent 2830956
11 files changed, +1006 −665
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +1 −0
  • internal/moves/archive/archive.go +20 −607
  • internal/moves/archive/archive_test.go +171 −4
  • internal/moves/archive/archive_unix_test.go +30 −0
  • internal/moves/archive/extract.go +421 −0
  • internal/moves/archive/limits.go +163 −0
  • internal/moves/archive/open_other.go +6 −0
  • internal/moves/archive/open_unix.go +12 −1
  • internal/moves/archive/sync_linux.go +4 −34
  • internal/moves/archive/sync_other.go +4 −19
  • internal/moves/archive/verify.go +174 −0
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
index 720f0a441..496fd6a82 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -125,6 +125,7 @@ Columns: id, file, text, status.
| MV-N83 | internal/moves/readiness.go | T11: the target's report claims <n> probe attempts and its measurements admit <n> | pending |
| MV-N84 | internal/moves/readiness.go | the target's readiness report is dated ahead of this clock | pending |
| MV-N85 | internal/moves/readiness.go | the source's readiness report is dated ahead of this clock | pending |
+| MV-N86 | internal/moves/archive/archive.go | archive: only regular files and directories move: <path> is a hard link | pending |
## Plan 2
diff --git a/internal/moves/archive/archive.go b/internal/moves/archive/archive.go
index 9ccc0754e..f31ae6aad 100644
--- a/internal/moves/archive/archive.go
+++ b/internal/moves/archive/archive.go
@@ -13,7 +13,6 @@ package archive
import (
"archive/tar"
- "crypto/ecdh"
"crypto/sha256"
"encoding/hex"
"errors"
@@ -22,68 +21,19 @@ import (
"io"
"io/fs"
"os"
- "path"
"path/filepath"
- "runtime"
"sort"
- "strings"
"github.com/klauspost/compress/zstd"
-
- "github.com/carlosframework/platform/internal/appconfig"
-)
-
-// Cap is the archive's content cap (M5): the sum of the regular files'
-// sizes, <host>.env included. Tar headers and padding are not content and
-// do not count against it (F25); they are bounded by MaxEntries and
-// MaxPathLen instead.
-const Cap int64 = 256 << 20
-
-// MaxEntries bounds the number of tar entries (files, directories and env)
-// in one archive, and MaxPathLen the length of a path inside the files
-// directory. With Cap they bound the decoded stream (MaxDecoded), so a zstd
-// bomb is bounded on the target even after it passed Verify.
-const (
- MaxEntries = 1 << 17
- MaxPathLen = 4096
)
-// entryOverhead is the most tar bytes one entry costs beyond its content:
-// a 512-byte header, a PAX header (512 bytes plus the path record, at most
-// len("files/")+MaxPathLen+1 bytes and a length prefix, rounded up to 4608)
-// when the path does not fit the USTAR fields, and under 512 bytes of
-// padding. That is at most 6143; 8 KiB leaves room. Owners, times and
-// sizes under Cap always fit USTAR, so no other PAX record is written.
-// TestTheLongestPathFitsTheEntryOverhead checks the arithmetic.
-const entryOverhead = 8 << 10
-
-// MaxDecoded is the most bytes a decoded archive (the tar stream) may hold:
-// content, per-entry overhead and the two-block end marker.
-const MaxDecoded int64 = Cap + MaxEntries*entryOverhead + 2*512
-
// ManifestVersion is the Manifest.V this package writes and reads.
const ManifestVersion = 1
-// MarkerName is the protocol marker inside the files directory, never
-// archived or digested (§5.1, §6.1 F5), and refused if an archive carries
-// it: an extracted marker would claim an installation that never happened.
-const MarkerName = ".files-gen"
-
-// EnvEntry is <host>.env's path inside the archive; FilesPrefix is the
-// files directory's namespace. The two never overlap.
-const (
- EnvEntry = "env"
- FilesPrefix = "files/"
-)
-
-// zstdWindow is the compression window, and the largest window a decoder
-// accepts: a hostile frame cannot make the target allocate more.
-const zstdWindow = 8 << 20
-
// Errors from Build (ErrSpecialFile, ErrOverCap, ErrTooManyEntries,
// ErrChanged and the long-path ErrBadPath) reach the operator through the
-// source report's S1; their texts are rows MV-N77 to MV-N81 of the copy
-// record. The rest are the target's refusals.
+// source report's S1; their texts are rows MV-N77 to MV-N81 and MV-N86 of
+// the copy record. The rest are the target's refusals.
var (
ErrSpecialFile = errors.New("archive: only regular files and directories move")
ErrOverCap = errors.New("archive: the files directory is over 256 MiB")
@@ -94,11 +44,6 @@ var (
ErrNotEmpty = errors.New("archive: the destination files directory is not empty")
)
-// errVerifyAborted is what Verify closes its pipe with when it stops
-// reading before the end, so Open's next write fails and its goroutine
-// exits instead of blocking on the pipe for the rest of the archive.
-var errVerifyAborted = errors.New("archive: verify stopped reading")
-
// The limits as enforced. Variables only so tests can lower them; nothing
// else writes them.
var (
@@ -158,7 +103,15 @@ func walk(root *os.Root, envPath string) ([]item, error) {
if err != nil {
return err
}
- if p == "." || p == MarkerName {
+ if p == "." {
+ return nil
+ }
+ if p == MarkerName {
+ // The marker is never archived, nor anything under it if
+ // it is (wrongly) a directory.
+ if d.IsDir() {
+ return fs.SkipDir
+ }
return nil
}
info, err := root.Lstat(p)
@@ -201,7 +154,7 @@ var openItem = openItemChecked
// openItemChecked opens it.rel inside the files root (or the env path) and
// requires the opened file to be the one listed: a regular file with the
-// listed identity. A file swapped after the walk (for a FIFO, another file,
+// listed identity and one name. A file swapped after the walk (for a FIFO, another file,
// or a symlink the root would refuse to leave by) fails here. The open is
// non-blocking where the platform has the flag, so a swapped-in FIFO
// returns at once instead of waiting for a writer.
@@ -225,6 +178,14 @@ func openItemChecked(it item) (io.ReadCloser, error) {
f.Close()
return nil, fmt.Errorf("%w: %s", ErrChanged, displayName(it))
}
+ // Phase 1 does not carry hard links. os.Root constrains names, not
+ // inodes: a pre-existing link to a file outside the instance directory
+ // is a regular file with the listed identity, and reading it would
+ // carry the outside inode to the target. Counted on the opened file.
+ if linkCount(got) > 1 {
+ f.Close()
+ return nil, fmt.Errorf("%w: %s is a hard link", ErrSpecialFile, displayName(it))
+ }
return f, nil
}
@@ -413,551 +374,3 @@ func PlainDigest(filesDir, envPath string) (plain string, bytes int64, entries i
}
return b.plain, b.content, len(b.entries), nil
}
-
-// checkName validates one archive path (a directory's without its
-// trailing slash) and says which namespace it is in. It is the one rule
-// Verify, Extract and the manifest check share.
-func checkName(name string, dir bool) (rel string, isEnv bool, err error) {
- bad := func(why string) error { return fmt.Errorf("%w: %q %s", ErrBadPath, name, why) }
- switch {
- case name == "":
- return "", false, bad("is empty")
- case len(name) > len(FilesPrefix)+MaxPathLen:
- return "", false, bad("is too long")
- case strings.ContainsRune(name, 0):
- return "", false, bad("holds a NUL")
- case path.IsAbs(name):
- return "", false, bad("is absolute")
- case path.Clean(name) != name:
- return "", false, bad("is not clean")
- }
- for _, c := range strings.Split(name, "/") {
- if c == ".." || c == "." || c == "" {
- return "", false, bad("leaves its directory")
- }
- }
- if name == EnvEntry {
- if dir {
- return "", false, bad("is the env file but not a regular file")
- }
- return "", true, nil
- }
- rel, ok := strings.CutPrefix(name, FilesPrefix)
- if !ok || rel == "" {
- return "", false, bad("is outside files/ and env")
- }
- if rel == MarkerName {
- return "", false, bad("is the protocol marker")
- }
- // The platform's own rules on top (Windows reserved names and drive
- // letters; a backslash is a separator there and a file name here).
- if !filepath.IsLocal(filepath.FromSlash(rel)) || (filepath.Separator != '/' && strings.ContainsRune(rel, filepath.Separator)) {
- return "", false, bad("is not a local path on this platform")
- }
- return rel, false, nil
-}
-
-// headerName validates a tar header's name and type: regular files and
-// directories only.
-func headerName(hdr *tar.Header) (name, rel string, isEnv bool, err error) {
- switch hdr.Typeflag {
- case tar.TypeReg, tar.TypeDir:
- default:
- return "", "", false, fmt.Errorf("%w: %q", ErrSpecialFile, hdr.Name)
- }
- if hdr.Size < 0 || (hdr.Typeflag == tar.TypeDir && hdr.Size != 0) {
- return "", "", false, fmt.Errorf("%w: %q has size %d", ErrBadPath, hdr.Name, hdr.Size)
- }
- name = hdr.Name
- if hdr.Typeflag == tar.TypeDir {
- name = strings.TrimSuffix(name, "/")
- }
- rel, isEnv, err = checkName(name, hdr.Typeflag == tar.TypeDir)
- return name, rel, isEnv, err
-}
-
-// boundedReader stops a decoded stream at its limit: past it, one more
-// byte is ErrTooLarge, so a stream exactly at the limit still ends cleanly.
-type boundedReader struct {
- r io.Reader
- left int64
-}
-
-func (b *boundedReader) Read(p []byte) (int, error) {
- if b.left <= 0 {
- var one [1]byte
- n, err := b.r.Read(one[:])
- if n > 0 {
- return 0, ErrTooLarge
- }
- return 0, err
- }
- if int64(len(p)) > b.left {
- p = p[:b.left]
- }
- n, err := b.r.Read(p)
- b.left -= int64(n)
- return n, err
-}
-
-func newDecoder(r io.Reader) (*zstd.Decoder, error) {
- return zstd.NewReader(r, zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxWindow(zstdWindow))
-}
-
-// requireEOF reads the raw stream once more after the decoder finished. A
-// stream fed by Open through a pipe reports Open's terminal verdict here
-// (the final chunk's authentication); trailing bytes are refused.
-func requireEOF(r io.Reader) error {
- var one [1]byte
- n, err := io.ReadFull(r, one[:])
- if n > 0 {
- return fmt.Errorf("%w: data after the archive", ErrBadPath)
- }
- if err == io.EOF {
- return nil
- }
- return err
-}
-
-// check validates a manifest on its own, before any byte of the archive is
-// read: the version and chunk size this package writes, the digests and
-// key present, and an entry list Build could have written (valid, unique
-// paths within the limits, sizes summing to Bytes, at most Cap).
-func (m Manifest) check() error {
- switch {
- case m.V != ManifestVersion:
- return fmt.Errorf("archive: manifest version %d, want %d", m.V, ManifestVersion)
- case m.Chunk != ChunkSize:
- return fmt.Errorf("archive: manifest chunk %d, want %d", m.Chunk, ChunkSize)
- case m.Plain == "" || m.Sealed == "" || m.SealedDataKey == "":
- return errors.New("archive: manifest carries no sealed digest, plaintext digest or sealed data key")
- case len(m.Entries) > entryLimit:
- return ErrTooManyEntries
- }
- seen := make(map[string]bool, len(m.Entries))
- var sum int64
- for _, e := range m.Entries {
- if _, _, err := checkName(e.Path, false); err != nil {
- return err
- }
- if seen[e.Path] {
- return fmt.Errorf("%w: %q named twice in the manifest", ErrBadPath, e.Path)
- }
- seen[e.Path] = true
- if e.Size < 0 || e.Size > Cap-sum {
- return ErrOverCap
- }
- sum += e.Size
- }
- if sum != m.Bytes {
- return fmt.Errorf("archive: manifest entries hold %d bytes, manifest says %d", sum, m.Bytes)
- }
- return nil
-}
-
-// Verify is T5 (§7.3): it opens the sealed object with priv, streams the
-// tar out of zstd and checks everything the manifest promised: the
-// plaintext digest, every entry's path, size and sha256, the entry types
-// (regular files and directories only), the limits, and the sealed digest
-// of the bytes it read. It writes nothing and returns the first mismatch.
-// Open's own verdict (every chunk authenticated, the final chunk present
-// and last) wins over any mismatch found in what it produced.
-func Verify(sealed io.Reader, m Manifest, priv *ecdh.PrivateKey, b appconfig.Binding) error {
- if err := m.check(); err != nil {
- return err
- }
- sealedHash := sha256.New()
- pr, pw := io.Pipe()
- done := make(chan error, 1)
- go func() {
- err := Open(io.TeeReader(sealed, sealedHash), m.SealedDataKey, priv, b, pw)
- pw.CloseWithError(err)
- done <- err
- }()
- plain, err := verifyStream(pr, m)
- // Break the pipe whatever happened, so a producer still writing fails
- // and exits, then wait for it. After a clean read to EOF this is a
- // no-op: Open has already returned.
- pr.CloseWithError(errVerifyAborted)
- if openErr := <-done; openErr != nil && !errors.Is(openErr, errVerifyAborted) {
- return openErr
- }
- if err != nil {
- return err
- }
- if plain != m.Plain {
- return fmt.Errorf("archive: plaintext digest %s, manifest says %s", plain, m.Plain)
- }
- if got := digestString(sealedHash); got != m.Sealed {
- return fmt.Errorf("archive: sealed digest %s, manifest says %s", got, m.Sealed)
- }
- return nil
-}
-
-// verifyStream reads the opened archive from r to its end and checks it
-// against m, returning the plaintext digest. h hashes the decoded tar,
-// end marker included, which is exactly what Build's hashWriter hashed (it
-// sits between tar.Writer and the compressor), so Plain is the same bytes
-// on both sides (round-4 finding 8).
-func verifyStream(r io.Reader, m Manifest) (string, error) {
- zr, err := newDecoder(r)
- if err != nil {
- return "", err
- }
- defer zr.Close()
- h := sha256.New()
- dec := &boundedReader{r: zr, left: decodedLimit}
- tr := tar.NewReader(io.TeeReader(dec, h))
- want := make(map[string]Entry, len(m.Entries))
- for _, e := range m.Entries {
- want[e.Path] = e
- }
- seen := map[string]bool{}
- var content int64
- for count := 0; ; count++ {
- hdr, err := tr.Next()
- if err == io.EOF {
- break
- }
- if err != nil {
- return "", err
- }
- if count >= entryLimit {
- return "", ErrTooManyEntries
- }
- name, _, _, err := headerName(hdr)
- if err != nil {
- return "", err
- }
- if seen[name] {
- return "", fmt.Errorf("%w: %q named twice", ErrBadPath, hdr.Name)
- }
- seen[name] = true
- if hdr.Typeflag == tar.TypeDir {
- continue
- }
- if hdr.Size > Cap-content {
- return "", ErrOverCap
- }
- content += hdr.Size
- e, ok := want[name]
- if !ok {
- return "", fmt.Errorf("archive: entry %q is not in the manifest", hdr.Name)
- }
- if e.Size != hdr.Size {
- return "", fmt.Errorf("archive: entry %q size differs from the manifest", hdr.Name)
- }
- fh := sha256.New()
- if _, err := io.CopyN(fh, tr, hdr.Size); err != nil {
- return "", err
- }
- if digestString(fh) != e.SHA256 {
- return "", fmt.Errorf("archive: entry %q digest differs", hdr.Name)
- }
- delete(want, name)
- }
- // tar.Reader stops at the end-of-archive marker; whatever follows in
- // the decoded stream must still reach h, and the raw stream must then
- // end, so Open's terminal result becomes this read's error. A Verify
- // that returned at the tar marker would trust an archive whose tail
- // was never authenticated.
- if _, err := io.Copy(h, dec); err != nil {
- return "", err
- }
- if err := requireEOF(r); err != nil {
- return "", err
- }
- if len(want) != 0 {
- return "", fmt.Errorf("archive: %d manifest entries missing from the archive", len(want))
- }
- if content != m.Bytes {
- return "", fmt.Errorf("archive: the archive holds %d bytes, manifest says %d", content, m.Bytes)
- }
- return digestString(h), nil
-}
-
-// Extract writes an opened archive into filesDir and envPath, refusing
-// anything a tar could smuggle: absolute paths, "..", the protocol marker,
-// links, devices and FIFOs, two entries naming one destination, more
-// entries than MaxEntries, content over Cap and a decoded stream over
-// MaxDecoded.
-//
-// Nothing reaches the destination unless the whole archive is accepted:
-// entries are written into a private staging directory beside filesDir
-// (and env into a temporary file beside envPath), and moved into place
-// only after the stream has been read to its end. filesDir must be absent
-// or an empty directory (ErrNotEmpty otherwise; a non-empty directory
-// would keep files the archive does not name), and its parent and
-// envPath's parent must exist. envPath is replaced when the archive
-// carries env and removed when it does not, so afterwards PlainDigest of
-// the pair equals the archive's. The files move first and env second; a
-// failure between the two renames leaves the files in place and is
-// returned.
-func Extract(r io.Reader, filesDir, envPath string) error {
- if filesDir == "" {
- return errors.New("archive: no files directory named")
- }
- filesDir = filepath.Clean(filesDir)
- if envPath != "" {
- envPath = filepath.Clean(envPath)
- if rel, err := filepath.Rel(filesDir, envPath); err == nil && filepath.IsLocal(rel) {
- return fmt.Errorf("%w: the env file %s is inside the files directory", ErrBadPath, envPath)
- }
- }
- rootMode, existed, err := checkDestination(filesDir)
- if err != nil {
- return err
- }
- stage, err := os.MkdirTemp(filepath.Dir(filesDir), "."+filepath.Base(filesDir)+".extract-")
- if err != nil {
- return err
- }
- x := &extraction{stage: stage, envPath: envPath}
- defer x.cleanup()
- if x.root, err = os.OpenRoot(stage); err != nil {
- return err
- }
- if err := x.read(r); err != nil {
- return err
- }
- return x.commit(filesDir, rootMode, existed)
-}
-
-// checkDestination requires filesDir to be absent or an empty directory
-// (not a symlink to one) and returns the mode the extracted root takes:
-// the existing directory's, else 0755.
-func checkDestination(filesDir string) (fs.FileMode, bool, error) {
- info, err := os.Lstat(filesDir)
- if errors.Is(err, fs.ErrNotExist) {
- return 0o755, false, nil
- }
- if err != nil {
- return 0, false, err
- }
- if !info.IsDir() {
- return 0, false, fmt.Errorf("%w: the destination %s is not a directory", ErrBadPath, filesDir)
- }
- f, err := os.Open(filesDir)
- if err != nil {
- return 0, false, err
- }
- names, err := f.Readdirnames(1)
- f.Close()
- if len(names) > 0 {
- return 0, false, fmt.Errorf("%w: %s", ErrNotEmpty, filesDir)
- }
- if err != nil && err != io.EOF {
- return 0, false, err
- }
- return info.Mode().Perm(), true, nil
-}
-
-type modeFix struct {
- rel string
- mode fs.FileMode
-}
-
-// extraction is one Extract in progress.
-type extraction struct {
- stage string // the staging directory, removed unless committed
- root *os.Root // stage, for every write
- envPath string
- envTmp string // the staged env file, if the archive carried one
- envMode fs.FileMode
- dirs []modeFix
-}
-
-func (x *extraction) cleanup() {
- if x.root != nil {
- x.root.Close()
- }
- if x.stage != "" {
- removeStage(x.stage)
- }
- if x.envTmp != "" {
- os.Remove(x.envTmp)
- }
-}
-
-// removeStage removes a staging directory whose directories may already
-// carry their final, read-only modes. It holds only what Extract created:
-// regular files and directories.
-func removeStage(stage string) {
- filepath.WalkDir(stage, func(p string, d fs.DirEntry, err error) error {
- if err == nil && d.IsDir() {
- os.Chmod(p, 0o700)
- }
- return nil
- })
- os.RemoveAll(stage)
-}
-
-// read writes every entry into the staging area and reads the stream to
-// its end.
-func (x *extraction) read(r io.Reader) error {
- zr, err := newDecoder(r)
- if err != nil {
- return err
- }
- defer zr.Close()
- dec := &boundedReader{r: zr, left: decodedLimit}
- tr := tar.NewReader(dec)
- seen := map[string]bool{}
- var content int64
- for count := 0; ; count++ {
- hdr, err := tr.Next()
- if err == io.EOF {
- break
- }
- if err != nil {
- return err
- }
- if count >= entryLimit {
- return ErrTooManyEntries
- }
- name, rel, isEnv, err := headerName(hdr)
- if err != nil {
- return err
- }
- if seen[name] {
- return fmt.Errorf("%w: %q named twice", ErrBadPath, hdr.Name)
- }
- seen[name] = true
- mode := fs.FileMode(hdr.Mode).Perm()
- if hdr.Typeflag == tar.TypeDir {
- // Created writable so children can land; the archived mode is
- // set once every entry is in (commit).
- if err := x.root.MkdirAll(rel, 0o700); err != nil {
- return err
- }
- x.dirs = append(x.dirs, modeFix{rel, mode})
- continue
- }
- if hdr.Size > Cap-content {
- return ErrOverCap
- }
- content += hdr.Size
- if isEnv {
- err = x.writeEnv(tr, hdr.Size, mode)
- } else {
- err = x.writeFile(tr, rel, hdr.Size, mode)
- }
- if err != nil {
- return err
- }
- }
- if _, err := io.Copy(io.Discard, dec); err != nil {
- return err
- }
- return requireEOF(r)
-}
-
-func (x *extraction) writeFile(src io.Reader, rel string, size int64, mode fs.FileMode) error {
- if dir := path.Dir(rel); dir != "." {
- if err := x.root.MkdirAll(dir, 0o700); err != nil {
- return err
- }
- }
- // O_EXCL: a name that already exists in the fresh staging tree (on a
- // case-insensitive filesystem, "A" after "a") is refused, not merged.
- f, err := x.root.OpenFile(rel, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
- if err != nil {
- return err
- }
- return finishFile(f, src, size, mode)
-}
-
-func (x *extraction) writeEnv(src io.Reader, size int64, mode fs.FileMode) error {
- if x.envPath == "" {
- return fmt.Errorf("%w: the archive carries env and no env path was given", ErrBadPath)
- }
- f, err := os.CreateTemp(filepath.Dir(x.envPath), "."+filepath.Base(x.envPath)+".extract-")
- if err != nil {
- return err
- }
- x.envTmp, x.envMode = f.Name(), mode
- return finishFile(f, src, size, mode)
-}
-
-// finishFile copies exactly size bytes, sets the archived mode (fchmod, so
-// the umask does not apply; §8.1) and closes. On Linux it does not sync:
-// commit syncs the staged filesystem once before anything moves into
-// place, one flush rather than one per file (the landing runs inside the
-// cut's offline window; per-file fsync took 6 ms a file on ext4).
-func finishFile(f *os.File, src io.Reader, size int64, mode fs.FileMode) error {
- if _, err := io.CopyN(f, src, size); err != nil {
- f.Close()
- return err
- }
- if err := f.Chmod(mode); err != nil {
- f.Close()
- return err
- }
- if syncEachFile {
- if err := f.Sync(); err != nil {
- f.Close()
- return err
- }
- }
- return f.Close()
-}
-
-// commit syncs the staged tree, restores directory modes deepest first (a
-// read-only 0555 directory is reconstructed exactly without blocking its
-// own extraction), then moves the staged tree and env file into place.
-func (x *extraction) commit(filesDir string, rootMode fs.FileMode, existed bool) error {
- if err := x.syncStaged(); err != nil {
- return err
- }
- sort.Slice(x.dirs, func(i, j int) bool { return len(x.dirs[i].rel) > len(x.dirs[j].rel) })
- for _, d := range x.dirs {
- if err := x.root.Chmod(d.rel, d.mode); err != nil {
- return err
- }
- }
- if err := os.Chmod(x.stage, rootMode); err != nil {
- return err
- }
- if existed {
- // os.Remove refuses a directory that is no longer empty.
- if err := os.Remove(filesDir); err != nil {
- return err
- }
- }
- if err := os.Rename(x.stage, filesDir); err != nil {
- return err
- }
- x.stage = ""
- if err := syncDir(filepath.Dir(filesDir)); err != nil {
- return err
- }
- if x.envPath == "" {
- return nil
- }
- if x.envTmp == "" {
- if err := os.Remove(x.envPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
- return err
- }
- } else {
- if err := os.Rename(x.envTmp, x.envPath); err != nil {
- return err
- }
- x.envTmp = ""
- }
- return syncDir(filepath.Dir(x.envPath))
-}
-
-// syncDir makes a rename durable. Windows cannot sync a directory; the
-// platform's boxes are Linux, and this only keeps that build whole.
-func syncDir(dir string) error {
- if runtime.GOOS == "windows" {
- return nil
- }
- d, err := os.Open(dir)
- if err != nil {
- return err
- }
- err = d.Sync()
- d.Close()
- return err
-}
diff --git a/internal/moves/archive/archive_test.go b/internal/moves/archive/archive_test.go
index ff4982d04..fb70123cd 100644
--- a/internal/moves/archive/archive_test.go
+++ b/internal/moves/archive/archive_test.go
@@ -15,6 +15,7 @@ import (
"path/filepath"
"runtime"
"strings"
+ "sync"
"testing"
"time"
@@ -158,9 +159,6 @@ func tarZst(t *testing.T, entries []rawEntry, trailer []byte) []byte {
tw := tar.NewWriter(zw)
for _, e := range entries {
hdr := e.hdr
- if hdr.Mode == 0 {
- hdr.Mode = 0o600
- }
if err := tw.WriteHeader(&hdr); err != nil {
t.Fatal(err)
}
@@ -181,7 +179,7 @@ func tarZst(t *testing.T, entries []rawEntry, trailer []byte) []byte {
}
func reg(name, body string) rawEntry {
- return rawEntry{hdr: tar.Header{Name: name, Typeflag: tar.TypeReg, Size: int64(len(body))}, body: []byte(body)}
+ return rawEntry{hdr: tar.Header{Name: name, Typeflag: tar.TypeReg, Size: int64(len(body)), Mode: 0o600}, body: []byte(body)}
}
func dirEntry(name string) rawEntry {
@@ -254,6 +252,9 @@ func TestExtractRefusesTraversalAndSpecialEntries(t *testing.T) {
"files root": {[]rawEntry{dirEntry("files/")}, ErrBadPath},
"outside": {[]rawEntry{reg("other", "x")}, ErrBadPath},
"marker": {[]rawEntry{reg("files/.files-gen", `{"op":"forged"}`)}, ErrBadPath},
+ "marker dir": {[]rawEntry{dirEntry("files/.files-gen/")}, ErrBadPath},
+ "marker child": {[]rawEntry{reg("files/.files-gen/child", "x")}, ErrBadPath},
+ "before its dir": {[]rawEntry{reg("files/a/b", "x")}, ErrBadPath},
"env as dir": {[]rawEntry{dirEntry("env/")}, ErrBadPath},
"too long": {[]rawEntry{reg("files/"+strings.Repeat("a", MaxPathLen+1), "x")}, ErrBadPath},
"symlink": {[]rawEntry{{hdr: tar.Header{Name: "files/l", Typeflag: tar.TypeSymlink, Linkname: "/etc/passwd"}}}, ErrSpecialFile},
@@ -866,6 +867,7 @@ func TestVerifyRefusesWhatTheManifestDoesNotPromise(t *testing.T) {
"over cap": {overCapArchive(t), nil, ErrOverCap},
"duplicate in tar": {dup, xEntry, ErrBadPath},
"marker in tar": {marker, nil, ErrBadPath},
+ "marker child": {tarZst(t, []rawEntry{reg("files/.files-gen/child", "x")}, nil), nil, ErrBadPath},
}
for name, c := range rawCases {
t.Run(name, func(t *testing.T) {
@@ -1015,3 +1017,168 @@ func TestOpenRequiresTheFinalFlag(t *testing.T) {
}
}
}
+
+// Fix round 1, ruling 2: a directory named .files-gen is skipped whole,
+// children included, so it neither reaches the archive nor blocks the
+// marker file the landing writes; and a manifest cannot name a path under
+// it.
+func TestBuildSkipsAMarkerDirectory(t *testing.T) {
+ dir, env := filesFixture(t)
+ if err := os.Remove(filepath.Join(dir, MarkerName)); err != nil {
+ t.Fatal(err)
+ }
+ before, _, _, err := PlainDigest(dir, env)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(dir, MarkerName, "deeper"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ mustWrite(t, filepath.Join(dir, MarkerName, "child"), []byte("c"), 0o644)
+ mustWrite(t, filepath.Join(dir, MarkerName, "deeper", "x"), []byte("x"), 0o644)
+ plain, entries, _, err := Build(dir, env, io.Discard)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range entries {
+ if strings.Contains(e.Path, MarkerName) {
+ t.Fatalf("marker subtree archived: %s", e.Path)
+ }
+ }
+ if plain != before {
+ t.Fatal("a marker directory changed the plain digest")
+ }
+ m := Manifest{V: ManifestVersion, Plain: "p", Sealed: "s", SealedDataKey: "k", Chunk: ChunkSize, Bytes: 1, Entries: []Entry{{Path: "files/.files-gen/child", Size: 1}}}
+ if err := m.check(); !errors.Is(err, ErrBadPath) {
+ t.Fatalf("manifest naming a marker child: %v", err)
+ }
+}
+
+// Fix round 1, rulings 3 and 4: every extracted file and directory is
+// fsynced, each directory already carrying its archived mode when it is,
+// then the destination's parent (and env's) after the renames. Restrictive
+// modes (a directory without read permission, a file without any) do not
+// stop the sync, and are in place once Extract returns.
+func TestExtractSyncsEverythingAfterRestoringModes(t *testing.T) {
+ raw := tarZst(t, []rawEntry{
+ {hdr: tar.Header{Name: "files/locked/", Typeflag: tar.TypeDir, Mode: 0o300}},
+ {hdr: tar.Header{Name: "files/locked/f", Typeflag: tar.TypeReg, Mode: 0o000, Size: 1}, body: []byte("f")},
+ {hdr: tar.Header{Name: "files/ro/", Typeflag: tar.TypeDir, Mode: 0o555}},
+ {hdr: tar.Header{Name: "files/ro/inner/", Typeflag: tar.TypeDir, Mode: 0o500}},
+ {hdr: tar.Header{Name: "files/ro/inner/g", Typeflag: tar.TypeReg, Mode: 0o400, Size: 1}, body: []byte("g")},
+ {hdr: tar.Header{Name: "env", Typeflag: tar.TypeReg, Mode: 0o600, Size: 2}, body: []byte("E\n")},
+ }, nil)
+ parent, files, env := extractTarget(t)
+ t.Cleanup(func() {
+ for _, d := range []string{"locked", "ro/inner", "ro"} {
+ os.Chmod(filepath.Join(files, d), 0o755)
+ }
+ })
+ type synced struct {
+ mode fs.FileMode
+ dir bool
+ }
+ var got = map[string]synced{}
+ var order []string
+ orig := fsyncFile
+ t.Cleanup(func() { fsyncFile = orig })
+ var mu sync.Mutex
+ fsyncFile = func(f *os.File) error {
+ info, err := f.Stat()
+ if err != nil {
+ return err
+ }
+ mu.Lock()
+ got[f.Name()] = synced{info.Mode().Perm(), info.IsDir()}
+ order = append(order, f.Name())
+ mu.Unlock()
+ return orig(f)
+ }
+ if err := Extract(bytes.NewReader(raw), files, env); err != nil {
+ t.Fatal(err)
+ }
+ find := func(suffix string) synced {
+ t.Helper()
+ for name, s := range got {
+ if strings.HasSuffix(filepath.ToSlash(name), suffix) {
+ return s
+ }
+ }
+ t.Fatalf("%s never synced; synced %v", suffix, order)
+ return synced{}
+ }
+ want := map[string]fs.FileMode{"/locked": 0o300, "/ro": 0o555, "/ro/inner": 0o500, "/locked/f": 0o000, "/ro/inner/g": 0o400}
+ for suffix, mode := range want {
+ if s := find(suffix); s.mode != mode {
+ t.Fatalf("%s synced with mode %o, archived %o", suffix, s.mode, mode)
+ }
+ }
+ // The staged root (its mode is the destination's, 0755 when absent),
+ // the env file, and both parents after the renames.
+ stageRoot := false
+ for name, s := range got {
+ if s.dir && strings.Contains(filepath.Base(name), ".out.extract-") && s.mode == 0o755 {
+ stageRoot = true
+ }
+ }
+ if !stageRoot {
+ t.Fatalf("staged root not synced with its final mode: %v", order)
+ }
+ envStaged := false
+ for name, s := range got {
+ if !s.dir && strings.HasPrefix(filepath.Base(name), ".out.env.extract-") && s.mode == 0o600 {
+ envStaged = true
+ }
+ }
+ if !envStaged {
+ t.Fatalf("staged env not synced: %v", order)
+ }
+ if s, ok := got[parent]; !ok || !s.dir {
+ t.Fatalf("destination parent not synced: %v", order)
+ }
+ if s, ok := got[filepath.Dir(env)]; !ok || !s.dir {
+ t.Fatalf("env parent not synced: %v", order)
+ }
+ if order[len(order)-1] != filepath.Dir(env) {
+ t.Fatalf("env parent is not the last sync: %v", order)
+ }
+ // Modes in place on return.
+ for suffix, mode := range want {
+ info, err := os.Lstat(filepath.Join(files, filepath.FromSlash(strings.TrimPrefix(suffix, "/"))))
+ if err != nil || info.Mode().Perm() != mode {
+ t.Fatalf("%s: %v %v", suffix, info, err)
+ }
+ }
+}
+
+// Ruling 4's measurement: serial against parallel fsync on this machine's
+// disk, for many small files and for one large one. Run by hand:
+// CARLOS_ARCHIVE_FSYNC_TIMING=1 go test -run TestExtractFsyncTiming -v.
+func TestExtractFsyncTiming(t *testing.T) {
+ if os.Getenv("CARLOS_ARCHIVE_FSYNC_TIMING") == "" {
+ t.Skip("set CARLOS_ARCHIVE_FSYNC_TIMING=1 to measure")
+ }
+ small := make([]rawEntry, 0, 3000)
+ for i := 0; i < 3000; i++ {
+ small = append(small, reg("files/s"+hex.EncodeToString([]byte{byte(i >> 8), byte(i)}), "z"))
+ }
+ big := make([]byte, 200<<20)
+ rand.Read(big)
+ fixtures := map[string][]byte{
+ "3000 small files": tarZst(t, small, nil),
+ "one 200 MiB file": tarZst(t, []rawEntry{{hdr: tar.Header{Name: "files/big", Typeflag: tar.TypeReg, Size: int64(len(big))}, body: big}}, nil),
+ }
+ orig := syncWorkers
+ t.Cleanup(func() { syncWorkers = orig })
+ for _, name := range []string{"3000 small files", "one 200 MiB file"} {
+ for _, workers := range []int{1, 16} {
+ syncWorkers = workers
+ _, files, env := extractTarget(t)
+ start := time.Now()
+ if err := Extract(bytes.NewReader(fixtures[name]), files, env); err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("%s, %2d workers: %v", name, workers, time.Since(start).Round(time.Millisecond))
+ }
+ }
+}
diff --git a/internal/moves/archive/archive_unix_test.go b/internal/moves/archive/archive_unix_test.go
index b9f34d68b..4b2d829e9 100644
--- a/internal/moves/archive/archive_unix_test.go
+++ b/internal/moves/archive/archive_unix_test.go
@@ -4,8 +4,10 @@ package archive
import (
"errors"
+ "io"
"os"
"path/filepath"
+ "strings"
"syscall"
"testing"
"time"
@@ -64,3 +66,31 @@ func TestOpenRefusesASwappedEntry(t *testing.T) {
}
rc.Close()
}
+
+// Fix round 1, ruling 1: Phase 1 does not carry hard links. A hard link to
+// a file outside the instance directory is a regular file with the listed
+// identity, and os.Root constrains names, not inodes: without a link-count
+// check Build would read the outside inode. Two links inside the directory
+// are refused too.
+func TestBuildRefusesHardLinks(t *testing.T) {
+ dir, env := filesFixture(t)
+ outside := filepath.Join(t.TempDir(), "host-secret")
+ mustWrite(t, outside, []byte("secret"), 0o600)
+ if err := os.Link(outside, filepath.Join(dir, "hl")); err != nil {
+ t.Skipf("no hard links here: %v", err)
+ }
+ if _, _, _, err := Build(dir, env, io.Discard); !errors.Is(err, ErrSpecialFile) || !strings.Contains(err.Error(), "hl") || !strings.Contains(err.Error(), "hard link") {
+ t.Fatalf("outside hard link: %v", err)
+ }
+ if _, _, _, err := PlainDigest(dir, env); !errors.Is(err, ErrSpecialFile) {
+ t.Fatalf("PlainDigest of an outside hard link: %v", err)
+ }
+
+ dir2, env2 := filesFixture(t)
+ if err := os.Link(filepath.Join(dir2, "a.txt"), filepath.Join(dir2, "sub", "a-again")); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, _, err := Build(dir2, env2, io.Discard); !errors.Is(err, ErrSpecialFile) || !strings.Contains(err.Error(), "hard link") {
+ t.Fatalf("two links inside: %v", err)
+ }
+}
diff --git a/internal/moves/archive/extract.go b/internal/moves/archive/extract.go
new file mode 100644
index 000000000..417a58d3d
--- /dev/null
+++ b/internal/moves/archive/extract.go
@@ -0,0 +1,421 @@
+package archive
+
+import (
+ "archive/tar"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strings"
+ "sync"
+)
+
+// Extract writes an opened archive into filesDir and envPath, refusing
+// anything a tar could smuggle: absolute paths, "..", the protocol marker,
+// links, devices and FIFOs, two entries naming one destination, more
+// entries than MaxEntries, content over Cap and a decoded stream over
+// MaxDecoded. Every directory must appear before anything inside it (Build's
+// sorted order always does), so each one carries its archived mode.
+//
+// Nothing reaches the destination unless the whole archive is accepted:
+// entries are written into a private staging directory beside filesDir
+// (and env into a temporary file beside envPath), made durable, and moved
+// into place only after the stream has been read to its end. filesDir must be absent
+// or an empty directory (ErrNotEmpty otherwise; a non-empty directory
+// would keep files the archive does not name), and its parent and
+// envPath's parent must exist. envPath is replaced when the archive
+// carries env and removed when it does not, so afterwards PlainDigest of
+// the pair equals the archive's. The files move first and env second; a
+// failure between the two renames leaves the files in place and is
+// returned.
+func Extract(r io.Reader, filesDir, envPath string) error {
+ if filesDir == "" {
+ return errors.New("archive: no files directory named")
+ }
+ filesDir = filepath.Clean(filesDir)
+ if envPath != "" {
+ envPath = filepath.Clean(envPath)
+ if rel, err := filepath.Rel(filesDir, envPath); err == nil && filepath.IsLocal(rel) {
+ return fmt.Errorf("%w: the env file %s is inside the files directory", ErrBadPath, envPath)
+ }
+ }
+ rootMode, existed, err := checkDestination(filesDir)
+ if err != nil {
+ return err
+ }
+ stage, err := os.MkdirTemp(filepath.Dir(filesDir), "."+filepath.Base(filesDir)+".extract-")
+ if err != nil {
+ return err
+ }
+ x := &extraction{stage: stage, envPath: envPath, dirSet: map[string]bool{}, files: newSyncPool(syncWorkers)}
+ defer x.cleanup()
+ if x.root, err = os.OpenRoot(stage); err != nil {
+ return err
+ }
+ if err := x.read(r); err != nil {
+ return err
+ }
+ return x.commit(filesDir, rootMode, existed)
+}
+
+// checkDestination requires filesDir to be absent or an empty directory
+// (not a symlink to one) and returns the mode the extracted root takes:
+// the existing directory's, else 0755.
+func checkDestination(filesDir string) (fs.FileMode, bool, error) {
+ info, err := os.Lstat(filesDir)
+ if errors.Is(err, fs.ErrNotExist) {
+ return 0o755, false, nil
+ }
+ if err != nil {
+ return 0, false, err
+ }
+ if !info.IsDir() {
+ return 0, false, fmt.Errorf("%w: the destination %s is not a directory", ErrBadPath, filesDir)
+ }
+ f, err := os.Open(filesDir)
+ if err != nil {
+ return 0, false, err
+ }
+ names, err := f.Readdirnames(1)
+ f.Close()
+ if len(names) > 0 {
+ return 0, false, fmt.Errorf("%w: %s", ErrNotEmpty, filesDir)
+ }
+ if err != nil && err != io.EOF {
+ return 0, false, err
+ }
+ return info.Mode().Perm(), true, nil
+}
+
+type modeFix struct {
+ rel string
+ mode fs.FileMode
+}
+
+// extraction is one Extract in progress.
+type extraction struct {
+ stage string // the staging directory, removed unless committed
+ root *os.Root // stage, for every write
+ envPath string
+ envTmp string // the staged env file, if the archive carried one
+ dirs []modeFix
+ dirSet map[string]bool
+ files *syncPool // fsyncs and closes each staged file as it is written
+}
+
+func (x *extraction) cleanup() {
+ x.files.wait()
+ if x.root != nil {
+ x.root.Close()
+ }
+ if x.stage != "" {
+ removeStage(x.stage)
+ }
+ if x.envTmp != "" {
+ os.Remove(x.envTmp)
+ }
+}
+
+// removeStage removes a staging directory whose directories may already
+// carry their final, restrictive modes. It holds only what Extract
+// created: regular files and directories.
+func removeStage(stage string) {
+ filepath.WalkDir(stage, func(p string, d fs.DirEntry, err error) error {
+ if err == nil && d.IsDir() {
+ os.Chmod(p, 0o700)
+ }
+ return nil
+ })
+ os.RemoveAll(stage)
+}
+
+// read writes every entry into the staging area and reads the stream to
+// its end.
+func (x *extraction) read(r io.Reader) error {
+ zr, err := newDecoder(r)
+ if err != nil {
+ return err
+ }
+ defer zr.Close()
+ dec := &boundedReader{r: zr, left: decodedLimit}
+ tr := tar.NewReader(dec)
+ seen := map[string]bool{}
+ var content int64
+ for count := 0; ; count++ {
+ hdr, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return err
+ }
+ if count >= entryLimit {
+ return ErrTooManyEntries
+ }
+ name, rel, isEnv, err := headerName(hdr)
+ if err != nil {
+ return err
+ }
+ if seen[name] {
+ return fmt.Errorf("%w: %q named twice", ErrBadPath, hdr.Name)
+ }
+ seen[name] = true
+ if !isEnv {
+ if dir := path.Dir(rel); dir != "." && !x.dirSet[dir] {
+ return fmt.Errorf("%w: %q comes before its directory", ErrBadPath, hdr.Name)
+ }
+ }
+ mode := fs.FileMode(hdr.Mode).Perm()
+ if hdr.Typeflag == tar.TypeDir {
+ // Created writable so children can land; the archived mode is
+ // set in commit, before the directory is synced.
+ if err := x.root.Mkdir(rel, 0o700); err != nil {
+ return err
+ }
+ x.dirs = append(x.dirs, modeFix{rel, mode})
+ x.dirSet[rel] = true
+ continue
+ }
+ if hdr.Size > Cap-content {
+ return ErrOverCap
+ }
+ content += hdr.Size
+ if isEnv {
+ err = x.writeEnv(tr, hdr.Size, mode)
+ } else {
+ err = x.writeFile(tr, rel, hdr.Size, mode)
+ }
+ if err != nil {
+ return err
+ }
+ }
+ if _, err := io.Copy(io.Discard, dec); err != nil {
+ return err
+ }
+ return requireEOF(r)
+}
+
+func (x *extraction) writeFile(src io.Reader, rel string, size int64, mode fs.FileMode) error {
+ // O_EXCL: a name that already exists in the fresh staging tree (on a
+ // case-insensitive filesystem, "A" after "a") is refused, not merged.
+ f, err := x.root.OpenFile(rel, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if err != nil {
+ return err
+ }
+ return x.finishFile(f, src, size, mode)
+}
+
+func (x *extraction) writeEnv(src io.Reader, size int64, mode fs.FileMode) error {
+ if x.envPath == "" {
+ return fmt.Errorf("%w: the archive carries env and no env path was given", ErrBadPath)
+ }
+ f, err := os.CreateTemp(filepath.Dir(x.envPath), "."+filepath.Base(x.envPath)+".extract-")
+ if err != nil {
+ return err
+ }
+ x.envTmp = f.Name()
+ return x.finishFile(f, src, size, mode)
+}
+
+// finishFile copies exactly size bytes and sets the archived mode (fchmod,
+// so the umask does not apply; §8.1), then hands the still-open file to
+// the sync pool, which fsyncs and closes it. Syncing the open descriptor
+// means a mode that refuses every open (0000) cannot stop the sync, and
+// the pool overlaps the syncs with the rest of the extraction.
+func (x *extraction) finishFile(f *os.File, src io.Reader, size int64, mode fs.FileMode) error {
+ if _, err := io.CopyN(f, src, size); err != nil {
+ f.Close()
+ return err
+ }
+ if err := f.Chmod(mode); err != nil {
+ f.Close()
+ return err
+ }
+ x.files.submit(func() error { return syncAndClose(f, fsyncFile) })
+ return nil
+}
+
+// commit makes the staged tree durable with its archived modes, then
+// publishes it.
+//
+// Crash ordering. Every staged file is fsynced with its archived mode
+// (finishFile) and every staged directory is fsynced after its archived
+// mode is set, deepest level first, then the staged root with its final
+// mode: all before the rename that publishes the tree. The rename is made
+// durable by fsyncing the destination's parent, then env is published the
+// same way. So a crash at any point leaves either no published tree (a
+// hidden .extract- directory to sweep) or a complete one carrying the
+// archived modes, never a published tree whose directories still have
+// their staging modes. The landing writes its marker only after Extract
+// returns nil.
+//
+// Cost. Scoped fsyncs only, never a filesystem-wide flush: other tenants'
+// dirty data on the box is not this landing's to wait for. They run on
+// syncWorkers workers, since concurrent fsyncs on ext4 and xfs share
+// journal commits. The cut budget prices this time under its Archive and
+// RestoreStaged terms (moves.Estimate).
+func (x *extraction) commit(filesDir string, rootMode fs.FileMode, existed bool) error {
+ if err := x.files.wait(); err != nil {
+ return err
+ }
+ if err := x.settleDirs(); err != nil {
+ return err
+ }
+ if err := settleDir(func() (*os.File, error) { return os.Open(x.stage) }, rootMode); err != nil {
+ return err
+ }
+ if existed {
+ // os.Remove refuses a directory that is no longer empty.
+ if err := os.Remove(filesDir); err != nil {
+ return err
+ }
+ }
+ if err := os.Rename(x.stage, filesDir); err != nil {
+ return err
+ }
+ x.stage = ""
+ if err := syncDir(filepath.Dir(filesDir)); err != nil {
+ return err
+ }
+ if x.envPath == "" {
+ return nil
+ }
+ if x.envTmp == "" {
+ if err := os.Remove(x.envPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return err
+ }
+ } else {
+ if err := os.Rename(x.envTmp, x.envPath); err != nil {
+ return err
+ }
+ x.envTmp = ""
+ }
+ return syncDir(filepath.Dir(x.envPath))
+}
+
+// settleDirs sets every staged directory's archived mode through an open
+// descriptor and fsyncs it, one depth level at a time, deepest first: a
+// directory is opened by a path through its ancestors, which are still
+// 0700 until their own level, so a restrictive mode (0300, 0000) never
+// blocks a deeper directory, and the descriptor syncs whatever the mode.
+func (x *extraction) settleDirs() error {
+ levels := map[int][]modeFix{}
+ for _, d := range x.dirs {
+ depth := strings.Count(d.rel, "/")
+ levels[depth] = append(levels[depth], d)
+ }
+ depths := make([]int, 0, len(levels))
+ for depth := range levels {
+ depths = append(depths, depth)
+ }
+ sort.Sort(sort.Reverse(sort.IntSlice(depths)))
+ for _, depth := range depths {
+ p := newSyncPool(syncWorkers)
+ for _, d := range levels[depth] {
+ p.submit(func() error {
+ return settleDir(func() (*os.File, error) { return x.root.Open(d.rel) }, d.mode)
+ })
+ }
+ if err := p.wait(); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func settleDir(open func() (*os.File, error), mode fs.FileMode) error {
+ f, err := open()
+ if err != nil {
+ return err
+ }
+ if err := f.Chmod(mode); err != nil {
+ f.Close()
+ return err
+ }
+ return syncAndClose(f, syncDirFile)
+}
+
+// fsyncFile is the one fsync every staged file and directory goes
+// through. A variable so a test can see each one and the mode it carried.
+var fsyncFile = func(f *os.File) error { return f.Sync() }
+
+// syncDirFile fsyncs a directory. Windows cannot sync a directory handle;
+// the platform's boxes are Linux, and this only keeps that build whole.
+func syncDirFile(f *os.File) error {
+ if runtime.GOOS == "windows" {
+ return nil
+ }
+ return fsyncFile(f)
+}
+
+func syncAndClose(f *os.File, sync func(*os.File) error) error {
+ err := sync(f)
+ if cerr := f.Close(); err == nil {
+ err = cerr
+ }
+ return err
+}
+
+// syncDir makes a rename in dir durable.
+func syncDir(dir string) error {
+ d, err := os.Open(dir)
+ if err != nil {
+ return err
+ }
+ return syncAndClose(d, syncDirFile)
+}
+
+// syncWorkers is how many fsyncs run at once (defaultSyncWorkers, per
+// platform). A variable so the timing test can compare serial and parallel.
+var syncWorkers = defaultSyncWorkers
+
+// syncPool runs jobs on a bounded set of workers and keeps the first
+// error. At most workers jobs run and workers more wait, so the staged
+// files held open for syncing stay bounded.
+type syncPool struct {
+ jobs chan func() error
+ wg sync.WaitGroup
+ once sync.Once
+ mu sync.Mutex
+ err error
+}
+
+func newSyncPool(workers int) *syncPool {
+ workers = max(workers, 1)
+ p := &syncPool{jobs: make(chan func() error, workers)}
+ p.wg.Add(workers)
+ for range workers {
+ go func() {
+ defer p.wg.Done()
+ for job := range p.jobs {
+ if err := job(); err != nil {
+ p.mu.Lock()
+ if p.err == nil {
+ p.err = err
+ }
+ p.mu.Unlock()
+ }
+ }
+ }()
+ }
+ return p
+}
+
+func (p *syncPool) submit(job func() error) { p.jobs <- job }
+
+// wait closes the pool, waits for every job and returns the first error.
+// Safe to call more than once.
+func (p *syncPool) wait() error {
+ p.once.Do(func() {
+ close(p.jobs)
+ p.wg.Wait()
+ })
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.err
+}
diff --git a/internal/moves/archive/limits.go b/internal/moves/archive/limits.go
new file mode 100644
index 000000000..866068be9
--- /dev/null
+++ b/internal/moves/archive/limits.go
@@ -0,0 +1,163 @@
+package archive
+
+import (
+ "archive/tar"
+ "fmt"
+ "io"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "github.com/klauspost/compress/zstd"
+)
+
+// Cap is the archive's content cap (M5): the sum of the regular files'
+// sizes, <host>.env included. Tar headers and padding are not content and
+// do not count against it (F25); they are bounded by MaxEntries and
+// MaxPathLen instead.
+const Cap int64 = 256 << 20
+
+// MaxEntries bounds the number of tar entries (files, directories and env)
+// in one archive, and MaxPathLen the length of a path inside the files
+// directory. With Cap they bound the decoded stream (MaxDecoded), so a zstd
+// bomb is bounded on the target even after it passed Verify.
+const (
+ MaxEntries = 1 << 17
+ MaxPathLen = 4096
+)
+
+// entryOverhead is the most tar bytes one entry costs beyond its content:
+// a 512-byte header, a PAX header (512 bytes plus the path record, at most
+// len("files/")+MaxPathLen+1 bytes and a length prefix, rounded up to 4608)
+// when the path does not fit the USTAR fields, and under 512 bytes of
+// padding. That is at most 6143; 8 KiB leaves room. Owners, times and
+// sizes under Cap always fit USTAR, so no other PAX record is written.
+// TestTheLongestPathFitsTheEntryOverhead checks the arithmetic.
+const entryOverhead = 8 << 10
+
+// MaxDecoded is the most bytes a decoded archive (the tar stream) may hold:
+// content, per-entry overhead and the two-block end marker.
+const MaxDecoded int64 = Cap + MaxEntries*entryOverhead + 2*512
+
+// MarkerName is the protocol marker inside the files directory, never
+// archived or digested (§5.1, §6.1 F5), and refused if an archive carries
+// it or anything under it: an extracted marker would claim an installation
+// that never happened, and a directory of that name would block the
+// landing's own marker write.
+const MarkerName = ".files-gen"
+
+// EnvEntry is <host>.env's path inside the archive; FilesPrefix is the
+// files directory's namespace. The two never overlap.
+const (
+ EnvEntry = "env"
+ FilesPrefix = "files/"
+)
+
+// zstdWindow is the compression window, and the largest window a decoder
+// accepts: a hostile frame cannot make the target allocate more.
+const zstdWindow = 8 << 20
+
+// checkName validates one archive path (a directory's without its
+// trailing slash) and says which namespace it is in. It is the one rule
+// Verify, Extract and the manifest check share.
+func checkName(name string, dir bool) (rel string, isEnv bool, err error) {
+ bad := func(why string) error { return fmt.Errorf("%w: %q %s", ErrBadPath, name, why) }
+ switch {
+ case name == "":
+ return "", false, bad("is empty")
+ case len(name) > len(FilesPrefix)+MaxPathLen:
+ return "", false, bad("is too long")
+ case strings.ContainsRune(name, 0):
+ return "", false, bad("holds a NUL")
+ case path.IsAbs(name):
+ return "", false, bad("is absolute")
+ case path.Clean(name) != name:
+ return "", false, bad("is not clean")
+ }
+ for _, c := range strings.Split(name, "/") {
+ if c == ".." || c == "." || c == "" {
+ return "", false, bad("leaves its directory")
+ }
+ }
+ if name == EnvEntry {
+ if dir {
+ return "", false, bad("is the env file but not a regular file")
+ }
+ return "", true, nil
+ }
+ rel, ok := strings.CutPrefix(name, FilesPrefix)
+ if !ok || rel == "" {
+ return "", false, bad("is outside files/ and env")
+ }
+ if rel == MarkerName || strings.HasPrefix(rel, MarkerName+"/") {
+ return "", false, bad("is the protocol marker or inside it")
+ }
+ // The platform's own rules on top (Windows reserved names and drive
+ // letters; a backslash is a separator there and a file name here).
+ if !filepath.IsLocal(filepath.FromSlash(rel)) || (filepath.Separator != '/' && strings.ContainsRune(rel, filepath.Separator)) {
+ return "", false, bad("is not a local path on this platform")
+ }
+ return rel, false, nil
+}
+
+// headerName validates a tar header's name and type: regular files and
+// directories only.
+func headerName(hdr *tar.Header) (name, rel string, isEnv bool, err error) {
+ switch hdr.Typeflag {
+ case tar.TypeReg, tar.TypeDir:
+ default:
+ return "", "", false, fmt.Errorf("%w: %q", ErrSpecialFile, hdr.Name)
+ }
+ if hdr.Size < 0 || (hdr.Typeflag == tar.TypeDir && hdr.Size != 0) {
+ return "", "", false, fmt.Errorf("%w: %q has size %d", ErrBadPath, hdr.Name, hdr.Size)
+ }
+ name = hdr.Name
+ if hdr.Typeflag == tar.TypeDir {
+ name = strings.TrimSuffix(name, "/")
+ }
+ rel, isEnv, err = checkName(name, hdr.Typeflag == tar.TypeDir)
+ return name, rel, isEnv, err
+}
+
+// boundedReader stops a decoded stream at its limit: past it, one more
+// byte is ErrTooLarge, so a stream exactly at the limit still ends cleanly.
+type boundedReader struct {
+ r io.Reader
+ left int64
+}
+
+func (b *boundedReader) Read(p []byte) (int, error) {
+ if b.left <= 0 {
+ var one [1]byte
+ n, err := b.r.Read(one[:])
+ if n > 0 {
+ return 0, ErrTooLarge
+ }
+ return 0, err
+ }
+ if int64(len(p)) > b.left {
+ p = p[:b.left]
+ }
+ n, err := b.r.Read(p)
+ b.left -= int64(n)
+ return n, err
+}
+
+func newDecoder(r io.Reader) (*zstd.Decoder, error) {
+ return zstd.NewReader(r, zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxWindow(zstdWindow))
+}
+
+// requireEOF reads the raw stream once more after the decoder finished. A
+// stream fed by Open through a pipe reports Open's terminal verdict here
+// (the final chunk's authentication); trailing bytes are refused.
+func requireEOF(r io.Reader) error {
+ var one [1]byte
+ n, err := io.ReadFull(r, one[:])
+ if n > 0 {
+ return fmt.Errorf("%w: data after the archive", ErrBadPath)
+ }
+ if err == io.EOF {
+ return nil
+ }
+ return err
+}
diff --git a/internal/moves/archive/open_other.go b/internal/moves/archive/open_other.go
index e43f1a616..931694b27 100644
--- a/internal/moves/archive/open_other.go
+++ b/internal/moves/archive/open_other.go
@@ -2,5 +2,11 @@
package archive
+import "io/fs"
+
// openNonblock: no FIFO opens block here.
const openNonblock = 0
+
+// linkCount: Windows' FileInfo carries no link count. The platform's boxes
+// are Linux; this keeps the build whole.
+func linkCount(fs.FileInfo) uint64 { return 1 }
diff --git a/internal/moves/archive/open_unix.go b/internal/moves/archive/open_unix.go
index 2644dc602..8f987263e 100644
--- a/internal/moves/archive/open_unix.go
+++ b/internal/moves/archive/open_unix.go
@@ -2,8 +2,19 @@
package archive
-import "syscall"
+import (
+ "io/fs"
+ "syscall"
+)
// openNonblock keeps an open of a FIFO swapped in after the walk from
// waiting for a writer; a regular file ignores it.
const openNonblock = syscall.O_NONBLOCK
+
+// linkCount is a file's number of names (st_nlink).
+func linkCount(fi fs.FileInfo) uint64 {
+ if st, ok := fi.Sys().(*syscall.Stat_t); ok {
+ return uint64(st.Nlink)
+ }
+ return 1
+}
diff --git a/internal/moves/archive/sync_linux.go b/internal/moves/archive/sync_linux.go
index 5061dd7ad..bc3de6abb 100644
--- a/internal/moves/archive/sync_linux.go
+++ b/internal/moves/archive/sync_linux.go
@@ -2,37 +2,7 @@
package archive
-import (
- "os"
- "path/filepath"
-
- "golang.org/x/sys/unix"
-)
-
-// syncEachFile is false on Linux: syncStaged flushes the whole staged
-// tree with syncfs(2) instead.
-const syncEachFile = false
-
-// syncStaged makes the staged tree and env file durable before they move
-// into place: one syncfs(2) on each filesystem involved. Through the
-// directories, which are still writable by their owner at this point,
-// never through the files, whose archived mode may refuse an open.
-func (x *extraction) syncStaged() error {
- if err := syncfs(x.stage); err != nil {
- return err
- }
- if x.envTmp != "" {
- return syncfs(filepath.Dir(x.envTmp))
- }
- return nil
-}
-
-func syncfs(dir string) error {
- d, err := os.Open(dir)
- if err != nil {
- return err
- }
- err = unix.Syncfs(int(d.Fd()))
- d.Close()
- return err
-}
+// defaultSyncWorkers: Extract's fsyncs run 16 at a time on Linux, where
+// concurrent fsyncs on ext4 and xfs share journal commits (the fix-round-1
+// timings are in the task 7 report).
+const defaultSyncWorkers = 16
diff --git a/internal/moves/archive/sync_other.go b/internal/moves/archive/sync_other.go
index 0ed34041b..b30e1408c 100644
--- a/internal/moves/archive/sync_other.go
+++ b/internal/moves/archive/sync_other.go
@@ -2,22 +2,7 @@
package archive
-import (
- "io/fs"
- "path/filepath"
-)
-
-// syncEachFile is true where there is no syncfs(2): finishFile syncs each
-// file while it is still open (its archived mode may refuse a later open).
-const syncEachFile = true
-
-// syncStaged syncs the staged directories, the files having been synced
-// as they were written.
-func (x *extraction) syncStaged() error {
- return filepath.WalkDir(x.stage, func(p string, d fs.DirEntry, err error) error {
- if err != nil || !d.IsDir() {
- return err
- }
- return syncDir(p)
- })
-}
+// defaultSyncWorkers: one at a time elsewhere. The platform's boxes are
+// Linux; this fallback only keeps darwin and windows builds whole and
+// correct.
+const defaultSyncWorkers = 1
diff --git a/internal/moves/archive/verify.go b/internal/moves/archive/verify.go
new file mode 100644
index 000000000..78bbcba92
--- /dev/null
+++ b/internal/moves/archive/verify.go
@@ -0,0 +1,174 @@
+package archive
+
+import (
+ "archive/tar"
+ "crypto/ecdh"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "io"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+)
+
+// errVerifyAborted is what Verify closes its pipe with when it stops
+// reading before the end, so Open's next write fails and its goroutine
+// exits instead of blocking on the pipe for the rest of the archive.
+var errVerifyAborted = errors.New("archive: verify stopped reading")
+
+// check validates a manifest on its own, before any byte of the archive is
+// read: the version and chunk size this package writes, the digests and
+// key present, and an entry list Build could have written (valid, unique
+// paths within the limits, sizes summing to Bytes, at most Cap).
+func (m Manifest) check() error {
+ switch {
+ case m.V != ManifestVersion:
+ return fmt.Errorf("archive: manifest version %d, want %d", m.V, ManifestVersion)
+ case m.Chunk != ChunkSize:
+ return fmt.Errorf("archive: manifest chunk %d, want %d", m.Chunk, ChunkSize)
+ case m.Plain == "" || m.Sealed == "" || m.SealedDataKey == "":
+ return errors.New("archive: manifest carries no sealed digest, plaintext digest or sealed data key")
+ case len(m.Entries) > entryLimit:
+ return ErrTooManyEntries
+ }
+ seen := make(map[string]bool, len(m.Entries))
+ var sum int64
+ for _, e := range m.Entries {
+ if _, _, err := checkName(e.Path, false); err != nil {
+ return err
+ }
+ if seen[e.Path] {
+ return fmt.Errorf("%w: %q named twice in the manifest", ErrBadPath, e.Path)
+ }
+ seen[e.Path] = true
+ if e.Size < 0 || e.Size > Cap-sum {
+ return ErrOverCap
+ }
+ sum += e.Size
+ }
+ if sum != m.Bytes {
+ return fmt.Errorf("archive: manifest entries hold %d bytes, manifest says %d", sum, m.Bytes)
+ }
+ return nil
+}
+
+// Verify is T5 (§7.3): it opens the sealed object with priv, streams the
+// tar out of zstd and checks everything the manifest promised: the
+// plaintext digest, every entry's path, size and sha256, the entry types
+// (regular files and directories only), the limits, and the sealed digest
+// of the bytes it read. It writes nothing and returns the first mismatch.
+// Open's own verdict (every chunk authenticated, the final chunk present
+// and last) wins over any mismatch found in what it produced.
+func Verify(sealed io.Reader, m Manifest, priv *ecdh.PrivateKey, b appconfig.Binding) error {
+ if err := m.check(); err != nil {
+ return err
+ }
+ sealedHash := sha256.New()
+ pr, pw := io.Pipe()
+ done := make(chan error, 1)
+ go func() {
+ err := Open(io.TeeReader(sealed, sealedHash), m.SealedDataKey, priv, b, pw)
+ pw.CloseWithError(err)
+ done <- err
+ }()
+ plain, err := verifyStream(pr, m)
+ // Break the pipe whatever happened, so a producer still writing fails
+ // and exits, then wait for it. After a clean read to EOF this is a
+ // no-op: Open has already returned.
+ pr.CloseWithError(errVerifyAborted)
+ if openErr := <-done; openErr != nil && !errors.Is(openErr, errVerifyAborted) {
+ return openErr
+ }
+ if err != nil {
+ return err
+ }
+ if plain != m.Plain {
+ return fmt.Errorf("archive: plaintext digest %s, manifest says %s", plain, m.Plain)
+ }
+ if got := digestString(sealedHash); got != m.Sealed {
+ return fmt.Errorf("archive: sealed digest %s, manifest says %s", got, m.Sealed)
+ }
+ return nil
+}
+
+// verifyStream reads the opened archive from r to its end and checks it
+// against m, returning the plaintext digest. h hashes the decoded tar,
+// end marker included, which is exactly what Build's hashWriter hashed (it
+// sits between tar.Writer and the compressor), so Plain is the same bytes
+// on both sides (round-4 finding 8).
+func verifyStream(r io.Reader, m Manifest) (string, error) {
+ zr, err := newDecoder(r)
+ if err != nil {
+ return "", err
+ }
+ defer zr.Close()
+ h := sha256.New()
+ dec := &boundedReader{r: zr, left: decodedLimit}
+ tr := tar.NewReader(io.TeeReader(dec, h))
+ want := make(map[string]Entry, len(m.Entries))
+ for _, e := range m.Entries {
+ want[e.Path] = e
+ }
+ seen := map[string]bool{}
+ var content int64
+ for count := 0; ; count++ {
+ hdr, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return "", err
+ }
+ if count >= entryLimit {
+ return "", ErrTooManyEntries
+ }
+ name, _, _, err := headerName(hdr)
+ if err != nil {
+ return "", err
+ }
+ if seen[name] {
+ return "", fmt.Errorf("%w: %q named twice", ErrBadPath, hdr.Name)
+ }
+ seen[name] = true
+ if hdr.Typeflag == tar.TypeDir {
+ continue
+ }
+ if hdr.Size > Cap-content {
+ return "", ErrOverCap
+ }
+ content += hdr.Size
+ e, ok := want[name]
+ if !ok {
+ return "", fmt.Errorf("archive: entry %q is not in the manifest", hdr.Name)
+ }
+ if e.Size != hdr.Size {
+ return "", fmt.Errorf("archive: entry %q size differs from the manifest", hdr.Name)
+ }
+ fh := sha256.New()
+ if _, err := io.CopyN(fh, tr, hdr.Size); err != nil {
+ return "", err
+ }
+ if digestString(fh) != e.SHA256 {
+ return "", fmt.Errorf("archive: entry %q digest differs", hdr.Name)
+ }
+ delete(want, name)
+ }
+ // tar.Reader stops at the end-of-archive marker; whatever follows in
+ // the decoded stream must still reach h, and the raw stream must then
+ // end, so Open's terminal result becomes this read's error. A Verify
+ // that returned at the tar marker would trust an archive whose tail
+ // was never authenticated.
+ if _, err := io.Copy(h, dec); err != nil {
+ return "", err
+ }
+ if err := requireEOF(r); err != nil {
+ return "", err
+ }
+ if len(want) != 0 {
+ return "", fmt.Errorf("archive: %d manifest entries missing from the archive", len(want))
+ }
+ if content != m.Bytes {
+ return "", fmt.Errorf("archive: the archive holds %d bytes, manifest says %d", content, m.Bytes)
+ }
+ return digestString(h), nil
+}