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}