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
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user