Lindenii Project Forge
Login

server

Lindenii Forge’s main backend daemon
Commit info
ID
afcf2c711b907514a91e5c4d45d2da7f118bb2b8
Author
Runxi Yu <me@runxiyu.org>
Author date
Sun, 17 Aug 2025 13:52:55 +0800
Committer
Runxi Yu <me@runxiyu.org>
Committer date
Sun, 17 Aug 2025 13:52:55 +0800
Actions
Lint ipc
version: "2"

linters:
  default: all
  disable:
    - depguard
    - wsl_v5 # tmp
    - wsl # tmp
    - unused # tmp
    - nonamedreturns
    - err113 # tmp
    - gochecknoinits # tmp
    - nlreturn # tmp
    - cyclop # tmp
    - gocognit # tmp
    - varnamelen # tmp
    - funlen # tmp
    - lll
    - mnd # tmp
    - revive # tmp
    - godox # tmp
    - nestif # tmp

linters-settings:
  revive:
    rules:
      - name: error-strings
        disabled: true

issues:
  max-issues-per-linter: 0
  max-same-issues: 0
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package git2c

import (
	"context"
	"fmt"
	"net"

	"go.lindenii.runxiyu.org/forge/forged/internal/common/bare"
)

// Client represents a connection to the git2d backend daemon.
type Client struct {
	socketPath string
	conn       net.Conn
	writer     *bare.Writer
	reader     *bare.Reader
}

