//go:build linux package host import ( "errors" "fmt" "os" "os/user" "path/filepath" "strconv" "syscall" "time" "github.com/carlosframework/platform/internal/safefile" "golang.org/x/sys/unix" ) // movesDir is /.moves, opened with O_NOFOLLOW below a data dir // root trusts, so nothing root reads or writes through it can be a // symlink the sandboxed agent planted. Every operation on the floor files // goes through one: the lock and the writes are all relative to its fd. type movesDir struct { fd int path string lock *os.File lockName string } // floorLockFile serialises every writer of the floor files (root's apply, // the agent's refresh) with every reader whose answer must not change under // it (root's judgement of carlos.prev and its activation guard). const floorLockFile = "floor.lock" const floorLockPoll = 100 * time.Millisecond // openMovesDir opens /.moves. With create it makes the directory // when missing; without, a missing directory is os.ErrNotExist. // // Crash ordering (fix round 2): with create, the parent (root) is fsynced // EVERY time, after .moves is known to exist, whoever made it. A writer // never relies on having created the directory itself: another writer may // have made it and not yet synced the parent, or failed to. Only then is // any file written inside, each renamed into place and followed by an fsync // of .moves. So a floor write that returned nil survives a power loss, // directory entry and all. A failed fsync is the open's error, which the // writers report as a floor that could not be written (errFloorWrite, which // defers an apply) and the activation guard as a lock it could not take. // The cost is one fsync of the data dir per write. func openMovesDir(root string, create bool) (*movesDir, error) { name := movesDirName rootDir, err := safefile.OpenDir(root) if err != nil { return nil, fmt.Errorf("open %s: %w", root, err) } defer rootDir.Close() rfd := int(rootDir.Fd()) flags := syscall.O_RDONLY | syscall.O_DIRECTORY | syscall.O_NOFOLLOW | syscall.O_CLOEXEC fd, err := syscall.Openat(rfd, name, flags, 0) if errors.Is(err, syscall.ENOENT) && create { if err := syscall.Mkdirat(rfd, name, 0o755); err != nil && !errors.Is(err, syscall.EEXIST) { return nil, fmt.Errorf("mkdir %s: %w", filepath.Join(root, name), err) } if fd, err = syscall.Openat(rfd, name, flags, 0); err == nil { fchownToService(fd) } } if err != nil { return nil, fmt.Errorf("open %s: %w", filepath.Join(root, name), err) } if create { if err := syncParent(rfd); err != nil { syscall.Close(fd) return nil, fmt.Errorf("fsync %s: %w", root, err) } } return &movesDir{fd: fd, path: filepath.Join(root, name)}, nil } // syncParent fsyncs the data dir. A variable so a test can count the calls. var syncParent = func(fd int) error { return syscall.Fsync(fd) } // Close releases the lock (closing its fd) and the directory. func (d *movesDir) Close() { if d.lock != nil { d.lock.Close() d.lock = nil } syscall.Close(d.fd) } // Lock takes floor.lock, waiting up to floorLockWait. func (d *movesDir) Lock() error { return d.lockNamed(floorLockFile, floorLockWait, errFloorLockBusy) } // lockNamed takes an exclusive flock on the file name in the directory, // waiting up to wait and then failing with busy. Opened read-only (a // read-only fd can be flocked), so the agent can lock a file root created // and root a file the agent created; O_NOFOLLOW and O_NONBLOCK so a symlink // or a FIFO planted in its place cannot redirect or park root. The fd is // kept until Close (or unlockRemoving), which releases the lock. One lock // per movesDir. // // A holder may remove the file it holds before releasing it // (unlockRemoving, the held-stage lock). A waiter that opened that file // would then lock a file no longer at the name, beside whoever has locked // the new one there. So once it has the flock it checks that the name still // names the file it locked (fstat of the fd against an fstatat of the name) // and, when the name is gone or names another file, closes it and starts // again on whatever is there now. The same removal can land inside the // open itself (the name vanishes, or the file opened has no link left); // that is retried too, while a symlink, a FIFO or a second link at the name // is still refused. Each retry pauses (lockNamedRetryPause, capped at what // is left of the wait), and none starts once the wait has passed: the call // fails with busy then, an open race included, and holds nothing. // // The deadline bounds the wait; correctness does not depend on it. A flock // taken just after the deadline (the check and the syscall are not one // step) is still a valid, exclusive lock, and is kept. func (d *movesDir) lockNamed(name string, wait time.Duration, busy error) error { deadline := time.Now().Add(wait) timedOut := func() error { return fmt.Errorf("lock %s: %w", filepath.Join(d.path, name), busy) } for { if time.Now().After(deadline) { return timedOut() } f, err := lockNamedOpenAt(d.fd, name, os.O_RDONLY|os.O_CREATE, 0o644) if err != nil { if !d.removedDuringOpen(name, err) { return fmt.Errorf("open %s: %w", filepath.Join(d.path, name), err) } // A holder's removal landed inside the open: start again. lockNamedPause(deadline) continue } lockNamedOpened(name) if err := flockWithin(f, deadline); err != nil { f.Close() if errors.Is(err, syscall.EWOULDBLOCK) { return timedOut() } return fmt.Errorf("lock %s: %w", filepath.Join(d.path, name), err) } same, err := d.names(name, f) if err != nil { f.Close() return fmt.Errorf("lock %s: %w", filepath.Join(d.path, name), err) } if same { d.lock, d.lockName = f, name return nil } // The holder before us removed this file as it released it. Start // again, within the wait: the publication waits inside the // registry's freeze, so a name that keeps changing ends in busy. f.Close() lockNamedPause(deadline) } } // lockNamedPause sleeps lockNamedRetryPause, or what is left of the wait // when that is less. func lockNamedPause(deadline time.Time) { if left := time.Until(deadline); left > 0 { time.Sleep(min(lockNamedRetryPause, left)) } } // flockWithin takes an exclusive flock on f, polling until deadline, and // once the deadline has passed stops trying and returns EWOULDBLOCK. A // flock that lands just after the deadline is kept: it is a valid, // exclusive lock, and the deadline only bounds the wait. func flockWithin(f *os.File, deadline time.Time) error { for { if time.Now().After(deadline) { return syscall.EWOULDBLOCK } err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) if err == nil || !errors.Is(err, syscall.EWOULDBLOCK) { return err } time.Sleep(floorLockPoll) } } // writeHook, when set, is called with each file name WriteDurable is about // to write: a test seam for the order the floor files are written in. var writeHook func(name string) // names reports whether name in the directory is still the file f has // open: a regular file at the name with f's device and inode. A name that // is gone is false, not an error. func (d *movesDir) names(name string, f *os.File) (bool, error) { var held, now unix.Stat_t if err := unix.Fstat(int(f.Fd()), &held); err != nil { return false, err } err := unix.Fstatat(d.fd, name, &now, unix.AT_SYMLINK_NOFOLLOW) if errors.Is(err, unix.ENOENT) { return false, nil } if err != nil { return false, err } return now.Dev == held.Dev && now.Ino == held.Ino, nil } // removedDuringOpen reports whether err, from opening name, is the race // with a holder removing it: not an errno other than ENOENT (a real refusal, // such as EACCES or ELOOP), and the name is now gone or a regular file with // one link (a fresh lock file). Anything else at the name keeps the open's // refusal, and so does a .moves that has itself been removed (no create // can succeed in it, so waiting out the lock's wait would only delay the // error). func (d *movesDir) removedDuringOpen(name string, err error) bool { var errno syscall.Errno if errors.As(err, &errno) && errno != syscall.ENOENT { return false } var st unix.Stat_t if derr := unix.Fstat(d.fd, &st); derr != nil || st.Nlink == 0 { return false } serr := unix.Fstatat(d.fd, name, &st, unix.AT_SYMLINK_NOFOLLOW) if errors.Is(serr, unix.ENOENT) { return true } return serr == nil && st.Mode&unix.S_IFMT == unix.S_IFREG && st.Nlink == 1 } // unlockRemoving removes the lock file while still holding its flock, then // releases the lock (closing its fd). The name is removed only while it // still names the file held, so another holder's file is never taken. A // waiter on the removed file finds the name gone after its flock and starts // again (lockNamed), so two holders never exist at once. The lock is // released whatever the removal does; an error is the removal's. func (d *movesDir) unlockRemoving() error { if d.lock == nil { return nil } f, name := d.lock, d.lockName d.lock, d.lockName = nil, "" defer f.Close() same, err := d.names(name, f) if err != nil || !same { return err } if err := unix.Unlinkat(d.fd, name, 0); err != nil && !errors.Is(err, unix.ENOENT) { return fmt.Errorf("remove %s: %w", filepath.Join(d.path, name), err) } return nil } // lockNamedRetryPause is the pause between lockNamed's retries after a // holder's removal, capped at what is left of the wait. const lockNamedRetryPause = 10 * time.Millisecond // lockNamedOpenAt opens the lock file for lockNamed. A test seam: a test // can make every open meet the race with a holder's removal. var lockNamedOpenAt = safefile.OpenFileAt // lockNamedOpened runs in lockNamed after each open of the lock file and // before its flock. A test seam: the window in which the file a waiter has // open can be removed by the holder before it and replaced by a new one. var lockNamedOpened = func(name string) {} // WriteDurable writes data to .moves/name the way writeDurable does (a // temp file, fsync, rename, fsync of the directory), all relative to the // directory's fd. The temp name is random, so two writers never share one. func (d *movesDir) WriteDurable(name string, data []byte, mode os.FileMode) error { if writeHook != nil { writeHook(name) } return safefile.WriteAt(d.fd, name, data, mode) } // fchownToService hands a directory root just created to the service user, // best-effort: a process that is not root, or a box with no such user (a // test), keeps the owner it has. func fchownToService(fd int) { if os.Geteuid() != 0 { return } u, err := user.Lookup(serviceUser) if err != nil { return } uid, uerr := strconv.Atoi(u.Uid) gid, gerr := strconv.Atoi(u.Gid) if uerr != nil || gerr != nil { return } syscall.Fchown(fd, uid, gid) } // floorLockCannotExist reports whether err, from opening or locking the // floor lock, means the lock cannot exist on this box at all: .moves or // floor.lock cannot be created (a read-only, full or unwritable data dir). // Then no writer can arm a floor either, which is what makes the F15 // fallback safe. Contention (errFloorLockBusy) is never this: a writer is // active. func floorLockCannotExist(err error) bool { if errors.Is(err, errFloorLockBusy) { return false } for _, e := range []syscall.Errno{syscall.EROFS, syscall.ENOSPC, syscall.EACCES, syscall.EPERM, syscall.EDQUOT} { if errors.Is(err, e) { return true } } return false }