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}