detached fda3c2b

gitpush.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package gitpush implements bounded, non-forcing mirror pushes over git/ssh.
4package gitpush
5
6import (
7	"bytes"
8	"context"
9	"crypto/ed25519"
10	"encoding/pem"
11	"errors"
12	"os"
13	"os/exec"
14	"strings"
15	"time"
16	"unicode"
17	"unicode/utf8"
18
19	"bnema.dev/ermite/internal/domain"
20	"bnema.dev/ermite/internal/ports"
21	"github.com/bnema/zerowrap"
22	"golang.org/x/crypto/ssh"
23)
24
25type Config struct {
26	Bin, Path, Home, KnownHosts, TempDir string
27	Timeout                              time.Duration
28	MaxOutput                            int
29	AllowedProtocols                     string
30	SSHOptions                           []string
31	RepoDir                              func(domain.RepoName) string
32}
33type Pusher struct{ cfg Config }
34
35var _ ports.MirrorPusher = (*Pusher)(nil)
36
37func New(cfg Config) *Pusher {
38	if cfg.Timeout <= 0 {
39		cfg.Timeout = 10 * time.Minute
40	}
41	if cfg.MaxOutput <= 0 {
42		cfg.MaxOutput = 1 << 20
43	}
44	if cfg.AllowedProtocols == "" {
45		cfg.AllowedProtocols = "ssh"
46	}
47	cfg.SSHOptions = append([]string(nil), cfg.SSHOptions...)
48	return &Pusher{cfg: cfg}
49}
50func quote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" }
51
52// validRefspec deliberately accepts only explicit same-name heads and tags.
53// Empty sources, wildcards, force prefixes and revision expressions never reach git.
54func validRefspec(s string) bool {
55	a, b, ok := strings.Cut(s, ":")
56	if !ok || a != b || !strings.HasPrefix(a, "refs/heads/") && !strings.HasPrefix(a, "refs/tags/") {
57		return false
58	}
59	if strings.ContainsAny(a, "+*?[:\\^~ ") || strings.Contains(a, "..") || strings.Contains(a, "@{") || strings.Contains(a, "//") || strings.HasSuffix(a, "/") || strings.HasSuffix(a, ".") {
60		return false
61	}
62	for _, part := range strings.Split(a, "/") {
63		if part == "" || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") {
64			return false
65		}
66	}
67	return !strings.ContainsFunc(a, unicode.IsControl)
68}
69
70func (p *Pusher) Push(parent context.Context, req ports.MirrorPush) (result ports.MirrorPushResult, err error) {
71	start := time.Now()
72	defer func() { result.Duration = time.Since(start) }()
73	internal := func(detail string) error {
74		return &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: detail}
75	}
76	if err := parent.Err(); err != nil {
77		return result, err
78	}
79	if len(req.PrivateKey) != ed25519.PrivateKeySize {
80		return result, internal("invalid mirror private key")
81	}
82	if p.cfg.RepoDir == nil || p.cfg.TempDir == "" || p.cfg.Home == "" || p.cfg.KnownHosts == "" {
83		return result, internal("mirror transport directories are not configured")
84	}
85	if req.URL == "" || strings.HasPrefix(req.URL, "-") || strings.ContainsFunc(req.URL, unicode.IsControl) {
86		return result, internal("invalid mirror target")
87	}
88	for _, ref := range req.Refspecs {
89		if !validRefspec(ref) {
90			return result, internal("invalid mirror refspec")
91		}
92	}
93	if len(req.Refspecs) == 0 {
94		return result, nil
95	} // never let git select implicit refspecs
96	ctx, cancel := context.WithTimeout(parent, p.cfg.Timeout)
97	defer cancel()
98	if err := os.MkdirAll(p.cfg.TempDir, 0700); err != nil {
99		return result, internal("create mirror key directory")
100	}
101	if err := os.Chmod(p.cfg.TempDir, 0700); err != nil {
102		return result, internal("protect mirror key directory")
103	}
104	dir, e := os.MkdirTemp(p.cfg.TempDir, "push-")
105	if e != nil {
106		return result, internal("create temporary mirror directory")
107	}
108	defer func() {
109		// The directory holds the private key: a leftover is reported, and resetRunDir wipes it at
110		// the next start.
111		if e := os.RemoveAll(dir); e != nil {
112			log := zerowrap.FromCtx(parent)
113			log.Warn().Err(e).Msg("remove the temporary mirror key directory")
114		}
115	}()
116	block, e := ssh.MarshalPrivateKey(req.PrivateKey, "")
117	if e != nil {
118		return result, internal("encode mirror private key")
119	}
120	f, e := os.CreateTemp(dir, "key-")
121	if e != nil {
122		return result, internal("create temporary mirror key")
123	}
124	keyPath := f.Name()
125	_, e = f.Write(pem.EncodeToMemory(block))
126	closeErr := f.Close()
127	if e != nil || closeErr != nil {
128		return result, internal("write temporary mirror key")
129	}
130	// -F /dev/null prevents ~/.ssh/config and system config from adding credentials
131	// or weakening the required options. Required options precede caller extras.
132	command := "ssh -F /dev/null -i " + quote(keyPath)
133	for _, opt := range append([]string{"IdentitiesOnly=yes", "BatchMode=yes", "StrictHostKeyChecking=accept-new", "UserKnownHostsFile=" + p.cfg.KnownHosts, "ConnectTimeout=30", "ServerAliveInterval=15", "ServerAliveCountMax=3"}, p.cfg.SSHOptions...) {
134		command += " -o " + quote(opt)
135	}
136	cleanup := max(min(2*time.Second, p.cfg.Timeout/8), time.Millisecond)
137	runCtx, stop := context.WithTimeout(ctx, max(p.cfg.Timeout-cleanup, time.Millisecond))
138	defer stop()
139	stdout := &limitWriter{max: p.cfg.MaxOutput, cancel: stop}
140	stderr := &limitWriter{max: p.cfg.MaxOutput, cancel: stop}
141	args := []string{"-C", p.cfg.RepoDir(req.Repo), "push", "--porcelain", "--no-verify", "--atomic", "--", req.URL}
142	args = append(args, req.Refspecs...)
143	cmd := exec.Command(p.cfg.Bin, args...)
144	cmd.Env = []string{"PATH=" + p.cfg.Path, "HOME=" + p.cfg.Home, "GIT_ALLOW_PROTOCOL=" + p.cfg.AllowedProtocols, "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1", "GIT_SSH_COMMAND=" + command, "LC_ALL=C"}
145	cmd.Dir = p.cfg.Home
146	cmd.Stdout = stdout
147	cmd.Stderr = stderr
148	cmd.WaitDelay = cleanup
149	runErr := run(runCtx, cmd)
150	if parent.Err() != nil {
151		return result, parent.Err()
152	}
153	rejected := false
154	for line := range strings.SplitSeq(stdout.buf.String(), "\n") {
155		fields := strings.Split(line, "\t")
156		if len(fields) < 3 || len(fields[0]) != 1 {
157			continue
158		}
159		var status ports.MirrorRefStatus
160		switch fields[0] {
161		case " ", "*":
162			status = ports.MirrorRefOK
163		case "=":
164			status = ports.MirrorRefUpToDate
165		case "!":
166			status = ports.MirrorRefRejected
167			rejected = true
168		default:
169			continue
170		}
171		_, ref, _ := strings.Cut(fields[1], ":")
172		result.Refs = append(result.Refs, ports.MirrorRefOutcome{Ref: ref, Status: status, Reason: safeDetail(fields[2], req.URL)})
173	}
174	if stdout.exceeded || stderr.exceeded {
175		return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror output limit exceeded"}
176	}
177	if runCtx.Err() != nil {
178		return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror push timed out"}
179	}
180	if runErr != nil || rejected {
181		// The remote's own messages ("remote: ...") are not ssh/git diagnostics: a hosting
182		// message must not pick the failure class. They stay in the detail.
183		class := domain.ClassifyMirrorOutput(withoutRemoteLines(stderr.buf.String())+"\n"+stdout.buf.String(), runErr, false)
184		if rejected && class == domain.MirrorClassNone {
185			class = domain.MirrorClassDiverged
186		}
187		var execErr *exec.Error
188		if errors.As(runErr, &execErr) || localGitError(stderr.buf.String()) {
189			class = domain.MirrorClassInternal
190		}
191		detail := safeDetail(stderr.buf.String(), req.URL)
192		if detail == "" {
193			detail = "git push failed"
194		}
195		return result, &domain.MirrorError{Class: class, Detail: detail}
196	}
197	return result, nil
198}
199
200// withoutRemoteLines drops the lines the remote sent through git ("remote: ...").
201func withoutRemoteLines(s string) string {
202	lines := strings.Split(s, "\n")
203	kept := lines[:0]
204	for _, l := range lines {
205		if !strings.HasPrefix(strings.TrimSpace(l), "remote:") {
206			kept = append(kept, l)
207		}
208	}
209	return strings.Join(kept, "\n")
210}
211
212func localGitError(stderr string) bool {
213	for _, marker := range []string{"cannot change to", "not a git repository", "src refspec", "bad object", "unable to read"} {
214		if strings.Contains(strings.ToLower(stderr), marker) {
215			return true
216		}
217	}
218	return false
219}
220
221// maxDetail caps a detail in bytes; a cut inside a multibyte character is repaired.
222const maxDetail = 4096
223
224// safeDetail removes the complete target before taking a bounded stderr tail.
225// SSH diagnostics may repeat the user separately, so suppress user@ tokens too.
226func safeDetail(s, url string) string {
227	s = strings.ReplaceAll(s, url, "[mirror target]")
228	words := strings.Fields(s)
229	for i, w := range words {
230		if strings.Contains(w, "@") || strings.Contains(w, "://") {
231			words[i] = "[redacted]"
232		}
233	}
234	s = strings.Join(words, " ")
235	if len(s) > maxDetail {
236		s = s[len(s)-maxDetail:]
237		for len(s) > 0 && !utf8.RuneStart(s[0]) { // do not start inside a character
238			s = s[1:]
239		}
240	}
241	return strings.ToValidUTF8(s, "?")
242}
243
244type limitWriter struct {
245	buf      bytes.Buffer
246	max      int
247	exceeded bool
248	cancel   context.CancelFunc
249}
250
251func (w *limitWriter) Write(p []byte) (int, error) {
252	if len(p) > w.max-w.buf.Len() {
253		w.exceeded = true
254		w.cancel()
255		return 0, errors.New("output limit reached")
256	}
257	return w.buf.Write(p)
258}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close