Artifact 05e3100bc9d1d21090e851410469063528637b5a0fc8f8f250135d1f7db939a3:
- File
cli/repo_clone.go
— part of check-in
[612f2e7acc]
at
2026-10-23 20:05:29
on branch main
— feat(signing): sign check-ins and horizon seeds with gpg or ssh
go-libfossil signed nothing and verified nothing, so a structural-horizon clone could not tell a seed its origin minted from one substituted on disk (draft-fossil-horizon-00 §5.2.4, HZ-5.2-5 and HZ-5.2-6), and a repository with fossil's clearsign setting on stored its check-ins unsigned.
Signing follows fossil's contract, the same one zit follows. With clearsign on (and the legacy omitsign off), every check-in a commit or branch creation stores is clear-signed by pgp-command: unset, gpg --clearsign -o, run as COMMAND OUTFILE INFILE; a command named ssh* runs as COMMAND INFILE and its detached signature is wrapped in fossil's SSH envelope. A signer that fails, or returns text that does not strip back to the manifest, abandons the commit. NoSign and --nosign store one check-in unsigned.
An origin with clearsign on signs each seed it mints. It signs only after the request is authorized, outside the request's transaction, keeps one signature per seed and command so the seed's name is stable, and re-signs a kept signature its own horizon-seed-key no longer verifies. Keeping is best effort, so a read-only mirror still serves.
A clone given its origin's key (CloneOpts.SeedKey, repo clone --seed-key, kept as horizon-seed-key) verifies every seed on clone, pull and rebuild, and falls back to the content horizon from one unsigned or signed by another key. An SSH key is verified in process: namespace fossilscm, the configured key, no SHA-1 ssh-rsa, and exactly fossil's envelope. An OpenPGP fingerprint is verified by gpg, which must report one good signature by that key over text byte-equal to what the parser reads, with nothing after the signature. A verifier that cannot run keeps the held seed and fails the sync instead of discarding it.
fossil 2.28 and zit read the signed check-ins; a check-in zit signs with the same SSH key verifies here; zit clones a signing origin and holds its seed. (user: [email protected], size: 4751) [annotate] [blame] [check-ins using] [more...]
package cli import ( "context" "fmt" "net/url" "time" libfossil "github.com/danmestas/go-libfossil" "github.com/danmestas/go-libfossil/internal/auth" ) // RepoCloneCmd clones a remote Fossil repository. type RepoCloneCmd struct { URL string `arg:"" optional:"" help:"Remote Fossil server URL"` Path string `arg:"" optional:"" help:"Local path for new repository file"` User string `short:"u" help:"Username for clone auth"` Pass string `short:"p" help:"Password for clone auth"` Invite string `help:"Invite token (from fossil invite)"` Timeout time.Duration `default:"10m" help:"Abort the clone if it runs longer than this; 0 disables the deadline"` Horizon string `help:"Clone a horizon store: elide blobs below this check-in, given as its full 40 or 64 digit hash; what each rule would hold needs a local repository ('repo cone cost') or a peer that exposes it out of band, as the origin's horizon-served URL may"` Leaves bool `help:"With --horizon, also hold every open leaf's ancestry back to the check-in, for merging across live branches offline; costly: on a repository with old unmerged branches that is nearly the whole history"` Structural bool `help:"With --horizon, also elide the manifests below the check-in, deriving their branch and tag state from the origin's seed"` Cone []string `name:"cone" help:"Clone a sparse store: fetch only this path's subtree (repeatable)"` ConeNot []string `name:"cone-not" help:"Carve this path out of a --cone (repeatable)"` SeedKey string `name:"seed-key" help:"The origin's public key, an SSH public key line or an OpenPGP fingerprint: with --structural, a horizon seed not signed by it is discarded and the clone falls back to the content horizon; kept as the horizon-seed-key setting"` } // cloneContext builds the context that bounds a clone. A positive timeout caps // the whole operation; zero (or negative) disables the deadline for a very // large repository, returning a plain cancellable context so the caller still // gets uniform cleanup through the returned cancel. func cloneContext(parent context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { if timeout > 0 { return context.WithTimeout(parent, timeout) } return context.WithCancel(parent) } func (c *RepoCloneCmd) Run(g *Globals) error { if c.Invite != "" { token, err := auth.DecodeInviteToken(c.Invite) if err != nil { return fmt.Errorf("invalid invite token: %w", err) } if c.URL == "" { c.URL = token.URL } if c.User == "" { c.User = token.Login } if c.Pass == "" { c.Pass = token.Password } } if c.URL == "" { return fmt.Errorf("URL required (provide as argument or via --invite token)") } if c.Path == "" { return fmt.Errorf("path required") } storeCone, err := cloneConeFlags(c.Cone, c.ConeNot, c.Horizon, c.Leaves, c.Structural) if err != nil { return err } if c.Leaves { warnLeavesCost() } ctx, cancel := cloneContext(context.Background(), c.Timeout) defer cancel() transport := libfossil.NewHTTPTransport(c.URL) opts := libfossil.CloneOpts{ User: c.User, Password: c.Pass, StoreCone: storeCone, SeedKey: c.SeedKey, } r, result, err := libfossil.Clone(ctx, c.Path, transport, opts) if err != nil { return fmt.Errorf("clone failed: %w", err) } defer r.Close() // fossil clone remembers its source as last-sync-url, the default peer // for later syncs, widening and required-content recovery. if err := r.SetConfig("last-sync-url", rememberedSyncURL(c.URL)); err != nil { return fmt.Errorf("clone: remember %s: %w", c.URL, err) } fmt.Printf("Cloned into %s\n", c.Path) fmt.Printf(" Rounds: %d\n", result.Rounds) fmt.Printf(" Blobs: %d\n", result.BlobsRecvd) fmt.Printf(" Project-code: %s\n", result.ProjectCode) if storeCone != nil { fmt.Printf(" Elided: %d\n", result.Elided) } if c.Structural { // Horizon §6.2.5: a client reports which mode a transfer used. if result.HorizonFallback { fmt.Println(" Horizon: content (the origin did not serve the structural horizon)") } else { fmt.Println(" Horizon: structural") } } if result.HorizonHistoryURL != "" { fmt.Printf(" History: %s\n", result.HorizonHistoryURL) } return nil } // rememberedSyncURL is url as fossil stores last-sync-url: a user name stays, // a password never does. An unparsable URL is stored as given, since the // clone already reached it. func rememberedSyncURL(raw string) string { u, err := url.Parse(raw) if err != nil || u.User == nil { return raw } u.User = url.User(u.User.Username()) return u.String() }