Files
mesh-catalog/modules/gitea/cmd/forge/patchfile.go
T
mesh-toolsandClaude d4b8b24701
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
Let gitea_commit take a large patch from a hash-checked spool file (issue 852)
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>
2026-10-15 20:39:13 +00:00

110 lines
4.5 KiB
Go

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
}