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

472 lines
13 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 (
"errors"
"fmt"
"io"
"os"
"path"
"strconv"
"strings"
"golang.org/x/sys/unix"
)
// null is the path a unified diff gives the missing side of a created or
// deleted file.
const null = "/dev/null"
// change is one file's worth of a unified diff.
type change struct {
path string
kill bool // the diff deletes the file
hunks []hunk
}
// hunk is one @@ block. Its lines keep their leading ' ', '-' or '+'.
type hunk struct {
old, new int // 1-based first line on each side
lines []string
noEndNew bool // the new side ends without a newline
}
// move is one file's worth of a staged diff: what goes in, what was there,
// and the directory both of them live in.
type move struct {
dirfd int
base string
path string
next string // the staged replacement; empty when the diff deletes the file
prev string // a link to the bytes that were there; empty when there were none
}
// patch applies a unified diff. Every file is verified and staged first, and
// the bytes that were there are kept on a second link, so a diff that does
// not fit changes nothing and a move that fails is put back.
func (d *daemon) patch(r *req) *rep {
changes, err := parseDiff(string(r.data))
if err != nil {
return fail(r, err)
}
if len(changes) == 0 {
return fail(r, errors.New("the diff names no file"))
}
work, err := d.plan(changes)
if err != nil {
return fail(r, err)
}
defer release(work)
if untouched, err := commit(work); err != nil {
reply := fail(r, err)
// A diff that was put back did not happen; one the daemon could not
// put back did, and says so, so a repeat of the id is not run again.
reply.refused = untouched
return reply
}
reply := &rep{size: uint64(len(work))}
for _, m := range work {
reply.entries = append(reply.entries, entry{name: m.path})
}
return reply
}
// plan verifies and stages every change. Nothing in the workspace is moved
// yet: on the way out with an error, every staged file is gone.
func (d *daemon) plan(changes []change) ([]move, error) {
var work []move
seen := make(map[[2]uint64]string, len(changes))
for _, c := range changes {
m, err := d.arrange(c, seen)
if err != nil {
release(work)
return nil, err
}
work = append(work, m)
}
return work, nil
}
// arrange stages one change: the new bytes in a file of their own, and a link
// to the old bytes to go back to. seen holds the files the diff has already
// named, by identity, so two spellings of one file are refused rather than
// applied one over the other.
func (d *daemon) arrange(c change, seen map[[2]uint64]string) (move, error) {
dirfd, base, err := d.root.parent(c.path)
if err != nil {
return move{}, err
}
m := move{dirfd: dirfd, base: base, path: c.path}
kept := false
defer func() {
if !kept {
release([]move{m})
}
}()
var st unix.Stat_t
here := unix.Fstatat(dirfd, base, &st, 0) == nil
switch {
case here && st.Mode&unix.S_IFMT == unix.S_IFDIR:
return move{}, fmt.Errorf("%s: is a directory", c.path)
case here:
if first, ok := seen[[2]uint64{st.Dev, st.Ino}]; ok {
return move{}, fmt.Errorf("the diff names %s and %s, which are one file", first, c.path)
}
seen[[2]uint64{st.Dev, st.Ino}] = c.path
case c.kill:
return move{}, fmt.Errorf("delete %s: %w", c.path, unix.ENOENT)
}
if !c.kill {
old, mode, err := d.current(c.path)
if err != nil {
return move{}, err
}
next, err := apply(c.path, old, c.hunks)
if err != nil {
return move{}, err
}
name, fd, err := d.stage(dirfd, mode)
if err != nil {
return move{}, err
}
m.next = name
if _, err := settle(os.NewFile(uintptr(fd), name), next, mode); err != nil {
return move{}, err
}
}
if here {
prev, err := link(dirfd, base)
if err != nil {
return move{}, fmt.Errorf("keep %s to go back to: %w", c.path, err)
}
m.prev = prev
}
kept = true
return m, nil
}
// link hard-links the file at base beside it under a name nothing else can
// name. The link holds the old bytes whatever happens to base.
func link(dirfd int, base string) (string, error) {
for try := 0; try < 4; try++ {
name, err := scratch()
if err != nil {
return "", err
}
err = unix.Linkat(dirfd, base, dirfd, name, 0)
if err == nil {
return name, nil
}
if !errors.Is(err, unix.EEXIST) {
return "", err
}
}
return "", fmt.Errorf("no free name beside %s", base)
}
// commit moves every staged file into place. A move that fails puts back the
// moves before it; the bool says whether the tree is as it was, which is what
// decides whether the action can be asked for again.
func commit(work []move) (bool, error) {
var err error
for i, m := range work {
if m.next == "" {
err = unix.Unlinkat(m.dirfd, m.base, 0)
if err != nil {
err = fmt.Errorf("delete %s: %w", m.path, err)
}
} else if err = unix.Renameat(m.dirfd, m.next, m.dirfd, m.base); err != nil {
err = fmt.Errorf("rename over %s: %w", m.path, err)
}
if err == nil {
continue
}
if back := undo(work[:i]); back != nil {
return false, fmt.Errorf("%w; and %d of %d files are changed: %w", err, i, len(work), back)
}
return true, err
}
return false, nil
}
// undo puts back what commit has moved, newest first: a file that was there is
// restored from its link, and one the diff created is removed.
func undo(work []move) error {
for i := len(work) - 1; i >= 0; i-- {
m := work[i]
if m.prev != "" {
if err := unix.Renameat(m.dirfd, m.prev, m.dirfd, m.base); err != nil {
return fmt.Errorf("put %s back: %w", m.path, err)
}
continue
}
if err := unix.Unlinkat(m.dirfd, m.base, 0); err != nil {
return fmt.Errorf("take %s away again: %w", m.path, err)
}
}
return nil
}
// release lets go of staged work. Before commit it takes the staged files and
// the links away; after commit the staged file is the file and the link is the
// last hold on the bytes it replaced, so the same call is what finishes a
// delete and frees what a write replaced.
func release(work []move) {
for _, m := range work {
if m.next != "" {
unix.Unlinkat(m.dirfd, m.next, 0)
}
if m.prev != "" {
unix.Unlinkat(m.dirfd, m.prev, 0)
}
unix.Close(m.dirfd)
}
}
// current reads the file a change applies to. A file the diff creates is
// absent, and that is not an error.
func (d *daemon) current(name string) ([]byte, uint32, error) {
f, err := d.root.file(name, unix.O_RDONLY, 0)
if err != nil {
if errors.Is(err, unix.ENOENT) {
return nil, fileMode, nil
}
return nil, 0, err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return nil, 0, err
}
data, err := io.ReadAll(f)
if err != nil {
return nil, 0, err
}
return data, uint32(st.Mode().Perm()), nil
}
// parseDiff reads a unified diff. Anything it does not understand is an
// error: a patch is never applied on a guess.
func parseDiff(text string) ([]change, error) {
lines := strings.Split(text, "\n")
var out []change
var cur *change
var h *hunk
named := make(map[string]string)
for i := 0; i < len(lines); i++ {
line := lines[i]
switch {
case strings.HasPrefix(line, "--- "):
if i+1 >= len(lines) || !strings.HasPrefix(lines[i+1], "+++ ") {
return nil, fmt.Errorf("line %d: --- without +++", i+1)
}
name, kill, err := sides(line[4:], lines[i+1][4:])
i++
if err != nil {
return nil, fmt.Errorf("line %d: %w", i+1, err)
}
if first, ok := named[path.Clean(name)]; ok {
return nil, fmt.Errorf("line %d: the diff already changes %s", i+1, first)
}
named[path.Clean(name)] = name
out = append(out, change{path: name, kill: kill})
cur, h = &out[len(out)-1], nil
case strings.HasPrefix(line, "@@"):
if cur == nil {
return nil, fmt.Errorf("line %d: a hunk before any file", i+1)
}
head, err := parseHunk(line)
if err != nil {
return nil, fmt.Errorf("line %d: %w", i+1, err)
}
cur.hunks = append(cur.hunks, head)
h = &cur.hunks[len(cur.hunks)-1]
case h != nil && line == `\ No newline at end of file`:
if n := len(h.lines); n > 0 && h.lines[n-1][0] != '-' {
h.noEndNew = true
}
case h != nil && line != "" && (line[0] == ' ' || line[0] == '+' || line[0] == '-'):
h.lines = append(h.lines, line)
case line == "":
// A context line is written as a single space, so an empty line
// inside a hunk is a diff this daemon will not guess at.
if h != nil && i != len(lines)-1 {
return nil, fmt.Errorf("line %d: an empty line inside a hunk", i+1)
}
case strings.HasPrefix(line, "rename ") || strings.HasPrefix(line, "copy "):
return nil, fmt.Errorf("line %d: %q moves a file, which a diff of hunks cannot say; send the delete and the create", i+1, strings.TrimSpace(line))
case strings.HasPrefix(line, "diff ") || strings.HasPrefix(line, "index ") ||
strings.HasPrefix(line, "old mode") || strings.HasPrefix(line, "new mode") ||
strings.HasPrefix(line, "similarity") ||
strings.HasPrefix(line, "new file") || strings.HasPrefix(line, "deleted file") ||
strings.HasPrefix(line, "Binary files"):
h = nil
default:
return nil, fmt.Errorf("line %d: %q is neither a header nor a hunk line", i+1, line)
}
}
return out, nil
}
// sides settles the file a diff section changes. git writes the path behind
// a/ on the --- line and behind b/ on the +++ line; a diff written with no
// prefixes writes the path itself on both. The reading that makes the two
// lines agree is the path, and a pair that agrees under neither reading is
// refused instead of guessed at.
func sides(from, to string) (string, bool, error) {
from, to = column(from), column(to)
stripped := [2]string{strings.TrimPrefix(from, "a/"), strings.TrimPrefix(to, "b/")}
switch {
case from == "" || to == "":
return "", false, errors.New("the file has no name")
case from == null && to == null:
return "", false, errors.New("both sides are /dev/null")
case to == null:
return stripped[0], true, nil
case from == null:
return stripped[1], false, nil
case stripped[0] == stripped[1]:
return stripped[1], false, nil
case from == to:
return to, false, nil
}
return "", false, fmt.Errorf("the diff changes %s on one side and %s on the other", from, to)
}
// column drops the timestamp a diff may write in a second column after the
// path.
func column(s string) string {
if i := strings.IndexByte(s, '\t'); i >= 0 {
s = s[:i]
}
return strings.TrimSpace(s)
}
// parseHunk reads "@@ -old,count +new,count @@".
func parseHunk(line string) (hunk, error) {
body := line[2:]
end := strings.Index(body, "@@")
if end < 0 {
return hunk{}, fmt.Errorf("hunk header does not close")
}
fields := strings.Fields(body[:end])
if len(fields) != 2 || !strings.HasPrefix(fields[0], "-") || !strings.HasPrefix(fields[1], "+") {
return hunk{}, fmt.Errorf("hunk header %q", strings.TrimSpace(body[:end]))
}
old, err := firstNum(fields[0][1:])
if err != nil {
return hunk{}, err
}
next, err := firstNum(fields[1][1:])
if err != nil {
return hunk{}, err
}
return hunk{old: old, new: next}, nil
}
func firstNum(s string) (int, error) {
if i := strings.Index(s, ","); i >= 0 {
s = s[:i]
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("hunk line number %q", s)
}
return n, nil
}
// apply runs every hunk against the file's current lines. A context or
// removed line that does not match is refused with its number.
func apply(name string, old []byte, hunks []hunk) ([]byte, error) {
lines, endNL := splitLines(old)
var out []string
at := 0 // how far into lines we have copied
for _, h := range hunks {
start := h.old - 1
if h.old == 0 {
start = 0
}
if start < at || start > len(lines) {
return nil, fmt.Errorf("%s: hunk at line %d does not fit a file of %d lines", name, h.old, len(lines))
}
out = append(out, lines[at:start]...)
at = start
for _, l := range h.lines {
switch l[0] {
case ' ', '-':
if at >= len(lines) {
return nil, fmt.Errorf("%s: hunk at line %d runs past the end of the file", name, h.old)
}
if lines[at] != l[1:] {
return nil, fmt.Errorf("%s:%d: the file has %q where the diff expects %q", name, at+1, lines[at], l[1:])
}
at++
if l[0] == ' ' {
out = append(out, l[1:])
}
case '+':
out = append(out, l[1:])
}
}
if h.noEndNew {
endNL = false
} else if at >= len(lines) {
endNL = true
}
}
out = append(out, lines[at:]...)
if len(out) == 0 {
return nil, nil
}
text := strings.Join(out, "\n")
if endNL {
text += "\n"
}
return []byte(text), nil
}
// splitLines cuts a file into lines and reports whether it ended with a
// newline, which the joined result has to reproduce exactly.
func splitLines(b []byte) ([]string, bool) {
if len(b) == 0 {
return nil, false
}
s := string(b)
nl := strings.HasSuffix(s, "\n")
if nl {
s = s[:len(s)-1]
}
return strings.Split(s, "\n"), nl
}