Let gitea_commit take a large patch from a hash-checked spool file (issue 852)
mesh/merge-gate head d4b8b2470159 merged into trunk aee97bf8ad42 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head d4b8b2470159 merged into trunk aee97bf8ad42 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered

A feature's diff is megabytes, too large to type into a call. gitea_commit now also takes patch_file, the
random hex name of a file in the module's spool directory /var/lib/mesh/forge-patches (mode 0733, owned by
the machine's account), with patch_sha256. The tool reads only that directory, never through a symlink or a
hard link, refuses a file whose SHA-256 differs, applies it as it applies patch, and removes it.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-10-15 20:39:13 +00:00
co-authored by Claude
parent de6657c846
commit d4b8b24701
6 changed files with 350 additions and 12 deletions
+13 -1
View File
@@ -44,7 +44,7 @@ base64-encoded. The Go bundle `cmd/forge` serves the commit, and the reads an ag
| tool | | what |
|---|---|---|
| `gitea_commit` | a | one commit on a branch of its own, created from `base` (default the default branch) when absent, carrying either `files` (each `{path, content \| content_base64 \| delete: true}`) or `patch` (a unified diff, as `git diff` writes it); answers the commit and its parent |
| `gitea_commit` | a | one commit on a branch of its own, created from `base` (default the default branch) when absent, carrying either `files` (each `{path, content \| content_base64 \| delete: true}`) `patch` (a unified diff, as `git diff` writes it), or `patch_file` with `patch_sha256` (a patch too large to type, see below); answers the commit and its parent |
| `gitea_commits` | r | a ref's history, newest first, optionally only commits touching a path: sha, author, date, subject |
| `gitea_compare` | r | what head holds that base does not: its commits and the files they touch |
| `gitea_pull_reviews` | r | a pull request's reviews — state, the commit reviewed and whether it is the head now, stale, dismissed — with their line comments |
@@ -62,6 +62,18 @@ the forge cannot compile covers its own text. `cmd/forge/rules.go` and `guard.ts
commit. Nothing about merging changes: a merge still needs an independent review at the head and both
required checks green, through `gitea_merge_pull_request`.
**A patch too large to type** (novox/hq issue 852: a feature's diff is megabytes) goes as `patch_file`, with
`patch_sha256`. The caller writes the patch into the module's spool directory `forge-patches`
(`/var/lib/mesh/forge-patches`, mode 0733 and owned by the machine's account, which the tools run as: anyone writes,
nobody lists; the bundle is given its path as `MESH_GITEA_PATCH_DIR`) and names it: `patch_file` is the file's NAME,
32 to 64 characters of `0-9a-f` that the caller chooses at random, never a path. The tool then reads that one file,
applies it exactly as it applies `patch` (same size limit, same answer) and removes it once it has found it, whether
the commit was made or not. A file refused before that (a bad name, a missing hash or spool directory, a name with no
file) stays in the spool. It refuses, sending nothing, a name of another shape, a file that is missing, empty, over
4 MiB, with more than one hard link or not a regular file (a symlink is never followed), and a file whose SHA-256 is not `patch_sha256`: the directory has no sticky bit
(the manifest allows none), so another writer could replace a file, and the hash is how the caller knows the tool read
the bytes it wrote. It reads nothing outside the directory.
**How the commit is made.** `files` become one call to the forge's multi-file contents endpoint: a path the
ref holds is updated, one it does not is created, and an update or delete carries the blob read just
before, so a file someone changed meanwhile is refused by the forge rather than overwritten. The blobs are read at
+29 -7
View File
@@ -35,6 +35,11 @@ type CommitAsk struct {
Message string
Files []FileChange
Patch string // a unified diff, as git diff writes it, instead of Files
// PatchFile is the name of a file in the spool directory holding the patch (novox/hq issue 852), with the
// SHA-256 its caller says it has; commitAsk reads it into Patch.
PatchFile string
PatchSHA256 string
patchFileGiven bool
}
// Committed is what gitea_commit made.
@@ -69,6 +74,11 @@ func readCommitAsk(args map[string]any) (CommitAsk, error) {
a := CommitAsk{Owner: text(args, "owner"), Repo: text(args, "repo"), Branch: text(args, "branch"),
Base: text(args, "base"), Message: strings.TrimSpace(raw(args, "message"))}
a.Patch = raw(args, "patch")
if v, ok := args["patch_file"]; ok && v != nil {
a.patchFileGiven = true
a.PatchFile = raw(args, "patch_file")
}
a.PatchSHA256 = strings.ToLower(strings.TrimSpace(raw(args, "patch_sha256")))
if a.Branch == "" {
return a, refuse("branch is required: the branch the commit goes on, a branch of its own (created from base when absent). Nothing was sent")
}
@@ -111,11 +121,23 @@ func readCommitAsk(args map[string]any) (CommitAsk, error) {
a.Files = append(a.Files, fc)
}
}
sources := 0
for _, g := range []bool{len(a.Files) > 0, strings.TrimSpace(a.Patch) != "", a.patchFileGiven} {
if g {
sources++
}
}
switch {
case len(a.Files) == 0 && strings.TrimSpace(a.Patch) == "":
return a, refuse("give files (each {path, content | content_base64 | delete: true}) or patch (a unified diff, as git diff writes it). Nothing was sent")
case len(a.Files) > 0 && strings.TrimSpace(a.Patch) != "":
return a, refuse("give files or patch, not both: one commit carries one of them. Nothing was sent")
case sources == 0:
return a, refuse("give files (each {path, content | content_base64 | delete: true}), patch (a unified diff, as git diff writes it) or patch_file (the name of a patch in the spool directory). Nothing was sent")
case sources > 1:
return a, refuse("give files, patch or patch_file, not more than one: one commit carries one of them. Nothing was sent")
case a.PatchSHA256 != "" && !a.patchFileGiven:
return a, refuse("patch_sha256 goes with patch_file only. Nothing was sent")
case a.patchFileGiven:
if err := checkPatchFileAsk(a); err != nil {
return a, err
}
case len(a.Files) > maxFiles:
return a, refuse("%d files is more than the %d one commit carries. Nothing was sent", len(a.Files), maxFiles)
case len(a.Patch) > maxPatchBytes:
@@ -134,13 +156,13 @@ func readCommitAsk(args map[string]any) (CommitAsk, error) {
return a, refuse("%s is named twice in files. Nothing was sent", fc.Path)
}
seen[fc.Path] = true
given := 0
have := 0
for _, g := range []bool{fc.Content != nil, fc.ContentBase64 != nil, fc.Delete} {
if g {
given++
have++
}
}
if given != 1 {
if have != 1 {
return a, refuse("files[%d] (%s) needs exactly one of content, content_base64 or delete: true. Nothing was sent", i, fc.Path)
}
if fc.Content != nil && !utf8.ValidString(*fc.Content) {
+7 -3
View File
@@ -55,7 +55,9 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool {
"the branch holds them — or patch, a unified diff as git diff writes it, which the forge applies. " +
"At most 200 files, 4 MiB each and 16 MiB together, or a 4 MiB patch. Answers the commit it made and " +
"the parent the forge made it on, read back from the branch, and says when the branch moved under " +
"it. Replaces git commit and git " +
"it. A patch too large to type goes as patch_file with patch_sha256: the name of a file in the " +
"spool directory (read once, applied as patch is, removed afterwards, never read from anywhere " +
"else). Replaces git commit and git " +
"push of a feature branch. (a)",
Input: map[string]any{
"type": "object",
@@ -79,7 +81,9 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool {
"required": []string{"path"},
},
},
"patch": str("a unified diff of the change, as git diff or git format-patch writes it; give this or files"),
"patch": str("a unified diff of the change, as git diff or git format-patch writes it; give this, patch_file or files"),
"patch_file": str("for a patch too large to type: the NAME (32 to 64 characters of 0-9a-f, chosen at random by the caller, no path) of a file the caller wrote into the forge patch spool directory (the module's forge-patches directory); needs patch_sha256; give this, patch or files"),
"patch_sha256": str("with patch_file: the file's SHA-256, in hex; the commit is refused, and nothing is sent, when the file's differs"),
},
"required": []string{"owner", "repo", "branch", "message"},
},
@@ -88,7 +92,7 @@ func Tools(forge func() (*Forge, error)) []stdio.Tool {
if err != nil {
return nil, err
}
return commit(ctx, f, a)
return commitAsk(ctx, f, os.Getenv(patchDirEnv), a)
}),
},
{
+109
View File
@@ -0,0 +1,109 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"syscall"
)
// A patch too large to type into a call is handed over as a file (novox/hq issue 852). The caller writes it into
// one spool directory the module declares (mode 0733: anyone writes, nobody lists) and names it in patch_file,
// with the file's SHA-256. The tool runs as the machine's account, so it reads nothing but that directory: the
// name is 32 to 64 characters of [0-9a-f], so it holds no separator and no dot; the file must be a regular file
// (never a symlink); and its hash must be the caller's, because without the sticky bit another writer could have
// replaced it. The file is removed once taken, whether the commit was made or not.
const patchDirEnv = "MESH_GITEA_PATCH_DIR"
var (
patchFileName = regexp.MustCompile(`^[0-9a-f]{32,64}$`)
patchSHA = regexp.MustCompile(`^[0-9a-f]{64}$`)
)
// checkPatchFileAsk judges the arguments of a patch_file ask, before any file is looked at.
func checkPatchFileAsk(a CommitAsk) error {
if !patchFileName.MatchString(a.PatchFile) {
return refuse("patch_file %q is not a name: 32 to 64 characters of 0-9a-f, no path (the file's name in the spool directory, chosen at random). Nothing was sent", a.PatchFile)
}
if !patchSHA.MatchString(a.PatchSHA256) {
return refuse("patch_file needs patch_sha256: the file's SHA-256, 64 hex characters. Nothing was sent")
}
return nil
}
// commitAsk makes the commit the ask names; a patch_file is first read from the spool directory into the patch
// and removed afterwards.
func commitAsk(ctx context.Context, f *Forge, spool string, a CommitAsk) (*Committed, error) {
if !a.patchFileGiven {
return commit(ctx, f, a)
}
if err := checkPatchFileAsk(a); err != nil {
return nil, err
}
if spool == "" {
return nil, refuse("patch_file is not available: %s is not set, so there is no spool directory. Nothing was sent", patchDirEnv)
}
path := filepath.Join(spool, a.PatchFile)
defer func() {
// Whatever happened, the file is not left: removing a symlink removes the link, never its target.
if st, err := os.Lstat(path); err == nil && !st.IsDir() {
_ = os.Remove(path)
}
}()
body, err := readSpooled(path, a.PatchFile)
if err != nil {
return nil, err
}
got := sha256.Sum256(body)
if hex.EncodeToString(got[:]) != a.PatchSHA256 {
return nil, refuse("the file %s is not the patch the caller hashed: its SHA-256 is %s, patch_sha256 says %s. Nothing was sent",
a.PatchFile, hex.EncodeToString(got[:]), a.PatchSHA256)
}
a.Patch = string(body)
return commit(ctx, f, a)
}
// readSpooled reads a regular file of at most maxPatchBytes, never through a symlink.
func readSpooled(path, name string) ([]byte, error) {
st, err := os.Lstat(path)
switch {
case errors.Is(err, os.ErrNotExist):
return nil, refuse("patch_file %s: no such file in the spool directory. Nothing was sent", name)
case err != nil:
return nil, refuse("patch_file %s cannot be read: %v. Nothing was sent", name, err)
case !st.Mode().IsRegular():
return nil, refuse("patch_file %s is not a regular file (a symlink or a directory is never followed). Nothing was sent", name)
case st.Size() > maxPatchBytes:
return nil, refuse("patch_file %s is %s, more than the %s one commit carries: split it into several commits. Nothing was sent", name, mib(int(st.Size())), mib(maxPatchBytes))
}
fh, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return nil, refuse("patch_file %s cannot be opened: %v. Nothing was sent", name, err)
}
defer fh.Close()
fst, err := fh.Stat()
if err != nil || !fst.Mode().IsRegular() {
return nil, refuse("patch_file %s is not a regular file. Nothing was sent", name)
}
// A hard link to a file elsewhere is no symlink, but it is not the caller's own file either.
if sys, ok := fst.Sys().(*syscall.Stat_t); !ok || sys.Nlink > 1 {
return nil, refuse("patch_file %s has more than one hard link: it must be a file of its own. Nothing was sent", name)
}
body, err := io.ReadAll(io.LimitReader(fh, maxPatchBytes+1))
switch {
case err != nil:
return nil, refuse("patch_file %s cannot be read: %v. Nothing was sent", name, err)
case len(body) > maxPatchBytes:
return nil, refuse("patch_file %s is more than the %s one commit carries: split it into several commits. Nothing was sent", name, mib(maxPatchBytes))
case strings.TrimSpace(string(body)) == "":
return nil, refuse("patch_file %s is empty. Nothing was sent", name)
}
return body, nil
}
+183
View File
@@ -0,0 +1,183 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
// novox/hq issue 852: a patch too large to type into a call is handed over as a file in one spool directory.
const spoolName = "0123456789abcdef0123456789abcdef"
const spoolPatch = "diff --git a/README.md b/README.md\n--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-hello\n+hi\n"
func sum(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
func spoolWith(t *testing.T, name, content string) string {
t.Helper()
dir := t.TempDir()
if name != "" {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
return dir
}
func fileAsk(t *testing.T, name, sha string) CommitAsk {
t.Helper()
return ask(t, map[string]any{"branch": "fix/9", "patch_file": name, "patch_sha256": sha})
}
func TestAPatchFileIsAppliedAsThePatchWouldBeAndTheFileIsGone(t *testing.T) {
f := newFake(t)
dir := spoolWith(t, spoolName, spoolPatch)
got, err := commitAsk(context.Background(), f.serve(), dir, fileAsk(t, spoolName, sum(spoolPatch)))
if err != nil {
t.Fatal(err)
}
if len(f.patches) != 1 || f.patches[0]["content"] != spoolPatch || f.patches[0]["new_branch"] != "fix/9" || got.Commit != "p1" {
t.Fatalf("%v %+v", f.patches, got)
}
if _, err := os.Lstat(filepath.Join(dir, spoolName)); !os.IsNotExist(err) {
t.Fatalf("the file is still there: %v", err)
}
}
func TestAPatchFileIsRemovedWhenTheForgeRefusesIt(t *testing.T) {
f := newFake(t)
dir := spoolWith(t, spoolName, spoolPatch)
// the default branch is refused after the file was read
a := ask(t, map[string]any{"branch": "main", "patch_file": spoolName, "patch_sha256": sum(spoolPatch)})
if _, err := commitAsk(context.Background(), f.serve(), dir, a); err == nil {
t.Fatal("taken")
}
if _, err := os.Lstat(filepath.Join(dir, spoolName)); !os.IsNotExist(err) {
t.Fatalf("the file is still there: %v", err)
}
}
func TestAPatchFileThatCannotBeTakenIsRefusedBeforeAnythingIsSent(t *testing.T) {
outside := filepath.Join(t.TempDir(), "secret")
if err := os.WriteFile(outside, []byte(spoolPatch), 0o600); err != nil {
t.Fatal(err)
}
type tc struct {
name string
setup func(dir string)
file string
sha string
want string
}
for _, c := range []tc{
{"wrong sha", func(d string) { _ = os.WriteFile(filepath.Join(d, spoolName), []byte(spoolPatch), 0o600) }, spoolName, sum("other"), "sha256"},
{"not found", func(string) {}, spoolName, sum(spoolPatch), "no such file"},
{"symlink", func(d string) { _ = os.Symlink(outside, filepath.Join(d, spoolName)) }, spoolName, sum(spoolPatch), "regular file"},
{"hard link", func(d string) { _ = os.Link(outside, filepath.Join(d, spoolName)) }, spoolName, sum(spoolPatch), "hard link"},
{"directory", func(d string) { _ = os.Mkdir(filepath.Join(d, spoolName), 0o700) }, spoolName, sum(spoolPatch), "regular file"},
{"too large", func(d string) {
_ = os.WriteFile(filepath.Join(d, spoolName), []byte(strings.Repeat("x", maxPatchBytes+1)), 0o600)
}, spoolName, sum(spoolPatch), "MiB"},
{"empty", func(d string) { _ = os.WriteFile(filepath.Join(d, spoolName), nil, 0o600) }, spoolName, sum(""), "empty"},
} {
f := newFake(t)
dir := t.TempDir()
c.setup(dir)
_, err := commitAsk(context.Background(), f.serve(), dir, fileAsk(t, c.file, c.sha))
if err == nil || !strings.Contains(err.Error(), c.want) || !strings.Contains(err.Error(), "Nothing was sent") {
t.Errorf("%s: %v", c.name, err)
}
if len(f.patches)+len(f.writes) != 0 {
t.Errorf("%s: something was sent", c.name)
}
}
if b, err := os.ReadFile(outside); err != nil || string(b) != spoolPatch {
t.Errorf("the file outside the spool was touched: %v", err)
}
}
func TestAPatchFileAskThatCannotBeReadIsRefused(t *testing.T) {
ok := sum(spoolPatch)
for name, args := range map[string]map[string]any{
"no sha": {"patch_file": spoolName},
"sha not hex": {"patch_file": spoolName, "patch_sha256": strings.Repeat("z", 64)},
"sha too short": {"patch_file": spoolName, "patch_sha256": ok[:63]},
"empty name": {"patch_file": "", "patch_sha256": ok, "files": filesArg()},
"dotdot": {"patch_file": "../" + spoolName, "patch_sha256": ok},
"slash": {"patch_file": "a/" + spoolName, "patch_sha256": ok},
"absolute": {"patch_file": "/etc/passwd", "patch_sha256": ok},
"too short": {"patch_file": strings.Repeat("a", 31), "patch_sha256": ok},
"too long": {"patch_file": strings.Repeat("a", 65), "patch_sha256": ok},
"upper case": {"patch_file": strings.ToUpper(spoolName), "patch_sha256": ok},
"not hex": {"patch_file": strings.Repeat("g", 32), "patch_sha256": ok},
"with patch": {"patch_file": spoolName, "patch_sha256": ok, "patch": "diff"},
"with files": {"patch_file": spoolName, "patch_sha256": ok, "files": filesArg(map[string]any{"path": "x", "content": "x"})},
"sha without it": {"patch": "diff", "patch_sha256": ok},
} {
full := map[string]any{"owner": "acme", "repo": "app", "message": "m", "branch": "b"}
for k, v := range args {
full[k] = v
}
_, err := readCommitAsk(full)
if err == nil || !strings.Contains(err.Error(), "Nothing was sent") {
t.Errorf("%s: %v", name, err)
}
}
// 64 characters is taken; so is an upper-case sha.
for _, n := range []string{spoolName, strings.Repeat("ab", 32)} {
if _, err := readCommitAsk(map[string]any{"owner": "acme", "repo": "app", "message": "m", "branch": "b",
"patch_file": n, "patch_sha256": strings.ToUpper(sum(spoolPatch))}); err != nil {
t.Errorf("%s: %v", n, err)
}
}
}
func TestAPatchFileWithoutASpoolDirectoryIsRefused(t *testing.T) {
f := newFake(t)
_, err := commitAsk(context.Background(), f.serve(), "", fileAsk(t, spoolName, sum(spoolPatch)))
if err == nil || !strings.Contains(err.Error(), "MESH_GITEA_PATCH_DIR") || !strings.Contains(err.Error(), "Nothing was sent") {
t.Fatalf("%v", err)
}
}
func TestTheManifestDeclaresTheSpoolDirectoryAndGivesItToTheBundle(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []struct {
ID, Type, Path, Mode, Owner string
} `json:"resources"`
Build struct {
Artifacts []struct {
From string `json:"from"`
Env map[string]string `json:"env"`
} `json:"artifacts"`
} `json:"build"`
}
if err := jsonInto(raw, &m); err != nil {
t.Fatal(err)
}
found := false
for _, r := range m.Resources {
if r.ID == "forge-patches" {
found = true
if r.Type != "directory" || r.Mode != "0733" || r.Owner != "${machine:account}" || r.Path == "" {
t.Errorf("%+v", r)
}
}
}
if !found {
t.Error("no forge-patches directory")
}
for _, a := range m.Build.Artifacts {
if a.From == "cmd/forge" && a.Env["MESH_GITEA_PATCH_DIR"] != "${dir:forge-patches}" {
t.Errorf("env %v", a.Env)
}
}
}
+9 -1
View File
@@ -184,6 +184,13 @@
"trusted": true,
"content": "# The accounts whose \"ready at <sha>\" comment marks a pull request ready (novox/hq issue 722), from the setting\n# ready-authors, written by the mesh. Replaced on every push; change the setting, never this file.\nauthors=${setting:ready-authors}\n"
},
{
"id": "forge-patches",
"type": "directory",
"path": "/var/lib/mesh/forge-patches",
"mode": "0733",
"owner": "${machine:account}"
},
{
"id": "state",
"type": "directory",
@@ -347,7 +354,8 @@
"env": {
"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}",
"MESH_GITEA_TOOLS_USER": "mesh-tools",
"MESH_GITEA_TOKEN_FILE": "${dir:runtime-state}/token"
"MESH_GITEA_TOKEN_FILE": "${dir:runtime-state}/token",
"MESH_GITEA_PATCH_DIR": "${dir:forge-patches}"
}
}
]