Files
zeekayandHanzo Dev 4855f06fd1
CI / build (push) Failing after 33s
Go / generate (push) Failing after 46s
fix(execd): confine git, make patch all-or-nothing, answer only what happened
The git ceiling named the workspace, which git only applies to a proper
ancestor of the directory it starts from, so a request with no dir found,
read and wrote a repository above the workspace. The ceiling is now the
workspace's parent, which refuses from the root and from any directory
under it.

patch staged by action id, so two changes naming one file collided and
the second failed with the first's bytes already in place, and a delete
that could not happen left earlier renames behind. Every change now
stages under a random name, the file that was there is kept on a second
link until every move has landed, and a move that fails is put back. A
diff that names one file twice, gives two names for one file, would
delete a directory, or renames is refused.

A zero-length packet is a packet on SOCK_SEQPACKET, and reading it as end
of file ended the daemon for good. POLLRDHUP tells the two apart.

Also: a listing fills a page to what a frame holds instead of promising
4096 entries no frame can carry; write and patch resolve the parent with
openat2 on the path as written, so one string cannot mean two places; a
refused request is not recorded, so its id is free, while an action that
ran is recorded whatever came of it; a staged name is random, so a
workspace file is never unlinked to make room; children start from a
declared environment instead of the daemon's; write reports the mode on
disk and keeps the mode a file had; mode means permission bits in stat as
well as read and list; a read past the end is refused; an oversize
refusal names the op it refused; the ledger is bounded by bytes as well
as count; the diff path is the reading both header lines agree on; the
build constraint sits above the package doc; and BUILD embeds a library,
which is how the repo builds a main package with tests.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-18 17:49:38 -07:00

272 lines
7.4 KiB
Go

