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 }