Raw
  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}