// Copyright 2026 The gVisor Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build linux
package main
import (
"crypto/rand"
"errors"
"fmt"
"io"
"os"
"slices"
"strings"
zap "github.com/zap-proto/go"
"golang.org/x/sys/unix"
)
const (
// readMax bounds one read. A larger file is read range by range.
readMax = 96 << 10
// fileMode is what a created file gets when the request says nothing.
fileMode = 0o644
// perm masks a mode down to the permission bits, which is what mode means
// in a request and in every reply that carries one.
perm = 0o777
)
// scratch names a file a request cannot name: the suffix is random, and the
// O_EXCL or the linkat that follows proves the name was free. A file the
// workspace already holds is never in the way of a write.
func scratch() (string, error) {
var n [8]byte
if _, err := rand.Read(n[:]); err != nil {
return "", err
}
return fmt.Sprintf(".execd%x", n), nil
}
// stage creates a file in dirfd under such a name, to be renamed over the one
// the request asked for once it holds the whole of the new contents.
func (d *daemon) stage(dirfd int, mode uint32) (string, int, error) {
for try := 0; try < 4; try++ {
name, err := scratch()
if err != nil {
return "", -1, err
}
fd, err := d.root.openAt(dirfd, name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL, mode)
if err == nil {
return name, fd, nil
}
if !errors.Is(err, unix.EEXIST) {
return "", -1, err
}
}
return "", -1, errors.New("no free name for a staged file")
}
func (d *daemon) readFile(r *req) *rep {
f, err := d.root.file(r.path, unix.O_RDONLY, 0)
if err != nil {
return fail(r, err)
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return fail(r, err)
}
if st.IsDir() {
return fail(r, fmt.Errorf("%s: is a directory", r.path))
}
if r.off < 0 {
return fail(r, fmt.Errorf("offset %d is negative", r.off))
}
if r.off > st.Size() {
return fail(r, fmt.Errorf("offset %d is past the end of a %d byte file", r.off, st.Size()))
}
n := int64(r.length)
if n == 0 || n > st.Size()-r.off {
n = st.Size() - r.off
}
if n > readMax {
return fail(r, fmt.Errorf("%d bytes is over the %d byte read; ask for a range", n, readMax))
}
buf := make([]byte, n)
got, err := f.ReadAt(buf, r.off)
if err != nil && !errors.Is(err, io.EOF) {
return fail(r, err)
}
return &rep{
data: buf[:got],
size: uint64(st.Size()),
mode: uint32(st.Mode().Perm()),
mtime: st.ModTime().UnixNano(),
}
}
// writeFile replaces a file's contents, and does it by rename so a reader
// sees either the old bytes or the new ones.
func (d *daemon) writeFile(r *req) *rep {
mode, err := d.place(r.path, r.data, r.mode)
if err != nil {
return fail(r, err)
}
return &rep{size: uint64(len(r.data)), mode: mode, path: r.path}
}
// place writes data to a staged file beside path and renames it over the top.
// A reader sees the old bytes or the new ones and never a half-written file.
// There is no fsync: the workspace is served by the host, so making the bytes
// durable is the host's job, not a syscall inside the sandbox.
//
// mode zero keeps the mode the file has, and is fileMode for a file that is
// not there yet. The mode is set rather than left to the umask, and the mode
// the file ends up with is returned, so a reply never names a mode that is
// not on disk.
func (d *daemon) place(name string, data []byte, mode uint32) (uint32, error) {
dirfd, base, err := d.root.parent(name)
if err != nil {
return 0, err
}
defer unix.Close(dirfd)
mode &= perm
if mode == 0 {
mode = fileMode
var st unix.Stat_t
if err := unix.Fstatat(dirfd, base, &st, 0); err == nil {
mode = st.Mode & perm
}
}
tmp, fd, err := d.stage(dirfd, mode)
if err != nil {
return 0, err
}
f := os.NewFile(uintptr(fd), tmp)
got, err := settle(f, data, mode)
if err != nil {
unix.Unlinkat(dirfd, tmp, 0)
return 0, err
}
if err := unix.Renameat(dirfd, tmp, dirfd, base); err != nil {
unix.Unlinkat(dirfd, tmp, 0)
return 0, fmt.Errorf("rename over %s: %w", name, err)
}
return got, nil
}
// settle fills a staged file, sets its mode past the umask and closes it. It
// reports the mode the file has, which is what the kernel kept of the one
// asked for.
func settle(f *os.File, data []byte, mode uint32) (uint32, error) {
var st unix.Stat_t
_, err := f.Write(data)
if err == nil {
err = unix.Fchmod(int(f.Fd()), mode)
}
if err == nil {
err = unix.Fstat(int(f.Fd()), &st)
}
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
return 0, err
}
return st.Mode & perm, nil
}
func (d *daemon) statPath(r *req) *rep {
fd, err := d.root.open(r.path, unix.O_PATH, 0)
if err != nil {
return fail(r, err)
}
defer unix.Close(fd)
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil {
return fail(r, err)
}
reply := &rep{
size: uint64(st.Size),
mode: st.Mode & perm,
mtime: st.Mtim.Nano(),
path: r.path,
}
if st.Mode&unix.S_IFMT == unix.S_IFDIR {
reply.flags |= flagDir
}
return reply
}
// listDir answers with the entries from off that fit in one frame. size is
// the whole directory's count, so a host pages by advancing off by the number
// of entries it got.
func (d *daemon) listDir(r *req) *rep {
f, err := d.root.file(r.path, unix.O_RDONLY|unix.O_DIRECTORY, 0)
if err != nil {
return fail(r, err)
}
defer f.Close()
all, err := f.ReadDir(-1)
if err != nil {
return fail(r, err)
}
// Directory order is whatever the filesystem hands back, so sort: a page
// is only meaningful against a stable order.
slices.SortFunc(all, func(a, b os.DirEntry) int { return strings.Compare(a.Name(), b.Name()) })
if r.off < 0 || r.off > int64(len(all)) {
return fail(r, fmt.Errorf("index %d is outside a directory of %d entries", r.off, len(all)))
}
want := len(all)
if r.length > 0 && int(r.length) < want {
want = int(r.length)
}
reply := &rep{size: uint64(len(all)), path: r.path}
room := listRoom(r.path)
for _, de := range all[r.off:] {
if len(reply.entries) == want {
break
}
room -= entryCost(de.Name())
if room < 0 {
break
}
e := entry{name: de.Name()}
if de.IsDir() {
e.flags |= flagDir
}
if info, err := de.Info(); err == nil {
e.size = uint64(info.Size())
e.mode = uint32(info.Mode().Perm())
e.mtime = info.ModTime().UnixNano()
}
reply.entries = append(reply.entries, e)
}
if len(reply.entries) == 0 && r.off < int64(len(all)) {
return fail(r, fmt.Errorf("%s: the name at index %d does not fit a frame", r.path, r.off))
}
return reply
}
// listRoom is what a listing's entries may take: one frame, less the reply's
// own header, fixed section, path and the padding between them.
func listRoom(path string) int {
return frameMax - (zap.HeaderSize + repRepSize + len(path) + 2*zap.Alignment)
}
// entryCost is what one entry adds to a listing: a length, and the entry's own
// message — a ZAP header, the fixed section and the name.
func entryCost(name string) int {
return 4 + zap.HeaderSize + entTotal + len(name)
}