//go:build linux package host import ( "bytes" "io" "os" "path/filepath" "slices" "golang.org/x/sys/unix" "github.com/carlosframework/platform/internal/safefile" ) // floorAllowsPrev is the floor gate's judgement: nil only when the floor // file and the verdict file both open safely and hold the same bytes. // Anything else is an error, and every error means the same thing to the // units as a mismatch: carlos.prev does not run. // // The floor file lives under the agent's /data, so it is reached without // following a symlink anywhere on its path (safefile.OpenDir walks from / // with O_NOFOLLOW) and opened once, leaf O_NOFOLLOW and O_NONBLOCK: a // symlink fails the open, and a FIFO opens at once instead of parking root // until a writer appears, then fails the fstat. The verdict lives in root's // own /opt/carlos, whose directories only root can change, so only its // leaf is opened that way. Both are then fstat-checked on the descriptor // they will be read from (checkFloorGateStat), so nothing can be swapped // between the check and the read. func floorAllowsPrev(floorPath, verdictPath string, floorOwners, verdictOwners []uint32) error { floor, err := readFloorGateFile(floorPath, floorOwners, true) if err != nil { return err } verdict, err := readFloorGateFile(verdictPath, verdictOwners, false) if err != nil { return err } if !bytes.Equal(floor, verdict) { return errFloorGateMismatch } return nil } // readFloorGateFile opens path once and reads it from that descriptor, // refusing anything checkFloorGateStat does. With pinDirs every directory // on the path is opened without following a symlink too. func readFloorGateFile(path string, owners []uint32, pinDirs bool) ([]byte, error) { const flags = unix.O_RDONLY | unix.O_NOFOLLOW | unix.O_NONBLOCK | unix.O_NOCTTY | unix.O_CLOEXEC var fd int var err error if pinDirs { dir, derr := safefile.OpenDir(filepath.Dir(path)) if derr != nil { return nil, derr } fd, err = unix.Openat(int(dir.Fd()), filepath.Base(path), flags, 0) dir.Close() } else { fd, err = unix.Open(path, flags, 0) } if err != nil { return nil, &os.PathError{Op: "open", Path: path, Err: err} } f := os.NewFile(uintptr(fd), path) defer f.Close() var st unix.Stat_t if err := unix.Fstat(fd, &st); err != nil { return nil, &os.PathError{Op: "fstat", Path: path, Err: err} } if err := checkFloorGateStat(floorGateStat{mode: st.Mode, uid: st.Uid, nlink: uint64(st.Nlink), size: st.Size}, owners); err != nil { return nil, floorGateUnsafe(path) } floorGateAfterStat(path) // Read one byte past the limit, so a file that grew after the fstat is // refused rather than cut short into a match. b, err := io.ReadAll(io.LimitReader(f, floorGateMaxBytes+1)) if err != nil { return nil, &os.PathError{Op: "read", Path: path, Err: err} } if len(b) > floorGateMaxBytes { return nil, floorGateUnsafe(path) } return b, nil } // floorGateAfterStat runs between a file's fstat and its read; a test sets // it to grow the file in that window. var floorGateAfterStat = func(path string) {} // floorGateStat is what checkFloorGateStat judges, from an fstat. type floorGateStat struct { mode uint32 uid uint32 nlink uint64 size int64 } // checkFloorGateStat refuses all but a regular file with one link, at most // floorGateMaxBytes long, owned by one of owners. func checkFloorGateStat(st floorGateStat, owners []uint32) error { if st.mode&unix.S_IFMT != unix.S_IFREG || st.nlink != 1 || st.size < 0 || st.size > floorGateMaxBytes || !slices.Contains(owners, st.uid) { return errFloorGateUnsafe } return nil }