package replica import ( "context" "errors" "fmt" "time" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/storer" "golang.org/x/crypto/ssh" ) type RestoreInput struct { Repo *git.Repository Dir string Remote Remote Commit plumbing.Hash Keys []ssh.PublicKey DryRun bool Import func(ctx context.Context, blob []byte, restored, checkpoint plumbing.Hash) error } type RestoreResult struct { Head, Commit plumbing.Hash Blob []byte HeadBlob []byte When time.Time } var ( ErrNothingToRestore = errors.New("replica: the remote branch is empty") ErrNotOnBranch = errors.New("replica: the chosen commit is not on the remote branch") ErrChainUnverified = errors.New("replica: a commit between the root and the remote head is not signed by an allowed key") ErrMergeCommit = errors.New("replica: the history holds a merge commit") ErrNoAllowedKeys = errors.New("replica: no allowed signing key") errNoImport = errors.New("replica: a restore that is not a dry run needs an import") ) func Restore(ctx context.Context, in RestoreInput) (RestoreResult, error) { if len(in.Keys) == 0 { return RestoreResult{}, ErrNoAllowedKeys } if !in.DryRun && (in.Import == nil || in.Repo == nil) { return RestoreResult{}, errNoImport } s, done, err := scratch(in.Dir) if err != nil { return RestoreResult{}, err } defer done() head, err := FetchHistory(ctx, s, in.Remote.URL, in.Remote.Auth, Branch) if err != nil { return RestoreResult{}, err } if head.IsZero() { return RestoreResult{}, ErrNothingToRestore } chosen := in.Commit if chosen.IsZero() { chosen = head } if !onBranch(s, chosen, head) { return RestoreResult{}, fmt.Errorf("%w: %s", ErrNotOnBranch, chosen) } if err := verifyChain(s.Storer, head, in.Keys); err != nil { return RestoreResult{}, err } c, err := object.GetCommit(s.Storer, chosen) if err != nil { return RestoreResult{}, err } blob, err := ReadFile(s.Storer, chosen) if err != nil { return RestoreResult{}, err } res := RestoreResult{Head: head, Commit: chosen, Blob: blob, HeadBlob: blob, When: c.Committer.When.UTC()} if chosen != head { if res.HeadBlob, err = ReadFile(s.Storer, head); err != nil { return RestoreResult{}, err } } if in.DryRun { return res, nil } set, err := content(s, head) if err != nil { return RestoreResult{}, err } ancestors, err := ancestry(s, head) if err != nil { return RestoreResult{}, err } if err := install(in.Repo, s, append(set, ancestors...)); err != nil { return RestoreResult{}, err } if err := in.Import(ctx, blob, chosen, head); err != nil { return RestoreResult{}, err } if err := in.Repo.Storer.SetReference(plumbing.NewHashReference(Branch, head)); err != nil { return RestoreResult{}, err } if err := in.Repo.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, Branch)); err != nil { return RestoreResult{}, err } return res, nil } func verifyChain(objects storer.EncodedObjectStorer, id plumbing.Hash, keys []ssh.PublicKey) error { for { c, err := verifyAny(objects, id, keys) if err != nil { return err } switch len(c.ParentHashes) { case 0: return nil case 1: id = c.ParentHashes[0] default: return fmt.Errorf("%w: %s", ErrMergeCommit, id) } } } func verifyAny(objects storer.EncodedObjectStorer, id plumbing.Hash, keys []ssh.PublicKey) (*object.Commit, error) { var first error for _, k := range keys { c, err := VerifyCommit(objects, id, k) if err == nil { return c, nil } if first == nil { first = err } } return nil, fmt.Errorf("%w: commit %s: %w", ErrChainUnverified, id, signatureKind(first)) }