1package configsrc
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "path/filepath"
9 "time"
10
11 "github.com/go-git/go-git/v5"
12 "github.com/go-git/go-git/v5/plumbing"
13 "github.com/go-git/go-git/v5/plumbing/filemode"
14 "github.com/go-git/go-git/v5/plumbing/object"
15 "github.com/go-git/go-git/v5/plumbing/storer"
16 "github.com/go-git/go-git/v5/plumbing/transport"
17 "golang.org/x/crypto/ssh"
18
19 "tug/internal/platform/config"
20 "tug/internal/platform/replica"
21)
22
23const (
24 KindRewound = "rewound"
25 KindDiverged = "diverged"
26 KindRemoteEmpty = "remote-empty"
27 KindUnreachable = "unreachable"
28 KindUnsigned = "unsigned"
29 KindNotSSHSignature = "not-ssh-signature"
30 KindForeignSigner = "foreign-signer"
31 KindBadSignature = "bad-signature"
32 KindInvalid = "invalid"
33 KindCheckpointMissing = "checkpoint-missing"
34)
35
36const (
37 repoName = "config-repo"
38 scratchName = "config-repo.repair"
39 asideName = "config-repo.old"
40 knownHostsName = "config-repo.known_hosts"
41 configFile = "tug.toml"
42)
43
44type Remote struct {
45 URL string
46 Branch string
47 DeployKey []byte
48 KnownHosts []byte
49}
50
51type Input struct {
52 Checkpoint string
53 Remote Remote
54 Signers []ssh.PublicKey
55 Timeout time.Duration
56}
57
58type Outcome int
59
60const (
61 Unchanged Outcome = iota
62 Accepted
63 Refused
64)
65
66type Result struct {
67 Outcome Outcome
68 Head string
69 Config []byte
70 Kind string
71 Fetched bool
72 Repaired bool
73 Cause error
74}
75
76var (
77 ErrUnreadable = errors.New("configsrc: object unreadable")
78 errNoConfig = errors.New("configsrc: no regular tug.toml at the root of the commit")
79 errNoSigners = errors.New("configsrc: no signer key")
80 errCommitID = errors.New("configsrc: not a commit id")
81 errAuth = errors.New("configsrc: ssh auth")
82 errEmpty = errors.New("configsrc: the remote branch is empty")
83 errRewound = errors.New("configsrc: the head is an ancestor of the checkpoint")
84 errDiverged = errors.New("configsrc: the head and the checkpoint are on unrelated histories")
85 errMissing = errors.New("configsrc: the remote branch lacks the checkpoint")
86)
87
88var (
89 syncPath = fsync
90 beforeRepair = func() {}
91)
92
93type Source struct {
94 dir string
95}
96
97type refusal struct {
98 kind string
99 cause error
100}
101
102func Open(stateDir string) (*Source, error) {
103 s := &Source{dir: stateDir}
104 if err := s.recover(); err != nil {
105 return nil, err
106 }
107 if _, err := s.repo(); err != nil {
108 return nil, err
109 }
110 return s, nil
111}
112
113func (s *Source) path(name string) string {
114 return filepath.Join(s.dir, name)
115}
116
117func (s *Source) repo() (*git.Repository, error) {
118 p := s.path(repoName)
119 r, err := git.PlainOpen(p)
120 if !errors.Is(err, git.ErrRepositoryNotExists) {
121 return r, err
122 }
123 if r, err = git.PlainInit(p, true); err != nil {
124 return nil, err
125 }
126 if err := syncTree(p); err != nil {
127 return nil, err
128 }
129 return r, syncPath(s.dir)
130}
131
132func (s *Source) Step(ctx context.Context, in Input) (Result, error) {
133 k, err := commitID(in.Checkpoint, true)
134 if err != nil {
135 return Result{}, err
136 }
137 auth, err := s.auth(in)
138 if errors.Is(err, errAuth) {
139 return refuse(Result{}, refusal{KindUnreachable, err}), nil
140 }
141 if err != nil {
142 return Result{}, err
143 }
144 if err := replica.DropEmptyRefs(s.path(repoName)); err != nil {
145 return Result{}, err
146 }
147 repo, err := s.repo()
148 if err != nil {
149 return Result{}, err
150 }
151 branch := plumbing.ReferenceName("refs/heads/" + in.Remote.Branch)
152 head, err := replica.Fetch(ctx, repo, in.Remote.URL, auth, branch)
153 if err != nil {
154 return refuse(Result{}, refusal{KindUnreachable, err}), nil
155 }
156 res := Result{Head: hexOf(head), Fetched: true}
157 v := inspect(repo.Storer, k, head)
158 if v.damage != nil {
159 repaired, r, err := s.repair(ctx, in.Remote.URL, auth, branch, k)
160 if err != nil {
161 return Result{}, err
162 }
163 if r != nil {
164 if r.kind == KindCheckpointMissing {
165 res.Head = hexOf(repaired)
166 }
167 return refuse(res, *r), nil
168 }
169 if repo, err = s.repo(); err != nil {
170 return Result{}, err
171 }
172 head, res.Head = repaired, hexOf(repaired)
173 res.Repaired = true
174 if v = inspect(repo.Storer, k, head); v.damage != nil {
175 return Result{}, v.damage
176 }
177 }
178 switch {
179 case head.IsZero():
180 return refuse(res, refusal{KindRemoteEmpty, errEmpty}), nil
181 case k.IsZero():
182 case head == k:
183 res.Outcome = Unchanged
184 return res, nil
185 case v.kFound:
186 case v.hFound:
187 return refuse(res, refusal{KindRewound, errRewound}), nil
188 default:
189 return refuse(res, refusal{KindDiverged, errDiverged}), nil
190 }
191 _, r, err := verify(repo.Storer, head, in.Signers)
192 if err != nil {
193 return Result{}, err
194 }
195 if r != nil {
196 return refuse(res, *r), nil
197 }
198 data, err := readConfig(repo.Storer, head)
199 if errors.Is(err, errNoConfig) {
200 return refuse(res, refusal{KindInvalid, err}), nil
201 }
202 if err != nil {
203 return Result{}, err
204 }
205 res.Outcome, res.Config = Accepted, data
206 return res, nil
207}
208
209func refuse(res Result, r refusal) Result {
210 res.Outcome, res.Kind, res.Cause, res.Config = Refused, r.kind, r.cause, nil
211 return res
212}
213
214func hexOf(h plumbing.Hash) string {
215 if h.IsZero() {
216 return ""
217 }
218 return h.String()
219}
220
221func commitID(s string, emptyOK bool) (plumbing.Hash, error) {
222 if s == "" && emptyOK {
223 return plumbing.ZeroHash, nil
224 }
225 if !plumbing.IsHash(s) {
226 return plumbing.ZeroHash, errCommitID
227 }
228 return plumbing.NewHash(s), nil
229}
230
231func (s *Source) auth(in Input) (transport.AuthMethod, error) {
232 ep, err := transport.NewEndpoint(in.Remote.URL)
233 if err != nil {
234 return nil, fmt.Errorf("%w: %w", errAuth, err)
235 }
236 if ep.Protocol != "ssh" {
237 return nil, nil
238 }
239 a, err := s.sshAuth(in.Remote, in.Timeout)
240 if err != nil {
241 return nil, err
242 }
243 return a, nil
244}
245
246func (s *Source) sshAuth(r Remote, timeout time.Duration) (*replica.SSH, error) {
247 p := s.path(knownHostsName)
248 if err := replaceFile(p+".tmp", p, r.KnownHosts); err != nil {
249 return nil, err
250 }
251 a, err := replica.SSHAuth(r.URL, r.DeployKey, p)
252 if err != nil {
253 return nil, fmt.Errorf("%w: %w", errAuth, err)
254 }
255 if timeout > 0 {
256 a.Timeout = timeout
257 }
258 return a, nil
259}
260
261func verify(st storer.EncodedObjectStorer, h plumbing.Hash, keys []ssh.PublicKey) (ssh.PublicKey, *refusal, error) {
262 if len(keys) == 0 {
263 return nil, &refusal{KindForeignSigner, errNoSigners}, nil
264 }
265 var bad, unsigned, notSSH, foreign error
266 for _, key := range keys {
267 _, err := replica.VerifySSHCommit(st, h, key, config.SignatureAlgorithms(key)...)
268 switch {
269 case err == nil:
270 return key, nil, nil
271 case errors.Is(err, replica.ErrBadSignature):
272 bad = err
273 case errors.Is(err, replica.ErrUnsigned):
274 unsigned = err
275 case errors.Is(err, replica.ErrNotSSHSignature), errors.Is(err, replica.ErrMalformed), errors.Is(err, replica.ErrNamespace):
276 notSSH = err
277 case errors.Is(err, replica.ErrWrongKey), errors.Is(err, replica.ErrUnsupportedKey):
278 foreign = err
279 default:
280 return nil, nil, err
281 }
282 }
283 switch {
284 case bad != nil:
285 return nil, &refusal{KindBadSignature, bad}, nil
286 case unsigned != nil:
287 return nil, &refusal{KindUnsigned, unsigned}, nil
288 case notSSH != nil:
289 return nil, &refusal{KindNotSSHSignature, notSSH}, nil
290 }
291 return nil, &refusal{KindForeignSigner, foreign}, nil
292}
293
294func readConfig(st storer.EncodedObjectStorer, h plumbing.Hash) ([]byte, error) {
295 c, err := object.GetCommit(st, h)
296 if err != nil {
297 return nil, err
298 }
299 t, err := object.GetTree(st, c.TreeHash)
300 if err != nil {
301 return nil, err
302 }
303 e, err := t.FindEntry(configFile)
304 if err != nil {
305 return nil, errNoConfig
306 }
307 switch e.Mode {
308 case filemode.Regular, filemode.Deprecated, filemode.Executable:
309 default:
310 return nil, errNoConfig
311 }
312 b, err := object.GetBlob(st, e.Hash)
313 if err != nil {
314 return nil, err
315 }
316 rd, err := b.Reader()
317 if err != nil {
318 return nil, err
319 }
320 defer rd.Close()
321 return io.ReadAll(rd)
322}
323
324func (s *Source) Pin(head string) error {
325 h, err := commitID(head, false)
326 if err != nil {
327 return err
328 }
329 p := s.path(repoName)
330 r, err := git.PlainOpen(p)
331 if err != nil {
332 return unreadable(h, err)
333 }
334 if _, err := readConfig(r.Storer, h); err != nil {
335 return unreadable(h, err)
336 }
337 if err := writeCheckpointRef(p, h); err != nil {
338 return err
339 }
340 if err := syncTree(p); err != nil {
341 return err
342 }
343 return syncPath(s.dir)
344}
345
346func (s *Source) Read(commit string) ([]byte, error) {
347 h, err := commitID(commit, false)
348 if err != nil {
349 return nil, err
350 }
351 r, err := git.PlainOpen(s.path(repoName))
352 if err != nil {
353 return nil, unreadable(h, err)
354 }
355 data, err := readConfig(r.Storer, h)
356 if err != nil {
357 return nil, unreadable(h, err)
358 }
359 return data, nil
360}
361
362func unreadable(h plumbing.Hash, err error) error {
363 if errors.Is(err, plumbing.ErrObjectNotFound) || errors.Is(err, git.ErrRepositoryNotExists) {
364 return fmt.Errorf("%w: %s: %w", ErrUnreadable, h.String()[:12], err)
365 }
366 return err
367}
368
369func IsTimeout(err error) bool {
370 var t interface{ Timeout() bool }
371 return errors.As(err, &t) && t.Timeout()
372}