Lindenii Project Forge
LMTP: Fix patch handling (\r\n, mbox format, double-Wait)
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org> package main import ( "bytes" "context" "encoding/hex" "errors" "os" "os/exec" "path" "sort" "strings" ) func writeTree(ctx context.Context, repoPath string, entries []treeEntry) (string, error) { var buf bytes.Buffer sort.Slice(entries, func(i, j int) bool { nameI, nameJ := entries[i].name, entries[j].name if nameI == nameJ { // meh return !(entries[i].mode == "40000") && (entries[j].mode == "40000") } if strings.HasPrefix(nameJ, nameI) && len(nameI) < len(nameJ) { return !(entries[i].mode == "40000") } if strings.HasPrefix(nameI, nameJ) && len(nameJ) < len(nameI) { return entries[j].mode == "40000" } return nameI < nameJ }) for _, e := range entries { buf.WriteString(e.mode) buf.WriteByte(' ') buf.WriteString(e.name) buf.WriteByte(0) buf.Write(e.sha) } cmd := exec.CommandContext(ctx, "git", "hash-object", "-w", "-t", "tree", "--stdin") cmd.Env = append(os.Environ(), "GIT_DIR="+repoPath) cmd.Stdin = &buf var out bytes.Buffer cmd.Stdout = &out if err := cmd.Run(); err != nil { return "", err }
if err := cmd.Wait(); err != nil { return "", err }
return strings.TrimSpace(out.String()), nil } func buildTreeRecursive(ctx context.Context, repoPath, baseTree string, updates map[string][]byte) (string, error) { treeCache := make(map[string][]treeEntry) var walk func(string, string) error walk = func(prefix, sha string) error { cmd := exec.CommandContext(ctx, "git", "cat-file", "tree", sha) cmd.Env = append(os.Environ(), "GIT_DIR="+repoPath) var out bytes.Buffer cmd.Stdout = &out if err := cmd.Run(); err != nil {
return err } if err := cmd.Wait(); err != nil {
return err } data := out.Bytes() i := 0 var entries []treeEntry for i < len(data) { modeEnd := bytes.IndexByte(data[i:], ' ') if modeEnd < 0 { return errors.New("invalid tree format") } mode := string(data[i : i+modeEnd]) i += modeEnd + 1 nameEnd := bytes.IndexByte(data[i:], 0) if nameEnd < 0 { return errors.New("missing null after filename") } name := string(data[i : i+nameEnd]) i += nameEnd + 1 if i+20 > len(data) { return errors.New("unexpected EOF in SHA") } shaBytes := data[i : i+20] i += 20 entries = append(entries, treeEntry{ mode: mode, name: name, sha: shaBytes, }) if mode == "40000" { subPrefix := path.Join(prefix, name) if err := walk(subPrefix, hex.EncodeToString(shaBytes)); err != nil { return err } } } treeCache[prefix] = entries return nil } if err := walk("", baseTree); err != nil { return "", err } for filePath, blobSha := range updates { parts := strings.Split(filePath, "/") dir := strings.Join(parts[:len(parts)-1], "/") name := parts[len(parts)-1] entries := treeCache[dir] found := false for i, e := range entries { if e.name == name { if blobSha == nil { // Remove TODO entries = append(entries[:i], entries[i+1:]...) } else { entries[i].sha = blobSha } found = true break } } if !found && blobSha != nil { entries = append(entries, treeEntry{ mode: "100644", name: name, sha: blobSha, }) } treeCache[dir] = entries } built := make(map[string][]byte) var build func(string) ([]byte, error) build = func(prefix string) ([]byte, error) { entries := treeCache[prefix] for i, e := range entries { if e.mode == "40000" { subPrefix := path.Join(prefix, e.name) if sha, ok := built[subPrefix]; ok { entries[i].sha = sha continue } newShaStr, err := build(subPrefix) if err != nil { return nil, err } entries[i].sha = newShaStr } } shaStr, err := writeTree(ctx, repoPath, entries) if err != nil { return nil, err } shaBytes, err := hex.DecodeString(shaStr) if err != nil { return nil, err } built[prefix] = shaBytes return shaBytes, nil } rootShaBytes, err := build("") if err != nil { return "", err } return hex.EncodeToString(rootShaBytes), nil } type treeEntry struct { mode string // like "100644" name string // individual name sha []byte }
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org> package main import ( "bytes" "crypto/rand" "encoding/hex"
"fmt" "io"
"os" "os/exec" "strings" "time" "github.com/bluekeyes/go-gitdiff/gitdiff"
"github.com/emersion/go-message"
"github.com/go-git/go-git/v5" )
func lmtpHandlePatch(session *lmtpSession, groupPath []string, repoName string, email *message.Entity) (err error) {
func lmtpHandlePatch(session *lmtpSession, groupPath []string, repoName string, mbox io.Reader) (err error) {
var diffFiles []*gitdiff.File var preamble string
if diffFiles, preamble, err = gitdiff.Parse(email.Body); err != nil { return
if diffFiles, preamble, err = gitdiff.Parse(mbox); err != nil { return fmt.Errorf("failed to parse patch: %w", err)
} var header *gitdiff.PatchHeader if header, err = gitdiff.ParsePatchHeader(preamble); err != nil {
return
return fmt.Errorf("failed to parse patch headers: %w", err)
} var repo *git.Repository var fsPath string repo, _, _, fsPath, err = openRepo(session.ctx, groupPath, repoName) if err != nil {
return
return fmt.Errorf("failed to open repo: %w", err)
} headRef, err := repo.Head() if err != nil {
return
return fmt.Errorf("failed to get repo head hash: %w", err)
} headCommit, err := repo.CommitObject(headRef.Hash()) if err != nil {
return
return fmt.Errorf("failed to get repo head commit: %w", err)
} headTree, err := headCommit.Tree() if err != nil {
return
return fmt.Errorf("failed to get repo head tree: %w", err)
} headTreeHash := headTree.Hash.String() blobUpdates := make(map[string][]byte) for _, diffFile := range diffFiles { sourceFile, err := headTree.File(diffFile.OldName) if err != nil {
return err
return fmt.Errorf("failed to get file at old name %#v: %w", diffFile.OldName, err)
} sourceString, err := sourceFile.Contents() if err != nil {
return err
return fmt.Errorf("failed to get contents: %w", err)
} sourceBuf := bytes.NewReader(stringToBytes(sourceString)) var patchedBuf bytes.Buffer if err := gitdiff.Apply(&patchedBuf, sourceBuf, diffFile); err != nil {
return err
return fmt.Errorf("failed to apply patch: %w", err)
} var hashBuf bytes.Buffer // It's really difficult to do this via go-git so we're just // going to use upstream git for now. // TODO cmd := exec.CommandContext(session.ctx, "git", "hash-object", "-w", "-t", "blob", "--stdin") cmd.Env = append(os.Environ(), "GIT_DIR="+fsPath) cmd.Stdout = &hashBuf cmd.Stdin = &patchedBuf if err := cmd.Run(); err != nil {
return err
return fmt.Errorf("failed to run git hash-object: %w", err)
} newHashStr := strings.TrimSpace(hashBuf.String()) newHash, err := hex.DecodeString(newHashStr) if err != nil {
return err
return fmt.Errorf("failed to decode hex string from git: %w", err)
} blobUpdates[diffFile.NewName] = newHash if diffFile.NewName != diffFile.OldName { blobUpdates[diffFile.OldName] = nil // Mark for deletion. } } newTreeSha, err := buildTreeRecursive(session.ctx, fsPath, headTreeHash, blobUpdates) if err != nil {
return err
return fmt.Errorf("failed to recursively build a tree: %w", err)
} commitMsg := header.Title if header.Body != "" { commitMsg += "\n\n" + header.Body } env := append(os.Environ(), "GIT_DIR="+fsPath, "GIT_AUTHOR_NAME="+header.Author.Name, "GIT_AUTHOR_EMAIL="+header.Author.Email, "GIT_AUTHOR_DATE="+header.AuthorDate.Format(time.RFC3339), ) commitCmd := exec.CommandContext(session.ctx, "git", "commit-tree", newTreeSha, "-p", headCommit.Hash.String(), "-m", commitMsg) commitCmd.Env = env var commitOut bytes.Buffer commitCmd.Stdout = &commitOut if err := commitCmd.Run(); err != nil {
return err
return fmt.Errorf("failed to commit tree: %w", err)
} newCommitSha := strings.TrimSpace(commitOut.String()) newBranchName := rand.Text() refCmd := exec.CommandContext(session.ctx, "git", "update-ref", "refs/heads/contrib/"+newBranchName, newCommitSha) //#nosec G204 refCmd.Env = append(os.Environ(), "GIT_DIR="+fsPath) if err := refCmd.Run(); err != nil {
return err
return fmt.Errorf("failed to update ref: %w", err)
} return nil }
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org> // SPDX-FileCopyrightText: Copyright (c) 2024 Robin Jarry <robin@jarry.cc> package main import ( "bytes" "context" "errors" "fmt" "io" "log/slog" "net" "strings" "time" "github.com/emersion/go-message" "github.com/emersion/go-smtp" ) type lmtpHandler struct{} type lmtpSession struct { from string to []string ctx context.Context cancel context.CancelFunc } func (session *lmtpSession) Reset() { session.from = "" session.to = nil } func (session *lmtpSession) Logout() error { session.cancel() return nil } func (session *lmtpSession) AuthPlain(_, _ string) error { return nil } func (session *lmtpSession) Mail(from string, _ *smtp.MailOptions) error { session.from = from return nil } func (session *lmtpSession) Rcpt(to string, _ *smtp.RcptOptions) error { session.to = append(session.to, to) return nil } func (*lmtpHandler) NewSession(_ *smtp.Conn) (smtp.Session, error) { ctx, cancel := context.WithCancel(context.Background()) session := &lmtpSession{ ctx: ctx, cancel: cancel, } return session, nil } func serveLMTP(listener net.Listener) error { smtpServer := smtp.NewServer(&lmtpHandler{}) smtpServer.LMTP = true smtpServer.Domain = config.LMTP.Domain smtpServer.Addr = config.LMTP.Socket smtpServer.WriteTimeout = time.Duration(config.LMTP.WriteTimeout) * time.Second smtpServer.ReadTimeout = time.Duration(config.LMTP.ReadTimeout) * time.Second smtpServer.EnableSMTPUTF8 = true return smtpServer.Serve(listener) } func (session *lmtpSession) Data(r io.Reader) error { var ( email *message.Entity from string to []string err error buf bytes.Buffer data []byte n int64 ) n, err = io.CopyN(&buf, r, config.LMTP.MaxSize) switch { case n == config.LMTP.MaxSize: err = errors.New("Message too big.") // drain whatever is left in the pipe _, _ = io.Copy(io.Discard, r) goto end case errors.Is(err, io.EOF): // message was smaller than max size break case err != nil: goto end } data = buf.Bytes() email, err = message.Read(bytes.NewReader(data)) if err != nil && message.IsUnknownCharset(err) { goto end } switch strings.ToLower(email.Header.Get("Auto-Submitted")) { case "auto-generated", "auto-replied": // Disregard automatic emails like OOO replies. slog.Info("ignoring automatic message", "from", session.from, "to", strings.Join(session.to, ","), "message-id", email.Header.Get("Message-Id"), "subject", email.Header.Get("Subject"), ) goto end } slog.Info("message received", "from", session.from, "to", strings.Join(session.to, ","), "message-id", email.Header.Get("Message-Id"), "subject", email.Header.Get("Subject"), ) // Make local copies of the values before to ensure the references will // still be valid when the task is run. from = session.from to = session.to _ = from for _, to := range to { if !strings.HasSuffix(to, "@"+config.LMTP.Domain) { continue } localPart := to[:len(to)-len("@"+config.LMTP.Domain)] var segments []string segments, err = pathToSegments(localPart) if err != nil { // TODO: Should the entire email fail or should we just // notify them out of band? err = fmt.Errorf("cannot parse path: %w", err) goto end } sepIndex := -1 for i, part := range segments { if part == "-" { sepIndex = i break } } if segments[len(segments)-1] == "" { segments = segments[:len(segments)-1] // We don't care about dir or not. } if sepIndex == -1 || len(segments) <= sepIndex+2 { err = errors.New("illegal path") goto end }
mbox := bytes.Buffer{} if _, err = fmt.Fprint(&mbox, "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\r\n"); err != nil { slog.Error("error handling patch... malloc???", "error", err) goto end } data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) if _, err = mbox.Write(data); err != nil { slog.Error("error handling patch... malloc???", "error", err) goto end } // TODO: Is mbox's From escaping necessary here?
groupPath := segments[:sepIndex] moduleType := segments[sepIndex+1] moduleName := segments[sepIndex+2] switch moduleType { case "repos":
err = lmtpHandlePatch(session, groupPath, moduleName, email)
err = lmtpHandlePatch(session, groupPath, moduleName, &mbox)
if err != nil { slog.Error("error handling patch", "error", err) goto end } default: err = errors.New("Emailing any endpoint other than repositories, is not supported yet.") // TODO goto end } } end: session.to = nil session.from = "" switch err { case nil: return nil default: return &smtp.SMTPError{
Code: 550, Message: err.Error(),
Code: 550, Message: "Permanent failure: " + err.Error(), EnhancedCode: [3]int{5, 7, 1},
} } }