detached 5278df2

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

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 43

↑↓ moveenter openesc close