package replica import ( "context" "errors" "fmt" "time" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "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" "github.com/go-git/go-git/v5/plumbing/transport" ) type Remote struct { URL string Auth transport.AuthMethod } type Checkpoint struct { Value string Set bool } type Checkpoints interface { ReadCheckpoint(ctx context.Context) (Checkpoint, error) SwapCheckpoint(ctx context.Context, expected Checkpoint, next string) error } type Outcome int const ( Pushed Outcome = iota + 1 Adopted ) type StepInput struct { Repo *git.Repository Dir string Checkpoints Checkpoints Primary Remote Mirror *Remote Signer *Signer Blob []byte When time.Time Digest func(blob []byte) ([32]byte, error) Current [32]byte } type Result struct { Outcome Outcome Commit plumbing.Hash MirrorErr error MirrorReseeded bool } var ( ErrRefused = errors.New("replica: refused") ErrRemoteHasHistory = errors.New("replica: no checkpoint is set and the remote branch holds history") ErrRemoteEmpty = errors.New("replica: a checkpoint is set and the remote branch is empty or absent") ErrRewound = errors.New("replica: the remote branch is an ancestor of the checkpoint") ErrForeignChild = errors.New("replica: the remote branch is a child of the checkpoint this appliance did not sign") ErrNotLocalHead = errors.New("replica: the remote branch is a signed child of the checkpoint that is not the local branch head") ErrDiverged = errors.New("replica: the remote branch is neither the checkpoint, an ancestor of it nor a child of it") ErrOwnRootDiffers = errors.New("replica: no checkpoint is set and the remote branch is this appliance's own root, but its export differs from the store's") ErrCheckpointMissing = errors.New("replica: the checkpoint commit is missing locally and is not on the remote branch") ) var ( beforePush = func() {} beforeMirror = func() {} beforeReseed = func() {} ) func refused(kind error, format string, args ...any) error { return fmt.Errorf("%w: %w: "+format, append([]any{ErrRefused, kind}, args...)...) } func localHead(repo *git.Repository) (plumbing.Hash, error) { ref, err := repo.Storer.Reference(Branch) if errors.Is(err, plumbing.ErrReferenceNotFound) { return plumbing.ZeroHash, nil } if err != nil { return plumbing.ZeroHash, err } return ref.Hash(), nil } func hasCommit(repo *git.Repository, id plumbing.Hash) bool { _, err := object.GetCommit(repo.Storer, id) return err == nil } func Step(ctx context.Context, in StepInput) (Result, error) { k, err := in.Checkpoints.ReadCheckpoint(ctx) if err != nil { return Result{}, err } if err := ensureCheckpoint(ctx, in.Repo, in.Dir, in.Primary, k); err != nil { return Result{}, err } head, err := RemoteHead(ctx, in.Primary.URL, in.Primary.Auth, Branch) if err != nil { return Result{}, err } var parent plumbing.Hash switch { case !k.Set && head.IsZero(): case !k.Set: return adoptRootOrRefuse(ctx, in, head) case head.IsZero(): return Result{}, refused(ErrRemoteEmpty, "checkpoint %s", k.Value) case head.String() == k.Value: parent = head default: return adoptOrRefuse(ctx, in, k, head) } id, err := CommitFile(in.Repo, Branch, parent, in.Blob, in.When, in.Signer) if err != nil { return Result{}, err } beforePush() if err := Push(ctx, in.Repo, in.Primary.URL, in.Primary.Auth, Branch, id, parent); err != nil { return Result{}, err } if err := in.Checkpoints.SwapCheckpoint(ctx, k, id.String()); err != nil { return Result{}, err } res := Result{Outcome: Pushed, Commit: id} if in.Mirror != nil { res.MirrorReseeded, res.MirrorErr = mirrorOrReseed(ctx, in, id) } return res, nil } func adoptOrRefuse(ctx context.Context, in StepInput, k Checkpoint, head plumbing.Hash) (Result, error) { checkpoint := plumbing.NewHash(k.Value) local, err := localHead(in.Repo) if err != nil { return Result{}, err } var objects storer.EncodedObjectStorer = in.Repo.Storer if !hasCommit(in.Repo, head) { s, done, err := scratch(in.Dir) if err != nil { return Result{}, err } defer done() fetched, err := FetchHistory(ctx, s, in.Primary.URL, in.Primary.Auth, Branch) if err != nil { return Result{}, err } if fetched != head { return Result{}, fmt.Errorf("%w: remote %s, read as %s", ErrRemoteMoved, fetched, head) } objects = s.Storer } c, err := object.GetCommit(objects, head) if err != nil { return Result{}, err } _, sigErr := VerifyCommit(objects, head, in.Signer.PublicKey()) if head == local && sigErr == nil && childOf(c, checkpoint) { if err := in.Checkpoints.SwapCheckpoint(ctx, k, head.String()); err != nil { return Result{}, err } return Result{Outcome: Adopted, Commit: head}, nil } return classify(objects, checkpoint, c, sigErr, local) } func childOf(c *object.Commit, parent plumbing.Hash) bool { return len(c.ParentHashes) == 1 && c.ParentHashes[0] == parent } func classify(objects storer.EncodedObjectStorer, checkpoint plumbing.Hash, c *object.Commit, sigErr error, local plumbing.Hash) (Result, error) { if kc, err := object.GetCommit(objects, checkpoint); err == nil { if ok, err := c.IsAncestor(kc); err == nil && ok { return Result{}, refused(ErrRewound, "remote %s, checkpoint %s", c.Hash, checkpoint) } } switch { case childOf(c, checkpoint) && sigErr != nil: return Result{}, refused(ErrForeignChild, "remote %s: %w", c.Hash, signatureKind(sigErr)) case childOf(c, checkpoint): return Result{}, refused(ErrNotLocalHead, "remote %s, local %s", c.Hash, local) } return Result{}, refused(ErrDiverged, "remote %s, checkpoint %s", c.Hash, checkpoint) } func signatureKind(err error) error { for _, k := range []error{ErrUnsigned, ErrNotSSHSignature, ErrMalformed, ErrNamespace, ErrWrongKey, ErrBadSignature, ErrUnsupportedKey} { if errors.Is(err, k) { return k } } return err } func adoptRootOrRefuse(ctx context.Context, in StepInput, head plumbing.Hash) (Result, error) { local, err := localHead(in.Repo) if err != nil { return Result{}, err } if head != local { return Result{}, refused(ErrRemoteHasHistory, "remote %s", head) } c, err := object.GetCommit(in.Repo.Storer, head) if err != nil { return Result{}, err } _, sigErr := VerifyCommit(in.Repo.Storer, head, in.Signer.PublicKey()) if c.NumParents() != 0 || sigErr != nil || in.Digest == nil { return Result{}, refused(ErrRemoteHasHistory, "remote %s", head) } same, err := sameExport(in, head) if err != nil { return Result{}, refused(ErrOwnRootDiffers, "remote %s: %w", head, err) } if !same { return Result{}, refused(ErrOwnRootDiffers, "remote %s", head) } if err := in.Checkpoints.SwapCheckpoint(ctx, Checkpoint{}, head.String()); err != nil { return Result{}, err } return Result{Outcome: Adopted, Commit: head}, nil } func sameExport(in StepInput, id plumbing.Hash) (bool, error) { blob, err := ReadFile(in.Repo.Storer, id) if err != nil { return false, err } committed, err := in.Digest(blob) if err != nil { return false, err } return committed == in.Current, nil } func mirrorOrReseed(ctx context.Context, in StepInput, id plumbing.Hash) (bool, error) { beforeMirror() err := pushMirror(ctx, in.Repo, *in.Mirror, id) if !errors.Is(err, plumbing.ErrObjectNotFound) { return false, err } return true, reseed(ctx, in, id) } func reseed(ctx context.Context, in StepInput, id plumbing.Hash) error { beforeReseed() s, done, err := scratch(in.Dir) if err != nil { return err } defer done() head, err := FetchHistory(ctx, s, in.Primary.URL, in.Primary.Auth, Branch) if err != nil { return err } if head != id { return fmt.Errorf("%w: re-seed: remote %s, checkpoint %s", ErrRemoteMoved, head, id) } return pushMirror(ctx, s, *in.Mirror, id) } func pushMirror(ctx context.Context, repo *git.Repository, mirror Remote, id plumbing.Hash) error { head, err := RemoteHead(ctx, mirror.URL, mirror.Auth, Branch) if err != nil { return err } opts := &git.PushOptions{ RemoteName: remoteName, RefSpecs: []config.RefSpec{config.RefSpec(id.String() + ":" + Branch.String())}, Auth: mirror.Auth, ProxyOptions: proxyFor(mirror.Auth), } if !head.IsZero() { opts.RequireRemoteRefs = []config.RefSpec{config.RefSpec(head.String() + ":" + Branch.String())} } return classifyPush(newRemote(repo, mirror.URL).PushContext(ctx, opts)) }