package configsrc import ( "context" "errors" "fmt" "io" "path/filepath" "time" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/filemode" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/storer" "github.com/go-git/go-git/v5/plumbing/transport" "golang.org/x/crypto/ssh" "tug/internal/platform/config" "tug/internal/platform/replica" ) const ( KindRewound = "rewound" KindDiverged = "diverged" KindRemoteEmpty = "remote-empty" KindUnreachable = "unreachable" KindUnsigned = "unsigned" KindNotSSHSignature = "not-ssh-signature" KindForeignSigner = "foreign-signer" KindBadSignature = "bad-signature" KindInvalid = "invalid" KindCheckpointMissing = "checkpoint-missing" ) const ( repoName = "config-repo" scratchName = "config-repo.repair" asideName = "config-repo.old" knownHostsName = "config-repo.known_hosts" configFile = "tug.toml" ) type Remote struct { URL string Branch string DeployKey []byte KnownHosts []byte } type Input struct { Checkpoint string Remote Remote Signers []ssh.PublicKey Timeout time.Duration } type Outcome int const ( Unchanged Outcome = iota Accepted Refused ) type Result struct { Outcome Outcome Head string Config []byte Kind string Fetched bool Repaired bool Cause error } var ( ErrUnreadable = errors.New("configsrc: object unreadable") errNoConfig = errors.New("configsrc: no regular tug.toml at the root of the commit") errNoSigners = errors.New("configsrc: no signer key") errCommitID = errors.New("configsrc: not a commit id") errAuth = errors.New("configsrc: ssh auth") errEmpty = errors.New("configsrc: the remote branch is empty") errRewound = errors.New("configsrc: the head is an ancestor of the checkpoint") errDiverged = errors.New("configsrc: the head and the checkpoint are on unrelated histories") errMissing = errors.New("configsrc: the remote branch lacks the checkpoint") ) var ( syncPath = fsync beforeRepair = func() {} ) type Source struct { dir string } type refusal struct { kind string cause error } func Open(stateDir string) (*Source, error) { s := &Source{dir: stateDir} if err := s.recover(); err != nil { return nil, err } if _, err := s.repo(); err != nil { return nil, err } return s, nil } func (s *Source) path(name string) string { return filepath.Join(s.dir, name) } func (s *Source) repo() (*git.Repository, error) { p := s.path(repoName) r, err := git.PlainOpen(p) if !errors.Is(err, git.ErrRepositoryNotExists) { return r, err } if r, err = git.PlainInit(p, true); err != nil { return nil, err } if err := syncTree(p); err != nil { return nil, err } return r, syncPath(s.dir) } func (s *Source) Step(ctx context.Context, in Input) (Result, error) { k, err := commitID(in.Checkpoint, true) if err != nil { return Result{}, err } auth, err := s.auth(in) if errors.Is(err, errAuth) { return refuse(Result{}, refusal{KindUnreachable, err}), nil } if err != nil { return Result{}, err } if err := replica.DropEmptyRefs(s.path(repoName)); err != nil { return Result{}, err } repo, err := s.repo() if err != nil { return Result{}, err } branch := plumbing.ReferenceName("refs/heads/" + in.Remote.Branch) head, err := replica.Fetch(ctx, repo, in.Remote.URL, auth, branch) if err != nil { return refuse(Result{}, refusal{KindUnreachable, err}), nil } res := Result{Head: hexOf(head), Fetched: true} v := inspect(repo.Storer, k, head) if v.damage != nil { repaired, r, err := s.repair(ctx, in.Remote.URL, auth, branch, k) if err != nil { return Result{}, err } if r != nil { if r.kind == KindCheckpointMissing { res.Head = hexOf(repaired) } return refuse(res, *r), nil } if repo, err = s.repo(); err != nil { return Result{}, err } head, res.Head = repaired, hexOf(repaired) res.Repaired = true if v = inspect(repo.Storer, k, head); v.damage != nil { return Result{}, v.damage } } switch { case head.IsZero(): return refuse(res, refusal{KindRemoteEmpty, errEmpty}), nil case k.IsZero(): case head == k: res.Outcome = Unchanged return res, nil case v.kFound: case v.hFound: return refuse(res, refusal{KindRewound, errRewound}), nil default: return refuse(res, refusal{KindDiverged, errDiverged}), nil } _, r, err := verify(repo.Storer, head, in.Signers) if err != nil { return Result{}, err } if r != nil { return refuse(res, *r), nil } data, err := readConfig(repo.Storer, head) if errors.Is(err, errNoConfig) { return refuse(res, refusal{KindInvalid, err}), nil } if err != nil { return Result{}, err } res.Outcome, res.Config = Accepted, data return res, nil } func refuse(res Result, r refusal) Result { res.Outcome, res.Kind, res.Cause, res.Config = Refused, r.kind, r.cause, nil return res } func hexOf(h plumbing.Hash) string { if h.IsZero() { return "" } return h.String() } func commitID(s string, emptyOK bool) (plumbing.Hash, error) { if s == "" && emptyOK { return plumbing.ZeroHash, nil } if !plumbing.IsHash(s) { return plumbing.ZeroHash, errCommitID } return plumbing.NewHash(s), nil } func (s *Source) auth(in Input) (transport.AuthMethod, error) { ep, err := transport.NewEndpoint(in.Remote.URL) if err != nil { return nil, fmt.Errorf("%w: %w", errAuth, err) } if ep.Protocol != "ssh" { return nil, nil } a, err := s.sshAuth(in.Remote, in.Timeout) if err != nil { return nil, err } return a, nil } func (s *Source) sshAuth(r Remote, timeout time.Duration) (*replica.SSH, error) { p := s.path(knownHostsName) if err := replaceFile(p+".tmp", p, r.KnownHosts); err != nil { return nil, err } a, err := replica.SSHAuth(r.URL, r.DeployKey, p) if err != nil { return nil, fmt.Errorf("%w: %w", errAuth, err) } if timeout > 0 { a.Timeout = timeout } return a, nil } func verify(st storer.EncodedObjectStorer, h plumbing.Hash, keys []ssh.PublicKey) (ssh.PublicKey, *refusal, error) { if len(keys) == 0 { return nil, &refusal{KindForeignSigner, errNoSigners}, nil } var bad, unsigned, notSSH, foreign error for _, key := range keys { _, err := replica.VerifySSHCommit(st, h, key, config.SignatureAlgorithms(key)...) switch { case err == nil: return key, nil, nil case errors.Is(err, replica.ErrBadSignature): bad = err case errors.Is(err, replica.ErrUnsigned): unsigned = err case errors.Is(err, replica.ErrNotSSHSignature), errors.Is(err, replica.ErrMalformed), errors.Is(err, replica.ErrNamespace): notSSH = err case errors.Is(err, replica.ErrWrongKey), errors.Is(err, replica.ErrUnsupportedKey): foreign = err default: return nil, nil, err } } switch { case bad != nil: return nil, &refusal{KindBadSignature, bad}, nil case unsigned != nil: return nil, &refusal{KindUnsigned, unsigned}, nil case notSSH != nil: return nil, &refusal{KindNotSSHSignature, notSSH}, nil } return nil, &refusal{KindForeignSigner, foreign}, nil } func readConfig(st storer.EncodedObjectStorer, h plumbing.Hash) ([]byte, error) { c, err := object.GetCommit(st, h) if err != nil { return nil, err } t, err := object.GetTree(st, c.TreeHash) if err != nil { return nil, err } e, err := t.FindEntry(configFile) if err != nil { return nil, errNoConfig } switch e.Mode { case filemode.Regular, filemode.Deprecated, filemode.Executable: default: return nil, errNoConfig } b, err := object.GetBlob(st, e.Hash) if err != nil { return nil, err } rd, err := b.Reader() if err != nil { return nil, err } defer rd.Close() return io.ReadAll(rd) } func (s *Source) Pin(head string) error { h, err := commitID(head, false) if err != nil { return err } p := s.path(repoName) r, err := git.PlainOpen(p) if err != nil { return unreadable(h, err) } if _, err := readConfig(r.Storer, h); err != nil { return unreadable(h, err) } if err := writeCheckpointRef(p, h); err != nil { return err } if err := syncTree(p); err != nil { return err } return syncPath(s.dir) } func (s *Source) Read(commit string) ([]byte, error) { h, err := commitID(commit, false) if err != nil { return nil, err } r, err := git.PlainOpen(s.path(repoName)) if err != nil { return nil, unreadable(h, err) } data, err := readConfig(r.Storer, h) if err != nil { return nil, unreadable(h, err) } return data, nil } func unreadable(h plumbing.Hash, err error) error { if errors.Is(err, plumbing.ErrObjectNotFound) || errors.Is(err, git.ErrRepositoryNotExists) { return fmt.Errorf("%w: %s: %w", ErrUnreadable, h.String()[:12], err) } return err } func IsTimeout(err error) bool { var t interface{ Timeout() bool } return errors.As(err, &t) && t.Timeout() }