1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package ports declares every interface that crosses a layer, and the
4// parameter and result types of the use case interfaces.
5//
6// Outbound ports (ports.go) are what the use cases need from the outside
7// world: adapters implement them. Inbound ports (services.go) are the use
8// cases as the adapters that serve requests see them: the types of package usecase
9// implement them. Adapters and use cases depend on this package and on domain,
10// never on each other; internal/app wires the implementations. All the mocks are generated in
11// ports/mocks.
12package ports
13
14import (
15 "context"
16 "fmt"
17 "io"
18 "time"
19
20 "bnema.dev/ermite/internal/domain"
21)
22
23//go:generate go tool mockery
24
25// RepoMetaStore keeps repository metadata. It is the source of truth for
26// everything ermite owns about a repository except its Git data.
27type RepoMetaStore interface {
28 // Create stores repo. It returns domain.ErrAlreadyExists if a repository
29 // with that name exists, including when a concurrent Create wins.
30 Create(ctx context.Context, repo domain.Repo) error
31 // Get returns the repository, or domain.ErrNotFound.
32 Get(ctx context.Context, name domain.RepoName) (domain.Repo, error)
33 // List returns every repository ordered by name.
34 List(ctx context.Context) ([]domain.Repo, error)
35 // Update replaces the stored repository. It returns domain.ErrNotFound if
36 // there is none and domain.ErrConflict if another write changed the
37 // stored repository since it was read.
38 Update(ctx context.Context, repo domain.Repo) error
39 // Delete removes the repository and everything keyed to it: its merge
40 // requests, their comments and read positions, and its number sequence.
41 // A repository created again under the same name starts empty, with its
42 // numbers starting at 1. Deleting a missing one is not an error.
43 Delete(ctx context.Context, name domain.RepoName) error
44}
45
46// StagedID identifies a repository being created. It is opaque to callers.
47type StagedID string
48
49// RepoDirs is the result of listing the repository directories.
50type RepoDirs struct {
51 // Names are the directories that hold a valid repository name.
52 Names []domain.RepoName
53 // Invalid are the entries that cannot be a repository directory.
54 Invalid []string
55}
56
57// GitRepos manages bare repository directories.
58type GitRepos interface {
59 // Stage creates an empty bare repository outside the public directory
60 // and returns its identifier.
61 Stage(ctx context.Context) (StagedID, error)
62 // Publish moves a staged repository to its final place under name. It
63 // returns domain.ErrAlreadyExists if a directory for name exists; the
64 // staged repository is left in place for Discard.
65 Publish(ctx context.Context, id StagedID, name domain.RepoName) error
66 // Discard removes a staged repository. A missing one is not an error.
67 Discard(ctx context.Context, id StagedID) error
68 // PurgeStaging removes every staged repository left by a previous run.
69 PurgeStaging(ctx context.Context) error
70 // List returns the repository directories.
71 List(ctx context.Context) (RepoDirs, error)
72 // Trash moves the repository directory to the trash, where it can be
73 // recovered by hand. It returns domain.ErrNotFound if there is none.
74 Trash(ctx context.Context, name domain.RepoName) error
75 // DefaultBranch returns the branch HEAD points to (without refs/heads/)
76 // and whether that branch exists. It returns domain.ErrNotFound if there
77 // is no such repository.
78 DefaultBranch(ctx context.Context, name domain.RepoName) (branch string, exists bool, err error)
79 // HasBranch reports whether refs/heads/<branch> exists. A name that
80 // cannot be a branch does not exist. It returns domain.ErrNotFound if
81 // there is no such repository.
82 HasBranch(ctx context.Context, name domain.RepoName, branch string) (bool, error)
83 // HeadBranch returns the short name of the branch HEAD points to, whether
84 // or not that branch exists, and "" when HEAD is detached. It reads no
85 // refs, so it is cheap enough for a listing. It returns
86 // domain.ErrNotFound if there is no such repository.
87 HeadBranch(ctx context.Context, name domain.RepoName) (string, error)
88 // SetDefaultBranch points HEAD to the branch, which need not exist yet.
89 SetDefaultBranch(ctx context.Context, name domain.RepoName, branch string) error
90 // Maintain refreshes the commit-graph of the repository.
91 Maintain(ctx context.Context, name domain.RepoName) error
92}
93
94// RepoFreshener is implemented by a RepoReader that keeps repositories open
95// between reads. The use cases that write to a repository (a push, the
96// maintenance that follows it) call Refresh so that the reader picks the change
97// up at once instead of when its own timer says so. It is optional: reads
98// stay correct without it.
99type RepoFreshener interface {
100 // Refresh tells the reader that the repository changed on disk. It does
101 // not fail: a reader that cannot refresh finds out on its next read.
102 Refresh(ctx context.Context, name domain.RepoName)
103}
104
105// CommitQuery selects a page of history.
106type CommitQuery struct {
107 // Revision is where the history starts; the zero value is HEAD.
108 Revision domain.Revision
109 // Path restricts the history to the commits that changed this file or
110 // directory; the root means the whole history.
111 Path domain.TreePath
112 // Skip is the number of commits of the listing to leave out. A caller that
113 // pages passes the commit it got in CommitPage.Head as Revision and the
114 // number of commits already read, so that every page walks the same
115 // history even when the revision moves.
116 Skip int
117 // Limit is the number of commits wanted, 1 to MaxCommitLimit.
118 Limit int
119}
120
121// MaxCommitTimes bounds RepoReader.CommitTimes.
122const MaxCommitTimes = 100_000
123
124// MaxCommitLimit is the most commits one CommitQuery may ask for.
125const MaxCommitLimit = 1000
126
127// Validate checks the page size and the offset of the query. It returns an
128// error matching domain.ErrInvalidPageSize.
129func (q CommitQuery) Validate() error {
130 if q.Limit < 1 || q.Limit > MaxCommitLimit || q.Skip < 0 {
131 return fmt.Errorf("list commits: %w: limit %d, skip %d", domain.ErrInvalidPageSize, q.Limit, q.Skip)
132 }
133 return nil
134}
135
136// CommitPage is a page of history, newest first.
137type CommitPage struct {
138 // Head is the commit the revision resolved to.
139 Head domain.ObjectID
140 // Commits has at most Limit commits.
141 Commits []domain.Commit
142 // HasMore tells that commits follow the last one.
143 HasMore bool
144}
145
146// RepoReader reads the Git data of repositories: refs, trees, blobs, history
147// and diffs. A repository that has no directory gives domain.ErrNotFound; so
148// do an unknown revision, an empty repository (which has no commit) and a
149// path that does not exist at the revision.
150type RepoReader interface {
151 // ListRefs returns HEAD, then the branches, then the tags, the last two
152 // ordered by name. HEAD is always listed: when its branch does not exist
153 // yet its ShortName and ids are empty.
154 ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error)
155 // ResolveRevision turns a revision into the commit it designates. A name
156 // that is both a branch and a tag means the branch.
157 ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error)
158 // ResolveRefPath splits a path such as "feature/x/dir/file" into the
159 // longest leading part that names a branch or a tag and the rest. When no
160 // ref matches, the first segment may be a commit id (full, or abbreviated
161 // to MinAbbrevLen or more). It returns domain.ErrNotFound if nothing
162 // matches.
163 ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error)
164 // GetTree lists the directory at path, at most domain.MaxTreeEntries
165 // entries (Tree.Truncated tells). With includeLastCommit, the first
166 // domain.MaxLastCommitEntries entries get their last commit. It returns
167 // domain.ErrNotDirectory if path names something else.
168 GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error)
169 // ListPaths lists the file and symlink paths of rev recursively in tree
170 // order, at most maxPaths paths and maxBytes bytes of paths (PathList.Truncated
171 // tells), skipping submodules and paths that are not addressable
172 // (domain.IsAddressablePath).
173 ListPaths(ctx context.Context, repo domain.RepoName, rev domain.Revision, maxPaths, maxBytes int) (domain.PathList, error)
174 // OpenBlob returns the description of the file at path and a reader of its
175 // content, which streams without holding the whole file in memory. The
176 // caller must close the reader. It returns domain.ErrNotFile if path names
177 // something else.
178 OpenBlob(ctx context.Context, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error)
179 // ListCommits returns a page of history.
180 ListCommits(ctx context.Context, repo domain.RepoName, q CommitQuery) (CommitPage, error)
181 // GetCommit returns a commit with its statistics and its diff against the
182 // first parent, bounded by limits.
183 GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error)
184 // CountCommits counts the commits reachable from head.
185 CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error)
186 // CommitTimes are the committer dates of the commits reachable from head
187 // committed at or after since, in no particular order; at most
188 // MaxCommitTimes, truncated tells there were more.
189 CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) (times []time.Time, truncated bool, err error)
190 // LatestTag is the most recently created tag (the commit date for a
191 // lightweight tag); ok is false when the repository has no tag.
192 LatestTag(ctx context.Context, repo domain.RepoName) (tag domain.TagInfo, ok bool, err error)
193}
194
195// HTTPBackend tells how to run `git http-backend` for one request.
196type HTTPBackend struct {
197 // Path is the git-http-backend executable.
198 Path string
199 // Dir is the working directory of the process.
200 Dir string
201 // Env is the complete environment of the process: the isolated Git
202 // environment, GIT_PROJECT_ROOT and the Git configuration of the request
203 // (GIT_CONFIG_COUNT/KEY/VALUE). The caller may append to it.
204 Env []string
205}
206
207// GitBackend provides the Git side of smart HTTP.
208type GitBackend interface {
209 // HTTPBackend returns how to run git http-backend. For a push it also
210 // enables http.receivepack and sets REMOTE_USER; a read request cannot
211 // reach receive-pack.
212 HTTPBackend(ctx context.Context, push bool) (HTTPBackend, error)
213}
214
215// GitArchive writes the archive of a commit.
216type GitArchive interface {
217 // Archive writes the tree of the commit as a gzip-compressed tar to w, all
218 // the paths below the directory prefix. It writes nothing and returns
219 // domain.ErrNotFound when the repository or the commit does not exist.
220 Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error
221}
222
223// TokenStore keeps the stored tokens and the index from secret hash to token.
224// The secret itself is never stored.
225type TokenStore interface {
226 // Create stores the token and its hash index in one write. It returns
227 // domain.ErrAlreadyExists if the id or the hash is taken.
228 Create(ctx context.Context, token domain.Token) error
229 // Get returns the token with that id, or domain.ErrNotFound.
230 Get(ctx context.Context, id string) (domain.Token, error)
231 // FindByHash returns the token whose secret has that hash, or
232 // domain.ErrNotFound.
233 FindByHash(ctx context.Context, hash domain.TokenHash) (domain.Token, error)
234 // List returns every token, oldest first.
235 List(ctx context.Context) ([]domain.Token, error)
236 // Revoke removes the token and its hash index in one write, or returns
237 // domain.ErrNotFound.
238 Revoke(ctx context.Context, id string) error
239 // Touch records that the token was used at the given time. It returns
240 // domain.ErrNotFound if the token is gone.
241 Touch(ctx context.Context, id string, at time.Time) error
242}
243
244// MergeRequestStore keeps the merge requests of the repositories, their
245// comments and the read position of the reviewer. A repository has no entry
246// until its first merge request. Create, SaveComment and SetRead return
247// domain.ErrNotFound when the repository does not exist, so that deleting a
248// repository leaves nothing for one created again under its name.
249type MergeRequestStore interface {
250 // Create stores the merge request under the next number of its
251 // repository, which it sets, and returns it. Numbers start at 1 and are
252 // never reused, even by a concurrent Create.
253 Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error)
254 // Get returns the merge request, or domain.ErrNotFound. Its Version
255 // tells Update which state it was read at.
256 Get(ctx context.Context, repo domain.RepoName, number int64) (domain.MergeRequest, error)
257 // List returns every merge request of the repository, highest number
258 // first.
259 List(ctx context.Context, repo domain.RepoName) ([]domain.MergeRequest, error)
260 // ListOpen returns the open merge requests of the repository, highest
261 // number first, reading only the open ones (through an index).
262 ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.MergeRequest, error)
263 // CountOpen returns the number of open merge requests of the repository
264 // from the index of keys: no request is read.
265 CountOpen(ctx context.Context, repo domain.RepoName) (int, error)
266 // OpenBySource returns the numbers of the open merge requests whose source
267 // branch is one of branches (short names), from an index of keys: a push
268 // looks up the branches it moved, whatever the number of merge requests the
269 // repository ever had. No request is read. It stops after limit numbers (a
270 // limit of 0 or less means none) and reports truncated when there are more,
271 // without counting them; the context is checked between keys, and a
272 // cancelled one ends the lookup with its error and what was found so far.
273 OpenBySource(ctx context.Context, repo domain.RepoName, branches []string, limit int) (numbers []int64, truncated bool, err error)
274 // OpenByTarget is OpenBySource for the open merge requests whose target
275 // branch is one of branches.
276 OpenByTarget(ctx context.Context, repo domain.RepoName, branches []string, limit int) (numbers []int64, truncated bool, err error)
277 // Update replaces the stored merge request. It returns domain.ErrNotFound
278 // if there is none and domain.ErrConflict if another write changed it
279 // since mr was read.
280 Update(ctx context.Context, mr domain.MergeRequest) error
281 // ListComments returns the comments of the merge request, oldest first.
282 ListComments(ctx context.Context, repo domain.RepoName, number int64) ([]domain.Comment, error)
283 // GetComment returns one comment, or domain.ErrNotFound.
284 GetComment(ctx context.Context, repo domain.RepoName, number int64, id int64) (domain.Comment, error)
285 // SaveComment writes a comment and the merge request it belongs to in one
286 // batch: a new comment (Version 0) must not exist yet, an existing one
287 // must be unchanged since it was read, and so must mr. It returns
288 // domain.ErrConflict otherwise. The caller sets mr.CommentSeq and the
289 // comment's ID for a new comment, and mr.UpdatedAt.
290 SaveComment(ctx context.Context, mr domain.MergeRequest, c domain.Comment) error
291 // GetRead returns the head the reviewer last marked read, or the zero
292 // ObjectID when there is none.
293 GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error)
294 // SetRead records the head the reviewer has read. It returns
295 // domain.ErrNotFound when the repository does not exist, and
296 // domain.ErrConflict when concurrent writes to the repository keep it
297 // from landing.
298 SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error
299}
300
301// MergeGit is what merge requests need from the Git repositories beyond
302// RepoReader. Every method returns domain.ErrNotFound when the repository has
303// no directory.
304type MergeGit interface {
305 // BranchHead returns the commit refs/heads/<branch> points to, or
306 // domain.ErrNotFound when there is no such branch.
307 BranchHead(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error)
308 // HasCommit reports whether the repository holds that commit.
309 HasCommit(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (bool, error)
310 // MergeBase returns the best common ancestor of two commits, or
311 // domain.ErrNotFound when they share no history.
312 MergeBase(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (domain.ObjectID, error)
313 // IsAncestor reports whether ancestor is reachable from descendant, a
314 // commit being its own ancestor.
315 IsAncestor(ctx context.Context, repo domain.RepoName, ancestor, descendant domain.ObjectID) (bool, error)
316 // ListRange returns the commits reachable from head and not from exclude
317 // ("exclude..head"), newest first, at most limit; more is true when the
318 // range holds more. A zero exclude means the whole history of head.
319 ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) (commits []domain.Commit, more bool, err error)
320 // DiffCommits returns the statistics and the diff from commit base to
321 // commit head (renames detected), bounded by limits.
322 DiffCommits(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error)
323 // CommitTree returns the tree of a commit.
324 CommitTree(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (domain.ObjectID, error)
325 // WriteCommit stores a commit object, given as the text of the object,
326 // and returns its id.
327 WriteCommit(ctx context.Context, repo domain.RepoName, object []byte) (domain.ObjectID, error)
328 // UpdateBranch moves refs/heads/<branch> from old to new. It returns
329 // domain.ErrConflict when the branch no longer points to old.
330 UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, new domain.ObjectID) error
331 // DeleteBranch deletes refs/heads/<branch> if it still points to old. It
332 // returns domain.ErrConflict when the branch moved or is gone.
333 DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error
334}
335
336// CommitSigner signs the commits ermite creates (ADR 0022). The private key
337// never leaves it.
338type CommitSigner interface {
339 // PublicKey returns the public key and its fingerprint.
340 PublicKey() domain.SigningPublicKey
341 // Sign signs the text of a commit object and returns the armored SSH
342 // signature (namespace "git") to put in its gpgsig header.
343 Sign(payload []byte) (string, error)
344}
345
346// SessionStore keeps what the browser sessions of the owner (ADR 0018) need
347// to survive a restart: the key that signs them and the epoch that voids them.
348type SessionStore interface {
349 // Key returns the 32-byte secret that signs the session cookies. The first
350 // call on an empty store generates and stores it; a restart keeps it.
351 Key(ctx context.Context) ([]byte, error)
352 // Epoch returns the current epoch, 0 until the first BumpEpoch. A session
353 // is valid only while its epoch is the current one.
354 Epoch(ctx context.Context) (uint64, error)
355 // BumpEpoch increments the epoch, which ends every session, and returns
356 // the new value.
357 BumpEpoch(ctx context.Context) (uint64, error)
358}
359
360// PasskeyStore keeps the owner's passkeys (ADR 0026) and the WebAuthn user
361// handle they belong to.
362type PasskeyStore interface {
363 // UserHandle returns the 32-byte WebAuthn user handle of the owner. The
364 // first call on an empty store generates and stores it; it never changes.
365 UserHandle(ctx context.Context) ([]byte, error)
366 // List returns every passkey, oldest first.
367 List(ctx context.Context) ([]domain.Passkey, error)
368 // Get returns the passkey with that credential id, or domain.ErrNotFound.
369 Get(ctx context.Context, id []byte) (domain.Passkey, error)
370 // Create stores the passkey. It returns domain.ErrAlreadyExists if the
371 // credential id is taken. It does not bound the number of passkeys: the
372 // use case checks domain.MaxPasskeys.
373 Create(ctx context.Context, p domain.Passkey) error
374 // Update replaces the stored passkey (sign count, backup state, last
375 // use), or returns domain.ErrNotFound.
376 Update(ctx context.Context, p domain.Passkey) error
377 // Delete removes the passkey, or returns domain.ErrNotFound.
378 Delete(ctx context.Context, id []byte) error
379}
380
381// PasskeyVerifier runs the WebAuthn ceremonies (ADR 0026). It hides the
382// WebAuthn library: the ceremony data it hands out is opaque to the caller,
383// which keeps it between the begin and the finish of a ceremony and passes it
384// back untouched. Every verification failure is domain.ErrUnauthenticated,
385// wrapped with a reason meant for logs, not for callers.
386type PasskeyVerifier interface {
387 // BeginRegistration starts the registration of a passkey for the owner.
388 // options is the JSON of the credential creation options for the browser
389 // ({"publicKey": {...}}); existing passkeys are excluded.
390 BeginRegistration(userHandle []byte, existing []domain.Passkey) (options []byte, ceremony []byte, err error)
391 // FinishRegistration checks the browser's response (the JSON of
392 // PublicKeyCredential.toJSON()) and returns the new passkey, without a name
393 // or creation time.
394 FinishRegistration(userHandle []byte, ceremony, response []byte) (domain.Passkey, error)
395 // BeginLogin starts a discoverable login. options is the JSON of the
396 // credential request options for the browser ({"publicKey": {...}}).
397 BeginLogin() (options []byte, ceremony []byte, err error)
398 // FinishLogin checks the browser's response and returns the stored passkey
399 // updated with the sign count and backup state the response reports. lookup
400 // returns domain.ErrNotFound for an unknown credential id. A counter that
401 // does not advance is not an error here: the returned SignCount is then the
402 // stored one, and the caller, which holds the lock that orders the logins,
403 // refuses it.
404 FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error)
405}
406
407// EventBus fans events out to in-process subscribers.
408type EventBus interface {
409 // Publish hands the event to every subscriber without blocking. A
410 // subscriber that cannot keep up loses events and receives a
411 // domain.EventGap.
412 Publish(ctx context.Context, ev domain.Event)
413 // Subscribe returns a channel of events published from now on. The
414 // channel is closed when ctx ends.
415 Subscribe(ctx context.Context) <-chan domain.Event
416}
417
418// Clock tells the time.
419type Clock interface {
420 Now() time.Time
421}