Lindenii Project Forge
Login

server

Lindenii Forge’s main backend daemon
Commit info
ID
e93df9b5bc32df01eb90e32b7f0bb1001cf18c53
Author
Runxi Yu <me@runxiyu.org>
Author date
Wed, 02 Apr 2025 15:06:15 +0800
Committer
Runxi Yu <me@runxiyu.org>
Committer date
Wed, 02 Apr 2025 15:06:15 +0800
Actions
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},
		}
	}
}