// NewClient establishes a connection to a git2d socket and returns a new Client.
func NewClient(socketPath string) (*Client, error) {
	conn, err := net.Dial("unix", socketPath)
func NewClient(ctx context.Context, socketPath string) (*Client, error) {
	dialer := &net.Dialer{} //exhaustruct:ignore
	conn, err := dialer.DialContext(ctx, "unix", socketPath)
	if err != nil {
		return nil, fmt.Errorf("git2d connection failed: %w", err)
	}

	writer := bare.NewWriter(conn)
	reader := bare.NewReader(conn)

	return &Client{
		socketPath: socketPath,
		conn:       conn,
		writer:     writer,
		reader:     reader,
	}, nil
}

// Close terminates the underlying socket connection.
func (c *Client) Close() error {
func (c *Client) Close() (err error) {
	if c.conn != nil {
		return c.conn.Close()
		err = c.conn.Close()
		if err != nil {
			return fmt.Errorf("close underlying socket: %w", err)
		}
	}
	return nil
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package git2c

import (
	"encoding/hex"
	"errors"
	"fmt"
	"io"
)

// CmdIndex requests a repository index from git2d and returns the list of commits
// and the contents of a README file if available.
func (c *Client) CmdIndex(repoPath string) ([]Commit, *FilenameContents, error) {
	if err := c.writer.WriteData([]byte(repoPath)); err != nil {
	err := c.writer.WriteData([]byte(repoPath))
	if err != nil {
		return nil, nil, fmt.Errorf("sending repo path failed: %w", err)
	}
	if err := c.writer.WriteUint(1); err != nil {
	err = c.writer.WriteUint(1)
	if err != nil {
		return nil, nil, fmt.Errorf("sending command failed: %w", err)
	}

	status, err := c.reader.ReadUint()
	if err != nil {
		return nil, nil, fmt.Errorf("reading status failed: %w", err)
	}
	if status != 0 {
		return nil, nil, fmt.Errorf("git2d error: %d", status)
	}

	// README
	readmeRaw, err := c.reader.ReadData()
	if err != nil {
		readmeRaw = nil
	}

	readmeFilename := "README.md" // TODO
	readme := &FilenameContents{Filename: readmeFilename, Content: readmeRaw}

	// Commits
	var commits []Commit
	for {
		id, err := c.reader.ReadData()
		if err != nil {
			if errors.Is(err, io.EOF) {
				break
			}
			return nil, nil, fmt.Errorf("reading commit ID failed: %w", err)
		}
		title, _ := c.reader.ReadData()
		authorName, _ := c.reader.ReadData()
		authorEmail, _ := c.reader.ReadData()
		authorDate, _ := c.reader.ReadData()

		commits = append(commits, Commit{
			Hash:    hex.EncodeToString(id),
			Author:  string(authorName),
			Email:   string(authorEmail),
			Date:    string(authorDate),
			Message: string(title),
		})
	}

	return commits, readme, nil
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package git2c

import (
	"errors"
	"fmt"
	"io"
)

// CmdTreeRaw queries git2d for a tree or blob object at the given path within the repository.
// It returns either a directory listing or the contents of a file.
func (c *Client) CmdTreeRaw(repoPath, pathSpec string) ([]TreeEntry, string, error) {
	if err := c.writer.WriteData([]byte(repoPath)); err != nil {
	err := c.writer.WriteData([]byte(repoPath))
	if err != nil {
		return nil, "", fmt.Errorf("sending repo path failed: %w", err)
	}
	if err := c.writer.WriteUint(2); err != nil {
	err = c.writer.WriteUint(2)
	if err != nil {
		return nil, "", fmt.Errorf("sending command failed: %w", err)
	}
	if err := c.writer.WriteData([]byte(pathSpec)); err != nil {
	err = c.writer.WriteData([]byte(pathSpec))
	if err != nil {
		return nil, "", fmt.Errorf("sending path failed: %w", err)
	}

	status, err := c.reader.ReadUint()
	if err != nil {
		return nil, "", fmt.Errorf("reading status failed: %w", err)
	}

	switch status {
	case 0:
		kind, err := c.reader.ReadUint()
		if err != nil {
			return nil, "", fmt.Errorf("reading object kind failed: %w", err)
		}

		switch kind {
		case 1:
			// Tree
			count, err := c.reader.ReadUint()
			if err != nil {
				return nil, "", fmt.Errorf("reading entry count failed: %w", err)
			}

			var files []TreeEntry
			for range count {
				typeCode, err := c.reader.ReadUint()
				if err != nil {
					return nil, "", fmt.Errorf("error reading entry type: %w", err)
				}
				mode, err := c.reader.ReadUint()
				if err != nil {
					return nil, "", fmt.Errorf("error reading entry mode: %w", err)
				}
				size, err := c.reader.ReadUint()
				if err != nil {
					return nil, "", fmt.Errorf("error reading entry size: %w", err)
				}
				name, err := c.reader.ReadData()
				if err != nil {
					return nil, "", fmt.Errorf("error reading entry name: %w", err)
				}

				files = append(files, TreeEntry{
					Name:      string(name),
					Mode:      fmt.Sprintf("%06o", mode),
					Size:      size,
					IsFile:    typeCode == 2,
					IsSubtree: typeCode == 1,
				})
			}

			return files, "", nil

		case 2:
			// Blob
			content, err := c.reader.ReadData()
			if err != nil && !errors.Is(err, io.EOF) {
				return nil, "", fmt.Errorf("error reading file content: %w", err)
			}

			return nil, string(content), nil

		default:
			return nil, "", fmt.Errorf("unknown kind: %d", kind)
		}

	case 3:
		return nil, "", fmt.Errorf("path not found: %s", pathSpec)

	default:
		return nil, "", fmt.Errorf("unknown status code: %d", status)
	}
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

// TODO: Make the C part report detailed error messages too

package git2c

import "errors"

var (
	Success            error
	ErrUnknown         = errors.New("git2c: unknown error")
	ErrPath            = errors.New("git2c: get tree entry by path failed")
	ErrRevparse        = errors.New("git2c: revparse failed")
	ErrReadme          = errors.New("git2c: no readme")
	ErrBlobExpected    = errors.New("git2c: blob expected")
	ErrEntryToObject   = errors.New("git2c: tree entry to object conversion failed")
	ErrBlobRawContent  = errors.New("git2c: get blob raw content failed")
	ErrRevwalk         = errors.New("git2c: revwalk failed")
	ErrRevwalkPushHead = errors.New("git2c: revwalk push head failed")
	ErrBareProto       = errors.New("git2c: bare protocol error")
)

func Perror(errno uint) error {
	switch errno {
	case 0:
		return Success
		return nil
	case 3:
		return ErrPath
	case 4:
		return ErrRevparse
	case 5:
		return ErrReadme
	case 6:
		return ErrBlobExpected
	case 7:
		return ErrEntryToObject
	case 8:
		return ErrBlobRawContent
	case 9:
		return ErrRevwalk
	case 10:
		return ErrRevwalkPushHead
	case 11:
		return ErrBareProto
	}
	return ErrUnknown
}
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package irc

import (
	"context"
	"crypto/tls"
	"fmt"
	"log/slog"
	"net"

	"go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
)

// Config contains IRC connection and identity settings for the bot.
// This should usually be a part of the primary config struct.
type Config struct {
	Net   string `scfg:"net"`
	Addr  string `scfg:"addr"`
	TLS   bool   `scfg:"tls"`
	SendQ uint   `scfg:"sendq"`
	Nick  string `scfg:"nick"`
	User  string `scfg:"user"`
	Gecos string `scfg:"gecos"`
}

// Bot represents an IRC bot client that handles events and allows for sending messages.
type Bot struct {
	// TODO: Use each config field instead of embedding Config here.
	config            *Config
	ircSendBuffered   chan string
	ircSendDirectChan chan misc.ErrorBack[string]
}

// NewBot creates a new Bot instance using the provided configuration.
func NewBot(c *Config) (b *Bot) {
	b = &Bot{
		config: c,
	}
	} //exhaustruct:ignore
	return
}

// Connect establishes a new IRC session and starts handling incoming and outgoing messages.
// This method blocks until an error occurs or the connection is closed.
func (b *Bot) Connect() error {
func (b *Bot) Connect(ctx context.Context) error {
	var err error
	var underlyingConn net.Conn
	if b.config.TLS {
		underlyingConn, err = tls.Dial(b.config.Net, b.config.Addr, nil)
		dialer := tls.Dialer{} //exhaustruct:ignore
		underlyingConn, err = dialer.DialContext(ctx, b.config.Net, b.config.Addr)
	} else {
		underlyingConn, err = net.Dial(b.config.Net, b.config.Addr)
		dialer := net.Dialer{} //exhaustruct:ignore
		underlyingConn, err = dialer.DialContext(ctx, b.config.Net, b.config.Addr)
	}
	if err != nil {
		return err
		return fmt.Errorf("dialing irc: %w", err)
	}
	defer underlyingConn.Close()
	defer func() {
		_ = underlyingConn.Close()
	}()

	conn := NewConn(underlyingConn)

	logAndWriteLn := func(s string) (n int, err error) {
		slog.Debug("irc tx", "line", s)
		return conn.WriteString(s + "\r\n")
	}

	_, err = logAndWriteLn("NICK " + b.config.Nick)
	if err != nil {
		return err
	}
	_, err = logAndWriteLn("USER " + b.config.User + " 0 * :" + b.config.Gecos)
	if err != nil {
		return err
	}

	readLoopError := make(chan error)
	writeLoopAbort := make(chan struct{})
	go func() {
		for {
			select {
			case <-writeLoopAbort:
				return
			default:
			}

			msg, line, err := conn.ReadMessage()
			if err != nil {
				readLoopError <- err
				return
			}

			slog.Debug("irc rx", "line", line)

			switch msg.Command {
			case "001":
				_, err = logAndWriteLn("JOIN #chat")
				if err != nil {
					readLoopError <- err
					return
				}
			case "PING":
				_, err = logAndWriteLn("PONG :" + msg.Args[0])
				if err != nil {
					readLoopError <- err
					return
				}
			case "JOIN":
				c, ok := msg.Source.(Client)
				if !ok {
					slog.Error("unable to convert source of JOIN to client")
				}
				if c.Nick != b.config.Nick {
					continue
				}
			default:
			}
		}
	}()

	for {
		select {
		case err = <-readLoopError:
			return err
		case line := <-b.ircSendBuffered:
			_, err = logAndWriteLn(line)
			if err != nil {
				select {
				case b.ircSendBuffered <- line:
				default:
					slog.Error("unable to requeue message", "line", line)
				}
				writeLoopAbort <- struct{}{}
				return err
			}
		case lineErrorBack := <-b.ircSendDirectChan:
			_, err = logAndWriteLn(lineErrorBack.Content)
			lineErrorBack.ErrorChan <- err
			if err != nil {
				writeLoopAbort <- struct{}{}
				return err
			}
		}
	}
}

// SendDirect sends an IRC message directly to the connection and bypasses
// the buffering system.
func (b *Bot) SendDirect(line string) error {
	ech := make(chan error, 1)

	b.ircSendDirectChan <- misc.ErrorBack[string]{
		Content:   line,
		ErrorChan: ech,
	}

	return <-ech
}

// Send queues a message to be sent asynchronously via the buffered send queue.
// If the queue is full, the message is dropped and an error is logged.
func (b *Bot) Send(line string) {
	select {
	case b.ircSendBuffered <- line:
	default:
		slog.Error("irc sendq full", "line", line)
	}
}

// ConnectLoop continuously attempts to maintain an IRC session.
// If the connection drops, it automatically retries with no delay.
func (b *Bot) ConnectLoop() {
func (b *Bot) ConnectLoop(ctx context.Context) {
	b.ircSendBuffered = make(chan string, b.config.SendQ)
	b.ircSendDirectChan = make(chan misc.ErrorBack[string])

	for {
		err := b.Connect()
		err := b.Connect(ctx)
		slog.Error("irc session error", "error", err)
	}
}
package irc

import (
	"bufio"
	"fmt"
	"net"
	"slices"

	"go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
)

type Conn struct {
	netConn   net.Conn
	bufReader *bufio.Reader
}

func NewConn(netConn net.Conn) Conn {
	return Conn{
		netConn:   netConn,
		bufReader: bufio.NewReader(netConn),
	}
}

func (c *Conn) ReadMessage() (msg Message, line string, err error) {
	raw, err := c.bufReader.ReadSlice('\n')
	if err != nil {
		return
	}

	if raw[len(raw)-1] == '\n' {
		raw = raw[:len(raw)-1]
	}
	if raw[len(raw)-1] == '\r' {
		raw = raw[:len(raw)-1]
	}

	lineBytes := slices.Clone(raw)
	line = misc.BytesToString(lineBytes)
	msg, err = Parse(lineBytes)

	return
}

func (c *Conn) Write(p []byte) (n int, err error) {
	return c.netConn.Write(p)
	n, err = c.netConn.Write(p)
	if err != nil {
		err = fmt.Errorf("write to connection: %w", err)
	}
	return n, err
}

func (c *Conn) WriteString(s string) (n int, err error) {
	return c.netConn.Write(misc.StringToBytes(s))
	n, err = c.netConn.Write(misc.StringToBytes(s))
	if err != nil {
		err = fmt.Errorf("write to connection: %w", err)
	}
	return n, err
}
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: Copyright (c) 2018-2024 luk3yx <https://luk3yx.github.io>
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package irc

import (
	"bytes"

	"go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
)

type Message struct {
	Command string
	Source  Source
	Tags    map[string]string
	Args    []string
}

// All strings returned are borrowed from the input byte slice.
func Parse(raw []byte) (msg Message, err error) {
	sp := bytes.Split(raw, []byte{' '}) // TODO: Use bytes.Cut instead here

	if bytes.HasPrefix(sp[0], []byte{'@'}) { // TODO: Check size manually
		if len(sp[0]) < 2 {
			err = ErrMalformedMsg
			return
			return msg, err
		}
		sp[0] = sp[0][1:]

		msg.Tags, err = tagsToMap(sp[0])
		if err != nil {
			return
			return msg, err
		}

		if len(sp) < 2 {
			err = ErrMalformedMsg
			return
			return msg, err
		}
		sp = sp[1:]
	} else {
		msg.Tags = nil // TODO: Is a nil map the correct thing to use here?
	}

	if bytes.HasPrefix(sp[0], []byte{':'}) { // TODO: Check size manually
		if len(sp[0]) < 2 {
			err = ErrMalformedMsg
			return
			return msg, err
		}
		sp[0] = sp[0][1:]

		msg.Source = parseSource(sp[0])

		if len(sp) < 2 {
			err = ErrMalformedMsg
			return
			return msg, err
		}
		sp = sp[1:]
	}

	msg.Command = misc.BytesToString(sp[0])
	if len(sp) < 2 {
		return
		return msg, err
	}
	sp = sp[1:]

	for i := 0; i < len(sp); i++ {
		if len(sp[i]) == 0 {
			continue
		}
		if sp[i][0] == ':' {
			if len(sp[i]) < 2 {
				sp[i] = []byte{}
			} else {
				sp[i] = sp[i][1:]
			}
			msg.Args = append(msg.Args, misc.BytesToString(bytes.Join(sp[i:], []byte{' '})))
			// TODO: Avoid Join by not using sp in the first place
			break
		}
		msg.Args = append(msg.Args, misc.BytesToString(sp[i]))
	}

	return
	return msg, err
}

var ircv3TagEscapes = map[byte]byte{ //nolint:gochecknoglobals
	':': ';',
	's': ' ',
	'r': '\r',
	'n': '\n',
}

func tagsToMap(raw []byte) (tags map[string]string, err error) {
	tags = make(map[string]string)
	for rawTag := range bytes.SplitSeq(raw, []byte{';'}) {
		key, value, found := bytes.Cut(rawTag, []byte{'='})
		if !found {
			err = ErrInvalidIRCv3Tag
			return
			return tags, err
		}
		if len(value) == 0 {
			tags[misc.BytesToString(key)] = ""
		} else {
			if !bytes.Contains(value, []byte{'\\'}) {
				tags[misc.BytesToString(key)] = misc.BytesToString(value)
			} else {
				valueUnescaped := bytes.NewBuffer(make([]byte, 0, len(value)))
				for i := 0; i < len(value); i++ {
					if value[i] == '\\' {
						i++
						byteUnescaped, ok := ircv3TagEscapes[value[i]]
						if !ok {
							byteUnescaped = value[i]
						}
						valueUnescaped.WriteByte(byteUnescaped)
					} else {
						valueUnescaped.WriteByte(value[i])
					}
				}
				tags[misc.BytesToString(key)] = misc.BytesToString(valueUnescaped.Bytes())
			}
		}
	}
	return
	return tags, err
}
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>

package irc

import (
	"bytes"

	"go.lindenii.runxiyu.org/forge/forged/internal/common/misc"
)

type Source interface {
	AsSourceString() string
}

//nolint:ireturn
func parseSource(s []byte) Source {
	nick, userhost, found := bytes.Cut(s, []byte{'!'})
	if !found {
		return Server{name: misc.BytesToString(s)}
	}

	user, host, found := bytes.Cut(userhost, []byte{'@'})
	if !found {
		return Server{name: misc.BytesToString(s)}
	}

	return Client{
		Nick: misc.BytesToString(nick),
		User: misc.BytesToString(user),
		Host: misc.BytesToString(host),
	}
}

type Server struct {
	name string
}

func (s Server) AsSourceString() string {
	return s.name
}

type Client struct {
	Nick string
	User string
	Host string
}

func (c Client) AsSourceString() string {
	return c.Nick + "!" + c.User + "@" + c.Host
}