Merge 4d9eaac4572380c0442cdd5c443314a3eda9d517 into 453499267f41ae8b3e4bd97a7445ec1c9de27425
102 files · +2491 -1408
M docs/merge-requests.md +13 -0
| @@ -91,3 +91,16 @@ A root note can carry a verdict: `--approve` or `--request-changes`. The text ma | |||
| 91 | 91 | - `mr show` lists the latest verdict of each author (`verdicts` in `--json`). | |
| 92 | 92 | - An approval is stale once the branch head differs from the head it was given on. Changes requested stay current until their author gives a new verdict, whatever is pushed. | |
| 93 | 93 | - Verdicts are immutable: editing or deleting the note keeps its verdict. Only a new verdict of the same author replaces it. | |
| 94 | + | ||
| 95 | + | ## Shared read position and history queries | |
| 96 | + | ||
| 97 | + | The read position is shared, not per principal. `MergeRequestDetail.HeadUnread` | |
| 98 | + | is a fact: the nonzero head differs from that position. The web presentation | |
| 99 | + | still decides who sees the mark-read button. | |
| 100 | + | ||
| 101 | + | Repository comparisons return at most `ports.MaxCompareCommits` (500) commits. | |
| 102 | + | Push summaries count at most `ports.MaxPushSummaryCommits` (100) commits and | |
| 103 | + | report whether more exist; ancestry, rather than the bounded list, detects a | |
| 104 | + | rewrite. Merge-request details include at most `ports.MaxMergeRequestCommits` | |
| 105 | + | (100) commits. These history queries remain on the merge-request service | |
| 106 | + | because it owns the merge-git port. | |
M go.mod +1 -1
| @@ -64,7 +64,7 @@ require ( | |||
| 64 | 64 | github.com/bep/godartsass/v2 v2.5.0 // indirect | |
| 65 | 65 | github.com/bep/golibsass v1.2.0 // indirect | |
| 66 | 66 | github.com/bits-and-blooms/bitset v1.24.5 // indirect | |
| 67 | - | github.com/bnema/hexcheck v0.3.1 // indirect | |
| 67 | + | github.com/bnema/hexcheck v0.4.0 // indirect | |
| 68 | 68 | github.com/brunoga/deep v1.3.1 // indirect | |
| 69 | 69 | github.com/bufbuild/buf v1.73.0 // indirect | |
| 70 | 70 | github.com/bufbuild/protocompile v0.14.2-0.20260910151042-7436f7c76201 // indirect | |
M go.sum +2 -2
| @@ -108,8 +108,8 @@ github.com/bits-and-blooms/bitset v1.24.5 h1:654xBVHc23gJMAgOTkPNoCVfiRxuIOAUnAZ | |||
| 108 | 108 | github.com/bits-and-blooms/bitset v1.24.5/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= | |
| 109 | 109 | github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= | |
| 110 | 110 | github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= | |
| 111 | - | github.com/bnema/hexcheck v0.3.1 h1:Apv7w0KmsfbOBOq/r1jPlENX3Z7ukAEPlAxvWMTTd3I= | |
| 112 | - | github.com/bnema/hexcheck v0.3.1/go.mod h1:8KuFStFhn1qNKDBrUvxOB5VzSP/uuePrURqPhq2KVAk= | |
| 111 | + | github.com/bnema/hexcheck v0.4.0 h1:VwzikMw5uCRvyf+FcI+O67TXovw7iwqvn7dSOUCJs7c= | |
| 112 | + | github.com/bnema/hexcheck v0.4.0/go.mod h1:8KuFStFhn1qNKDBrUvxOB5VzSP/uuePrURqPhq2KVAk= | |
| 113 | 113 | github.com/bnema/kvconf v0.1.2 h1:1hDKL9jkQSBdR9dSKbwTONhUUBfJgAdh5vu+yFHOb70= | |
| 114 | 114 | github.com/bnema/kvconf v0.1.2/go.mod h1:+P2X9I/sLx+Lfir45s9a7WA0GnC22WgKbZWE9LK6lcU= | |
| 115 | 115 | github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU= | |
M internal/adapters/cilogs/cilogs.go +2 -0
| @@ -244,6 +244,8 @@ func (s *Store) Count(_ context.Context, ref ports.CILogRef) (int64, error) { | |||
| 244 | 244 | } | |
| 245 | 245 | ||
| 246 | 246 | // Read returns lines from line number from. | |
| 247 | + | // | |
| 248 | + | //hexcheck:ignore suspicious-business-logic-in-adapter reads a bounded snapshot of stored log lines using sparse offsets and maps missing or out-of-range reads to port errors | |
| 247 | 249 | func (s *Store) Read(_ context.Context, ref ports.CILogRef, from int64, maxLines, maxBytes int) ([]domain.CILogLine, error) { | |
| 248 | 250 | if !validRef(ref) || from < 0 { | |
| 249 | 251 | return nil, domain.ErrNotFound | |
M internal/adapters/cisummaries/cisummaries.go +2 -0
| @@ -56,6 +56,8 @@ func (s *Store) path(ref ports.CILogRef, step int32) string { | |||
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | 58 | // Put stores the summary of a step, or removes it when markdown is empty. | |
| 59 | + | // | |
| 60 | + | //hexcheck:ignore suspicious-business-logic-in-adapter validates storage coordinates and atomically writes a step summary, deleting its file when empty | |
| 59 | 61 | func (s *Store) Put(_ context.Context, ref ports.CILogRef, step int32, markdown string) error { | |
| 60 | 62 | if !validRef(ref) || step < 1 || step > domain.CIMaxSteps { | |
| 61 | 63 | return domain.ErrNotFound | |
M internal/adapters/ermitegit/commitobjects.go +12 -11
| @@ -22,10 +22,10 @@ var _ ports.CommitObjectReader = (*Adapter)(nil) | |||
| 22 | 22 | // always serves it, and the wrapped reader only the repositories the library | |
| 23 | 23 | // cannot read. | |
| 24 | 24 | // | |
| 25 | - | //hexcheck:ignore suspicious-business-logic-in-adapter applies the limits of the port and of the domain | |
| 25 | + | //hexcheck:ignore suspicious-business-logic-in-adapter streams object headers and bytes; eligibility, deduplication and byte limits are decided by domain.CommitObjectBudget, request validation by domain.ValidateCommitObjects | |
| 26 | 26 | func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) { | |
| 27 | - | if len(ids) > ports.MaxCommitLimit { | |
| 28 | - | return nil, fmt.Errorf("%w: %d commit objects asked, at most %d", domain.ErrInvalid, len(ids), ports.MaxCommitLimit) | |
| 27 | + | if err := domain.ValidateCommitObjects(ids, ports.MaxCommitLimit); err != nil { | |
| 28 | + | return nil, err | |
| 29 | 29 | } | |
| 30 | 30 | cli := func() (map[domain.ObjectID][]byte, error) { | |
| 31 | 31 | r, ok := a.cli.(ports.CommitObjectReader) | |
| @@ -36,12 +36,12 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i | |||
| 36 | 36 | } | |
| 37 | 37 | return serve(ctx, a, repo, func(r *git.Repo) (map[domain.ObjectID][]byte, error) { | |
| 38 | 38 | out := make(map[domain.ObjectID][]byte, len(ids)) | |
| 39 | - | var total int64 | |
| 39 | + | var budget domain.CommitObjectBudget | |
| 40 | 40 | for _, id := range ids { | |
| 41 | 41 | if err := ctx.Err(); err != nil { | |
| 42 | 42 | return nil, err | |
| 43 | 43 | } | |
| 44 | - | if _, done := out[id]; done { | |
| 44 | + | if budget.Seen(id) { | |
| 45 | 45 | continue | |
| 46 | 46 | } | |
| 47 | 47 | gid, err := git.ParseID(id.String()) | |
| @@ -55,12 +55,13 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i | |||
| 55 | 55 | if err != nil { | |
| 56 | 56 | return nil, err | |
| 57 | 57 | } | |
| 58 | - | if typ != git.TypeCommit || size > domain.MaxCommitObjectBytes { | |
| 59 | - | continue | |
| 60 | - | } | |
| 61 | - | if total+size > domain.MaxCommitObjectsBytes { | |
| 58 | + | take, stop := budget.Admit(id, typ == git.TypeCommit, size) | |
| 59 | + | if stop { | |
| 62 | 60 | break | |
| 63 | 61 | } | |
| 62 | + | if !take { | |
| 63 | + | continue | |
| 64 | + | } | |
| 64 | 65 | typ, data, err := r.ReadObject(gid, nil) | |
| 65 | 66 | if errors.Is(err, git.ErrNotFound) { | |
| 66 | 67 | continue // removed meanwhile | |
| @@ -68,9 +69,9 @@ func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, i | |||
| 68 | 69 | if err != nil { | |
| 69 | 70 | return nil, err | |
| 70 | 71 | } | |
| 71 | - | if typ == git.TypeCommit && len(data) <= domain.MaxCommitObjectBytes && total+int64(len(data)) <= domain.MaxCommitObjectsBytes { | |
| 72 | + | if take, _ := budget.Admit(id, typ == git.TypeCommit, int64(len(data))); take { | |
| 72 | 73 | out[id] = data | |
| 73 | - | total += int64(len(data)) | |
| 74 | + | budget.Charge(id, int64(len(data))) | |
| 74 | 75 | } | |
| 75 | 76 | } | |
| 76 | 77 | return out, nil | |
M internal/adapters/ermitegit/convert.go +25 -36
| @@ -27,8 +27,6 @@ func domainID(id git.ObjectID) (domain.ObjectID, error) { return domain.ParseObj | |||
| 27 | 27 | // listRefs lists HEAD, then the branches, then the tags, like gitcli: a tag | |
| 28 | 28 | // follows to its commit when it leads to one; HEAD takes the ids of its | |
| 29 | 29 | // branch or tag, and only the name of an unborn branch. | |
| 30 | - | // | |
| 31 | - | //hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain | |
| 32 | 30 | func listRefs(r *git.Repo) ([]domain.Ref, error) { | |
| 33 | 31 | var refs []domain.Ref | |
| 34 | 32 | byName := make(map[string]domain.Ref) | |
| @@ -121,55 +119,48 @@ func peelID(r *git.Repo, id git.ObjectID) (_ git.ObjectID, ok bool, _ error) { | |||
| 121 | 119 | return commit, true, nil | |
| 122 | 120 | } | |
| 123 | 121 | ||
| 124 | - | // resolveRevision tries the candidates of gitcli in the same order, which | |
| 125 | - | // the port documents: HEAD is HEAD; a full object id is an object before it | |
| 126 | - | // is a ref; then the branch, then the tag; then a full ref name and an | |
| 127 | - | // abbreviated id as written. It does not use Repo.Resolve, whose order is | |
| 128 | - | // git's own (tags before branches, no ref for a missing full id). | |
| 129 | - | // | |
| 130 | - | //hexcheck:ignore suspicious-business-logic-in-adapter tries the candidates of the port in its documented order, like gitcli; the rule is the port contract | |
| 122 | + | // resolveRevision performs the lookups ordered by the domain, rather than | |
| 123 | + | // Repo.Resolve, whose Git-specific resolution order differs from the port. | |
| 131 | 124 | func resolveRevision(r *git.Repo, rev domain.Revision) (resolved, error) { | |
| 132 | - | s := rev.String() | |
| 133 | - | if rev.IsHead() || s == "HEAD" { | |
| 134 | - | if c, ok, err := peelRef(r, "HEAD"); err != nil || ok { | |
| 135 | - | return resolved{commit: c}, err | |
| 136 | - | } | |
| 137 | - | return resolved{}, notFound(rev) | |
| 138 | - | } | |
| 139 | - | if rev.IsObjectID() { | |
| 140 | - | id, err := git.ParseID(s) | |
| 125 | + | for _, candidate := range rev.Candidates() { | |
| 126 | + | c, ok, err := lookupRevisionCandidate(r, candidate) | |
| 141 | 127 | if err != nil { | |
| 142 | 128 | return resolved{}, err | |
| 143 | 129 | } | |
| 144 | - | if c, ok, err := peelID(r, id); err != nil || ok { | |
| 145 | - | return resolved{commit: c}, err | |
| 146 | - | } | |
| 147 | - | } | |
| 148 | - | for _, name := range [...]string{headsPrefix + s, tagsPrefix + s} { | |
| 149 | - | if c, ok, err := peelRef(r, name); err != nil || ok { | |
| 150 | - | return resolved{commit: c, ref: name}, err | |
| 130 | + | if ok { | |
| 131 | + | return resolved{commit: c, ref: candidate.ResolvedRef()}, nil | |
| 151 | 132 | } | |
| 152 | 133 | } | |
| 153 | - | switch { | |
| 154 | - | case len(s) > 5 && s[:5] == "refs/": | |
| 155 | - | if c, ok, err := peelRef(r, s); err != nil || ok { | |
| 156 | - | return resolved{commit: c, ref: s}, err | |
| 134 | + | return resolved{}, notFound(rev) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // lookupRevisionCandidate translates one domain candidate to the library API. | |
| 138 | + | func lookupRevisionCandidate(r *git.Repo, candidate domain.RevisionCandidate) (git.ObjectID, bool, error) { | |
| 139 | + | switch candidate.Kind { | |
| 140 | + | case domain.RevisionCandidateHead, domain.RevisionCandidateRef: | |
| 141 | + | return peelRef(r, candidate.Name) | |
| 142 | + | case domain.RevisionCandidateObject: | |
| 143 | + | id, err := git.ParseID(candidate.Name) | |
| 144 | + | if err != nil { | |
| 145 | + | return git.ObjectID{}, false, err | |
| 157 | 146 | } | |
| 158 | - | case rev.IsAbbrevID(): | |
| 159 | - | id, err := r.ResolveCommitPrefix(s) | |
| 147 | + | return peelID(r, id) | |
| 148 | + | case domain.RevisionCandidateAbbrevID: | |
| 149 | + | id, err := r.ResolveCommitPrefix(candidate.Name) | |
| 160 | 150 | if err == nil { | |
| 161 | 151 | // The prefix may name an annotated tag: git peels it. | |
| 162 | 152 | id, err = r.PeelToCommit(id) | |
| 163 | 153 | } | |
| 164 | 154 | switch { | |
| 165 | 155 | case err == nil: | |
| 166 | - | return resolved{commit: id}, nil | |
| 156 | + | return id, true, nil | |
| 167 | 157 | case errors.Is(err, git.ErrNotFound), errors.Is(err, git.ErrAmbiguous), errors.Is(err, git.ErrWrongType): | |
| 158 | + | return git.ObjectID{}, false, nil | |
| 168 | 159 | default: | |
| 169 | - | return resolved{}, err | |
| 160 | + | return git.ObjectID{}, false, err | |
| 170 | 161 | } | |
| 171 | 162 | } | |
| 172 | - | return resolved{}, notFound(rev) | |
| 163 | + | return git.ObjectID{}, false, fmt.Errorf("unknown revision candidate kind %d", candidate.Kind) | |
| 173 | 164 | } | |
| 174 | 165 | ||
| 175 | 166 | func notFound(rev domain.Revision) error { | |
| @@ -477,8 +468,6 @@ func fillLastCommits(ctx context.Context, r *git.Repo, commit domain.ObjectID, d | |||
| 477 | 468 | // The ids, the names, the emails and the message share one backing string, cut | |
| 478 | 469 | // into substrings: one allocation for the text of a commit instead of one per | |
| 479 | 470 | // field. | |
| 480 | - | // | |
| 481 | - | //hexcheck:ignore suspicious-business-logic-in-adapter converts ermite-git values into domain values; the limits and the orders come from domain | |
| 482 | 471 | func convertCommit(c *git.Commit) (domain.Commit, error) { | |
| 483 | 472 | if len(c.Encoding) > 0 && !isUTF8Label(c.Encoding) { | |
| 484 | 473 | return domain.Commit{}, errFallback | |
M internal/adapters/ermitegit/ermitegit.go +1 -1
| @@ -291,7 +291,7 @@ func (a *Adapter) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain. | |||
| 291 | 291 | ||
| 292 | 292 | // ResolveRevision turns a revision into a commit. | |
| 293 | 293 | // | |
| 294 | - | //hexcheck:ignore suspicious-business-logic-in-adapter routes a call by a code constant; rules and limits come from domain and ports | |
| 294 | + | //hexcheck:ignore suspicious-business-logic-in-adapter selects the configured CLI or library backend, with a separate route for abbreviated IDs; resolveRevision calls domain.Revision.Candidates for resolution order | |
| 295 | 295 | func (a *Adapter) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) { | |
| 296 | 296 | cli := func() (domain.ResolvedRevision, error) { return a.cli.ResolveRevision(ctx, repo, rev) } | |
| 297 | 297 | route := a.routes.ResolveRevision | |
M internal/adapters/gitcli/archive.go +0 -2
| @@ -28,8 +28,6 @@ var _ ports.GitArchive = (*Git)(nil) | |||
| 28 | 28 | // not by a gzip binary. A client that goes away ends ctx, which kills git. | |
| 29 | 29 | // Nothing is written to w when the commit does not exist | |
| 30 | 30 | // (domain.ErrNotFound). | |
| 31 | - | // | |
| 32 | - | //hexcheck:ignore suspicious-business-logic-in-adapter runs git archive and compresses its output: a process concern, access and naming are the use case's | |
| 33 | 31 | func (g *Git) Archive(ctx context.Context, repo domain.RepoName, commit domain.ObjectID, prefix string, w io.Writer) error { | |
| 34 | 32 | dir, err := g.repoDir(repo) | |
| 35 | 33 | if err != nil { | |
M internal/adapters/gitcli/changed.go +0 -4
| @@ -21,8 +21,6 @@ const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" | |||
| 21 | 21 | // renames not detected (a rename lists both paths, as GitHub's push filters | |
| 22 | 22 | // see it), and stops reading after max paths: truncated tells there were more. | |
| 23 | 23 | // A zero base diffs against the empty tree. | |
| 24 | - | // | |
| 25 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads the path list git prints; the bound comes from the port | |
| 26 | 24 | func (g *Git) ChangedPaths(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limit int) ([]string, bool, error) { | |
| 27 | 25 | if limit < 1 || head.IsZero() { | |
| 28 | 26 | return nil, false, fmt.Errorf("changed paths: %w: limit %d", domain.ErrInvalid, limit) | |
| @@ -63,8 +61,6 @@ func (g *Git) ChangedPaths(ctx context.Context, repo domain.RepoName, base, head | |||
| 63 | 61 | // (refs/heads/*), newest first, at most limit. It runs | |
| 64 | 62 | // `git log --not --branches --not <head>`: tags do not count, so the commit of | |
| 65 | 63 | // another tag that no branch has is listed. | |
| 66 | - | // | |
| 67 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port | |
| 68 | 64 | func (g *Git) CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) { | |
| 69 | 65 | if head.IsZero() { | |
| 70 | 66 | return nil, false, fmt.Errorf("commits off branches: %w: no head", domain.ErrInvalid) | |
M internal/adapters/gitcli/commitobjects.go +11 -15
| @@ -19,15 +19,10 @@ var _ ports.CommitObjectReader = (*Git)(nil) | |||
| 19 | 19 | // that would take the total over domain.MaxCommitObjectsBytes (known from | |
| 20 | 20 | // `info`, before any content is read, so the stream stays consistent). | |
| 21 | 21 | // | |
| 22 | - | //hexcheck:ignore suspicious-business-logic-in-adapter applies the limits of the port and of the domain | |
| 22 | + | //hexcheck:ignore suspicious-business-logic-in-adapter streams object headers and bytes; eligibility, deduplication and byte limits are decided by domain.CommitObjectBudget, request validation by domain.ValidateCommitObjects | |
| 23 | 23 | func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) { | |
| 24 | - | if len(ids) > ports.MaxCommitLimit { | |
| 25 | - | return nil, fmt.Errorf("%w: %d commit objects asked, at most %d", domain.ErrInvalid, len(ids), ports.MaxCommitLimit) | |
| 26 | - | } | |
| 27 | - | for _, id := range ids { | |
| 28 | - | if id.IsZero() { | |
| 29 | - | return nil, fmt.Errorf("%w: empty commit id", domain.ErrInvalidObjectID) | |
| 30 | - | } | |
| 24 | + | if err := domain.ValidateCommitObjects(ids, ports.MaxCommitLimit); err != nil { | |
| 25 | + | return nil, err | |
| 31 | 26 | } | |
| 32 | 27 | dir, err := g.repoDir(repo) | |
| 33 | 28 | if err != nil { | |
| @@ -36,12 +31,12 @@ func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids [ | |||
| 36 | 31 | out := make(map[domain.ObjectID][]byte, len(ids)) | |
| 37 | 32 | err = g.cat.do(ctx, dir, func(c *catConn) error { | |
| 38 | 33 | clear(out) // a retry on a fresh process starts over | |
| 39 | - | var total int64 | |
| 34 | + | var budget domain.CommitObjectBudget | |
| 40 | 35 | for _, id := range ids { | |
| 41 | 36 | if err := ctx.Err(); err != nil { | |
| 42 | 37 | return err | |
| 43 | 38 | } | |
| 44 | - | if _, done := out[id]; done { | |
| 39 | + | if budget.Seen(id) { | |
| 45 | 40 | continue | |
| 46 | 41 | } | |
| 47 | 42 | h, err := c.info(id.String()) | |
| @@ -51,18 +46,19 @@ func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids [ | |||
| 51 | 46 | if err != nil { | |
| 52 | 47 | return err | |
| 53 | 48 | } | |
| 54 | - | if h.Type != "commit" || h.Size > domain.MaxCommitObjectBytes { | |
| 55 | - | continue | |
| 56 | - | } | |
| 57 | - | if total+h.Size > domain.MaxCommitObjectsBytes { | |
| 49 | + | take, stop := budget.Admit(id, h.Type == "commit", h.Size) | |
| 50 | + | if stop { | |
| 58 | 51 | break | |
| 59 | 52 | } | |
| 53 | + | if !take { | |
| 54 | + | continue | |
| 55 | + | } | |
| 60 | 56 | _, data, err := c.contents(id.String(), domain.MaxCommitObjectBytes) | |
| 61 | 57 | if err != nil { | |
| 62 | 58 | return err | |
| 63 | 59 | } | |
| 64 | 60 | out[id] = data | |
| 65 | - | total += int64(len(data)) | |
| 61 | + | budget.Charge(id, int64(len(data))) | |
| 66 | 62 | } | |
| 67 | 63 | return nil | |
| 68 | 64 | }) | |
M internal/adapters/gitcli/diff.go +15 -47
| @@ -32,8 +32,6 @@ const maxDiffLine = 64 << 10 | |||
| 32 | 32 | // Statistics come from the line counts, binary files from their "-" counts. | |
| 33 | 33 | // The patch is parsed as it streams: nothing beyond the limits is kept, and | |
| 34 | 34 | // git is stopped once nothing more is needed. | |
| 35 | - | // | |
| 36 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git diff-tree output into the domain diff, applying the limits the port passes in | |
| 37 | 35 | func (g *Git) GetCommit(ctx context.Context, repo domain.RepoName, rev domain.Revision, limits domain.DiffLimits) (domain.CommitDetail, error) { | |
| 38 | 36 | dir, err := g.repoDir(repo) | |
| 39 | 37 | if err != nil { | |
| @@ -91,7 +89,7 @@ type rawEntry struct { | |||
| 91 | 89 | ||
| 92 | 90 | // readDiff parses the output of diff-tree --raw --numstat -p. | |
| 93 | 91 | // | |
| 94 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values, applying the limits the port passes in | |
| 92 | + | //hexcheck:ignore suspicious-business-logic-in-adapter streams raw, numstat and patch sections; domain.DiffBudget decides window and hunk eligibility and accounts retained lines and bytes; adapter only skips sections and stops the process | |
| 95 | 93 | func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.DiffStats, error) { | |
| 96 | 94 | entries, err := readRaw(r) | |
| 97 | 95 | if err != nil { | |
| @@ -107,23 +105,17 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||
| 107 | 105 | stats.Deletions += entries[i].del | |
| 108 | 106 | } | |
| 109 | 107 | var diff domain.Diff | |
| 110 | - | if limits.MaxFiles > 0 && len(entries) > limits.MaxFiles { | |
| 111 | - | entries = entries[:limits.MaxFiles] | |
| 112 | - | diff.Truncated = true | |
| 113 | - | } | |
| 114 | - | // Only the files of the window [from, to) get their hunks; the others are | |
| 115 | - | // listed as they are, neither truncated nor counted in the budgets. | |
| 116 | - | from, to := max(limits.From, 0), len(entries) | |
| 117 | - | if limits.To > 0 { | |
| 118 | - | to = min(limits.To, len(entries)) | |
| 119 | - | } | |
| 108 | + | budget := domain.NewDiffBudget(limits) | |
| 109 | + | kept, from, to, truncated := budget.Window(len(entries)) | |
| 110 | + | entries = entries[:kept] | |
| 111 | + | diff.Truncated = truncated | |
| 120 | 112 | ||
| 121 | 113 | // The patch holds one section per file, two for a type change, in the | |
| 122 | 114 | // order of the entries. Entries without lines to show (binary, pure | |
| 123 | 115 | // rename, mode change) have a section without hunks. | |
| 124 | 116 | lastRead := -1 | |
| 125 | 117 | for i := from; i < to; i++ { | |
| 126 | - | if wantsHunks(&entries[i], limits, limits.MaxTotalLines, limits.MaxTotalBytes) { | |
| 118 | + | if budget.WantsHunks(entries[i].file.Binary, entries[i].added+entries[i].del) { | |
| 127 | 119 | lastRead = i | |
| 128 | 120 | } | |
| 129 | 121 | } | |
| @@ -133,8 +125,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||
| 133 | 125 | _, _ = r.Discard(1) | |
| 134 | 126 | } | |
| 135 | 127 | } | |
| 136 | - | pr := &patchReader{lr: newLineReader(r, maxDiffLine), bytesLeft: limits.MaxTotalBytes} | |
| 137 | - | budget := limits.MaxTotalLines | |
| 128 | + | pr := &patchReader{lr: newLineReader(r, maxDiffLine), budget: budget} | |
| 138 | 129 | diff.Files = make([]domain.FileDiff, 0, len(entries)) | |
| 139 | 130 | for i := range entries { | |
| 140 | 131 | e := &entries[i] | |
| @@ -144,21 +135,19 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||
| 144 | 135 | } | |
| 145 | 136 | inWindow := i >= from && i < to | |
| 146 | 137 | switch { | |
| 147 | - | case !inWindow, !changedLines(e): | |
| 138 | + | case !inWindow, !pr.budget.ChangedLines(e.file.Binary, e.added+e.del): | |
| 148 | 139 | // Nothing to show. | |
| 149 | - | case !wantsHunks(e, limits, budget, pr.bytesLeft): | |
| 140 | + | case !pr.budget.WantsHunks(e.file.Binary, e.added+e.del): | |
| 150 | 141 | e.file.Truncated = true | |
| 151 | 142 | default: | |
| 152 | - | bytesBefore := pr.bytesLeft | |
| 153 | - | hunks, lines, truncated, err := pr.file(sections, min(limits.MaxFileLines, budget)) | |
| 143 | + | before := pr.budget | |
| 144 | + | hunks, _, truncated, err := pr.file(sections, pr.budget.FileLines()) | |
| 154 | 145 | if err != nil { | |
| 155 | 146 | return domain.Diff{}, domain.DiffStats{}, err | |
| 156 | 147 | } | |
| 157 | 148 | e.file.Hunks, e.file.Truncated = hunks, truncated | |
| 158 | 149 | if truncated { | |
| 159 | - | pr.bytesLeft = bytesBefore // nothing of this file is kept | |
| 160 | - | } else { | |
| 161 | - | budget -= lines | |
| 150 | + | pr.budget = before // nothing of this file is kept | |
| 162 | 151 | } | |
| 163 | 152 | sections = 0 | |
| 164 | 153 | } | |
| @@ -177,20 +166,7 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||
| 177 | 166 | return diff, stats, nil | |
| 178 | 167 | } | |
| 179 | 168 | ||
| 180 | - | // changedLines reports whether the file has text lines in its patch. | |
| 181 | - | func changedLines(e *rawEntry) bool { return !e.file.Binary && e.added+e.del > 0 } | |
| 182 | - | ||
| 183 | - | // wantsHunks reports whether the lines of e can still be shown: the changed | |
| 184 | - | // lines alone, a lower bound of the hunk lines, must fit the per-file limit | |
| 185 | - | // and what remains of the total, and some text bytes must remain. | |
| 186 | - | func wantsHunks(e *rawEntry, limits domain.DiffLimits, linesLeft, bytesLeft int) bool { | |
| 187 | - | n := e.added + e.del | |
| 188 | - | return changedLines(e) && n <= limits.MaxFileLines && n <= linesLeft && bytesLeft > 0 | |
| 189 | - | } | |
| 190 | - | ||
| 191 | 169 | // readRaw reads the `--raw -z` records, which start with ':'. | |
| 192 | - | // | |
| 193 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values | |
| 194 | 170 | func readRaw(r *bufio.Reader) ([]rawEntry, error) { | |
| 195 | 171 | var entries []rawEntry | |
| 196 | 172 | for { | |
| @@ -241,8 +217,6 @@ func unexpectedEOF(err error) error { | |||
| 241 | 217 | ||
| 242 | 218 | // parseRawMeta parses ":<old mode> <new mode> <old id> <new id> <status>" and | |
| 243 | 219 | // returns the number of paths that follow. | |
| 244 | - | // | |
| 245 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values | |
| 246 | 220 | func parseRawMeta(meta string) (rawEntry, int, error) { | |
| 247 | 221 | f := strings.Fields(strings.TrimPrefix(meta, ":")) | |
| 248 | 222 | if len(f) != 5 || f[4] == "" { | |
| @@ -296,8 +270,6 @@ func parseRawMeta(meta string) (rawEntry, int, error) { | |||
| 296 | 270 | ||
| 297 | 271 | // readNumstat reads one `--numstat -z` record per entry. A binary file has | |
| 298 | 272 | // "-" counts. | |
| 299 | - | // | |
| 300 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values | |
| 301 | 273 | func readNumstat(r *bufio.Reader, entries []rawEntry) error { | |
| 302 | 274 | for i := range entries { | |
| 303 | 275 | tok, err := readToken(r) | |
| @@ -341,7 +313,7 @@ type patchReader struct { | |||
| 341 | 313 | eof bool | |
| 342 | 314 | cut bool // the last line read was cut | |
| 343 | 315 | ||
| 344 | - | bytesLeft int // text bytes still allowed in the diff | |
| 316 | + | budget domain.DiffBudget | |
| 345 | 317 | } | |
| 346 | 318 | ||
| 347 | 319 | const sectionMarker = "diff --git " | |
| @@ -434,8 +406,6 @@ func (p *patchReader) file(sections, max int) (hunks []domain.Hunk, lines int, t | |||
| 434 | 406 | ||
| 435 | 407 | // section reads the hunks of the current section up to the next marker, which | |
| 436 | 408 | // stays pending. It gives up (truncated) after more than max lines. | |
| 437 | - | // | |
| 438 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values, applying the limits the port passes in | |
| 439 | 409 | func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncated bool, err error) { | |
| 440 | 410 | var cur *domain.Hunk | |
| 441 | 411 | var oldN, newN int | |
| @@ -490,8 +460,8 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate | |||
| 490 | 460 | continue | |
| 491 | 461 | } | |
| 492 | 462 | lines++ | |
| 493 | - | p.bytesLeft -= len(text) | |
| 494 | - | if lines > max || p.bytesLeft < 0 { | |
| 463 | + | fits := p.budget.ChargeLine(len(text)) | |
| 464 | + | if lines > max || !fits { | |
| 495 | 465 | return nil, lines, true, nil | |
| 496 | 466 | } | |
| 497 | 467 | cur.Lines = append(cur.Lines, l) | |
| @@ -499,8 +469,6 @@ func (p *patchReader) section(max int) (hunks []domain.Hunk, lines int, truncate | |||
| 499 | 469 | } | |
| 500 | 470 | ||
| 501 | 471 | // parseHunkHeader parses "@@ -a[,b] +c[,d] @@[ header]". | |
| 502 | - | // | |
| 503 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values | |
| 504 | 472 | func parseHunkHeader(s string) (domain.Hunk, error) { | |
| 505 | 473 | bad := func() (domain.Hunk, error) { | |
| 506 | 474 | return domain.Hunk{}, fmt.Errorf("git diff-tree: unexpected hunk header %q", truncateText(s)) | |
M internal/adapters/gitcli/export_test.go +0 -6
| @@ -110,15 +110,9 @@ func (g *Git) CatFileWaiting() int { | |||
| 110 | 110 | return n | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | - | // MaxPushedCommits is the bound of the commits of a push, for the tests. | |
| 114 | - | const MaxPushedCommits = maxPushedCommits | |
| 115 | - | ||
| 116 | 113 | // ParseRevList reads rev-list output with the bounds of the guard replaced, and | |
| 117 | 114 | // returns the number of commits kept. | |
| 118 | 115 | func ParseRevList(r io.Reader, maxCommits, maxIDs int) (int, error) { | |
| 119 | 116 | c, err := parseRevList(bufio.NewReaderSize(r, 64*1024), maxCommits, maxIDs) | |
| 120 | 117 | return len(c), err | |
| 121 | 118 | } | |
| 122 | - | ||
| 123 | - | // MaxPushedObjects is the bound of the ids of the commits of a push. | |
| 124 | - | const MaxPushedObjects = maxPushedObjects | |
M internal/adapters/gitcli/merge.go +0 -16
| @@ -39,8 +39,6 @@ func parseID(out string) (domain.ObjectID, error) { | |||
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | 41 | // BranchHead resolves refs/heads/<branch>^{commit} with `cat-file`. | |
| 42 | - | // | |
| 43 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps a branch name to a git object name | |
| 44 | 42 | func (g *Git) BranchHead(ctx context.Context, repo domain.RepoName, branch string) (domain.ObjectID, error) { | |
| 45 | 43 | dir, err := g.repoDir(repo) | |
| 46 | 44 | if err != nil { | |
| @@ -95,8 +93,6 @@ func (g *Git) HasCommit(ctx context.Context, repo domain.RepoName, id domain.Obj | |||
| 95 | 93 | } | |
| 96 | 94 | ||
| 97 | 95 | // MergeBase runs `git merge-base`, which exits 1 when there is none. | |
| 98 | - | // | |
| 99 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps git exit codes to the domain not found error | |
| 100 | 96 | func (g *Git) MergeBase(ctx context.Context, repo domain.RepoName, a, b domain.ObjectID) (domain.ObjectID, error) { | |
| 101 | 97 | dir, err := g.repoDir(repo) | |
| 102 | 98 | if err != nil { | |
| @@ -129,8 +125,6 @@ func (g *Git) IsAncestor(ctx context.Context, repo domain.RepoName, ancestor, de | |||
| 129 | 125 | } | |
| 130 | 126 | ||
| 131 | 127 | // ListRange runs `git log <head> ^<exclude>`. | |
| 132 | - | // | |
| 133 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port | |
| 134 | 128 | func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head domain.ObjectID, limit int) ([]domain.Commit, bool, error) { | |
| 135 | 129 | var excluded []string | |
| 136 | 130 | if !exclude.IsZero() { | |
| @@ -142,8 +136,6 @@ func (g *Git) ListRange(ctx context.Context, repo domain.RepoName, exclude, head | |||
| 142 | 136 | // logCommits runs `git log` for the commits reachable from head: revArgs go | |
| 143 | 137 | // before the head (options such as --not), excluded after it. Newest first, | |
| 144 | 138 | // at most limit; more says there were others. | |
| 145 | - | // | |
| 146 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; the limit comes from the port | |
| 147 | 139 | func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int, revArgs, excluded []string) ([]domain.Commit, bool, error) { | |
| 148 | 140 | if limit < 1 || limit > ports.MaxCommitLimit { | |
| 149 | 141 | return nil, false, fmt.Errorf("list range: %w: limit %d", domain.ErrInvalidPageSize, limit) | |
| @@ -183,8 +175,6 @@ func (g *Git) logCommits(ctx context.Context, repo domain.RepoName, head domain. | |||
| 183 | 175 | } | |
| 184 | 176 | ||
| 185 | 177 | // DiffCommits reads the diff between two commits like GetCommit does. | |
| 186 | - | // | |
| 187 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads a git diff into the domain diff with the limits the port passes in | |
| 188 | 178 | func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limits domain.DiffLimits) (domain.DiffStats, domain.Diff, error) { | |
| 189 | 179 | dir, err := g.repoDir(repo) | |
| 190 | 180 | if err != nil { | |
| @@ -198,8 +188,6 @@ func (g *Git) DiffCommits(ctx context.Context, repo domain.RepoName, base, head | |||
| 198 | 188 | } | |
| 199 | 189 | ||
| 200 | 190 | // CommitTree runs `git rev-parse <commit>^{tree}`. | |
| 201 | - | // | |
| 202 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses the id git printed | |
| 203 | 191 | func (g *Git) CommitTree(ctx context.Context, repo domain.RepoName, id domain.ObjectID) (domain.ObjectID, error) { | |
| 204 | 192 | dir, err := g.repoDir(repo) | |
| 205 | 193 | if err != nil { | |
| @@ -229,8 +217,6 @@ func (g *Git) WriteCommit(ctx context.Context, repo domain.RepoName, object []by | |||
| 229 | 217 | // UpdateBranch runs `git update-ref refs/heads/<branch> <new> <old>`, which | |
| 230 | 218 | // takes the ref lock and refuses when the ref is not at old. A failure is | |
| 231 | 219 | // told from a moved branch by reading the branch again. | |
| 232 | - | // | |
| 233 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port | |
| 234 | 220 | func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch string, old, newID domain.ObjectID) error { | |
| 235 | 221 | dir, err := g.repoDir(repo) | |
| 236 | 222 | if err != nil { | |
| @@ -252,8 +238,6 @@ func (g *Git) UpdateBranch(ctx context.Context, repo domain.RepoName, branch str | |||
| 252 | 238 | // DeleteBranch runs `git update-ref -d refs/heads/<branch> <old>`, which takes | |
| 253 | 239 | // the ref lock and refuses when the ref is not at old. A failure is told from | |
| 254 | 240 | // a moved or deleted branch by reading the branch again. | |
| 255 | - | // | |
| 256 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps a refused update-ref to the conflict error of the port | |
| 257 | 241 | func (g *Git) DeleteBranch(ctx context.Context, repo domain.RepoName, branch string, old domain.ObjectID) error { | |
| 258 | 242 | dir, err := g.repoDir(repo) | |
| 259 | 243 | if err != nil { | |
M internal/adapters/gitcli/pull.go +0 -2
| @@ -18,8 +18,6 @@ var _ ports.PullGit = (*Git)(nil) | |||
| 18 | 18 | // database and touches no ref or index: it prints the tree of the result and | |
| 19 | 19 | // exits 0, or exits 1 when the merge conflicts. Commits with no common history | |
| 20 | 20 | // do not merge (GitHub makes no test merge for them either). | |
| 21 | - | // | |
| 22 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps git exit codes to the clean/conflict answer of the port | |
| 23 | 21 | func (g *Git) TestMerge(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID) (domain.ObjectID, bool, error) { | |
| 24 | 22 | dir, err := g.repoDir(repo) | |
| 25 | 23 | if err != nil { | |
A internal/adapters/gitcli/push_trees.go +400 -0
| @@ -0,0 +1,400 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package gitcli | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bufio" | |
| 7 | + | "context" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "path/filepath" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | var _ ports.PushTreeReader = (*Git)(nil) | |
| 20 | + | ||
| 21 | + | const ( | |
| 22 | + | maxDiffPath = 4096 | |
| 23 | + | maxRevListLine = 8192 | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // PushSubtrees peels push endpoints, walks new commits, and reads entries at path. | |
| 27 | + | func (g *Git) PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) { | |
| 28 | + | out := domain.PushSubtrees{Ends: map[string]domain.PushObjectType{}, Subtrees: map[string]domain.Subtree{}} | |
| 29 | + | dir, err := g.repoDir(repo) | |
| 30 | + | if err != nil { | |
| 31 | + | return out, err | |
| 32 | + | } | |
| 33 | + | extra, err := quarantineEnv(dir, objects) | |
| 34 | + | if err != nil { | |
| 35 | + | return out, err | |
| 36 | + | } | |
| 37 | + | var kept []domain.RefUpdate | |
| 38 | + | for _, up := range updates { | |
| 39 | + | if up.New != domain.ZeroOID { | |
| 40 | + | kept = append(kept, up) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | if len(kept) == 0 { | |
| 44 | + | return out, nil | |
| 45 | + | } | |
| 46 | + | return g.readPushSubtrees(ctx, dir, extra, kept, path, bounds, out) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // readPushSubtrees batches the endpoint and walked-object cat-file queries. | |
| 50 | + | func (g *Git) readPushSubtrees(ctx context.Context, dir string, extra []string, kept []domain.RefUpdate, path string, bounds domain.PushWalkBounds, out domain.PushSubtrees) (domain.PushSubtrees, error) { | |
| 51 | + | types, err := g.batchCheck(ctx, dir, extra, kept, "^{}", "%(objecttype)") | |
| 52 | + | if err != nil { | |
| 53 | + | return out, err | |
| 54 | + | } | |
| 55 | + | tips := pushCommitTips(kept, types, out.Ends) | |
| 56 | + | if len(tips) != 0 { | |
| 57 | + | out.Commits, err = g.newCommits(ctx, dir, extra, tips, bounds) | |
| 58 | + | if err != nil { | |
| 59 | + | return out, err | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ids := commitAndParentIDs(out.Commits) | |
| 63 | + | ws, err := g.subtreesAt(ctx, dir, extra, ids, path) | |
| 64 | + | if err != nil { | |
| 65 | + | return out, err | |
| 66 | + | } | |
| 67 | + | for id, w := range ws { | |
| 68 | + | out.Subtrees[id] = w | |
| 69 | + | } | |
| 70 | + | ids = pushEndSubtreeIDs(kept, out.Ends) | |
| 71 | + | ws, err = g.subtreesAt(ctx, dir, extra, ids, path) | |
| 72 | + | if err != nil { | |
| 73 | + | return out, err | |
| 74 | + | } | |
| 75 | + | for id, w := range ws { | |
| 76 | + | out.Subtrees[id] = w | |
| 77 | + | } | |
| 78 | + | return out, nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // pushCommitTips translates peeled endpoint answers and deduplicates rev-list tips. | |
| 82 | + | func pushCommitTips(kept []domain.RefUpdate, types []string, ends map[string]domain.PushObjectType) []string { | |
| 83 | + | var tips []string | |
| 84 | + | seen := map[string]bool{} | |
| 85 | + | for i, up := range kept { | |
| 86 | + | for j, id := range []string{up.Old, up.New} { | |
| 87 | + | if id != domain.ZeroOID { | |
| 88 | + | ends[id] = domain.PushObjectType(types[2*i+j]) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | if types[2*i+1] == "commit" && !seen[up.New] { | |
| 92 | + | seen[up.New] = true | |
| 93 | + | tips = append(tips, up.New) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | return tips | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // commitAndParentIDs deduplicates the object names for a cat-file batch. | |
| 100 | + | func commitAndParentIDs(commits []domain.PushedCommit) []string { | |
| 101 | + | var ids []string | |
| 102 | + | asked := map[string]bool{} | |
| 103 | + | for _, c := range commits { | |
| 104 | + | for _, id := range append([]domain.ObjectID{c.ID}, c.Parents...) { | |
| 105 | + | key := id.String() | |
| 106 | + | if !asked[key] { | |
| 107 | + | asked[key] = true | |
| 108 | + | ids = append(ids, key) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | return ids | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // pushEndSubtreeIDs omits blobs (not tree-ish) and includes HEAD only for creates. | |
| 116 | + | func pushEndSubtreeIDs(kept []domain.RefUpdate, ends map[string]domain.PushObjectType) []string { | |
| 117 | + | var ids []string | |
| 118 | + | for _, up := range kept { | |
| 119 | + | for _, id := range []string{up.Old, up.New} { | |
| 120 | + | if id != domain.ZeroOID && ends[id] != domain.PushObjectBlob { | |
| 121 | + | ids = append(ids, id) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | // HEAD is needed only for a new tree-ish ref. Keep it in the end batch. | |
| 126 | + | for _, up := range kept { | |
| 127 | + | if up.Old == domain.ZeroOID && ends[up.New] != domain.PushObjectBlob { | |
| 128 | + | ids = append(ids, domain.HeadKey) | |
| 129 | + | break | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | return ids | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // newCommits lists the commits reachable from the tips and from no ref | |
| 136 | + | // (`rev-list --parents <tips> --not --all`), at most bounds.Commits: more | |
| 137 | + | // fails closed. | |
| 138 | + | func (g *Git) newCommits(ctx context.Context, dir string, extra []string, tips []string, bounds domain.PushWalkBounds) ([]domain.PushedCommit, error) { | |
| 139 | + | if err := revListLimitsError(bounds); err != nil { | |
| 140 | + | return nil, err | |
| 141 | + | } | |
| 142 | + | args := readArgs(dir, append(append([]string{"rev-list", "--parents", "--max-count=" + strconv.Itoa(bounds.Commits+1)}, tips...), "--not", "--all")...) | |
| 143 | + | var out []domain.PushedCommit | |
| 144 | + | err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { | |
| 145 | + | var err error | |
| 146 | + | out, err = parseRevList(r, bounds.Commits, bounds.IDs) | |
| 147 | + | return err | |
| 148 | + | }) | |
| 149 | + | if err != nil { | |
| 150 | + | return nil, fmt.Errorf("commits of a push: %w", err) | |
| 151 | + | } | |
| 152 | + | return out, nil | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // revListLimitsError checks the numeric range of rev-list and parser limits. | |
| 156 | + | func revListLimitsError(bounds domain.PushWalkBounds) error { | |
| 157 | + | if bounds.Commits < 0 || bounds.IDs < 0 || bounds.Commits == int(^uint(0)>>1) { | |
| 158 | + | return fmt.Errorf("%w: invalid push walk bounds", domain.ErrInvalid) | |
| 159 | + | } | |
| 160 | + | return nil | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // parseRevList reads the lines of `rev-list --parents` ("<commit> <parent>..."): | |
| 164 | + | // at most maxCommits lines and maxIDs ids in all (a commit and each of its | |
| 165 | + | // parents count, repeats too). A line over maxRevListLine, a limit crossed or an | |
| 166 | + | // id that is not one is an error, and it is raised as soon as the line that | |
| 167 | + | // crosses it is read: nothing past it is read or kept. | |
| 168 | + | func parseRevList(r *bufio.Reader, maxCommits, maxIDs int) ([]domain.PushedCommit, error) { | |
| 169 | + | var out []domain.PushedCommit | |
| 170 | + | ids := 0 | |
| 171 | + | for { | |
| 172 | + | line, err := r.ReadSlice('\n') | |
| 173 | + | switch { | |
| 174 | + | case errors.Is(err, bufio.ErrBufferFull) || len(line) > maxRevListLine: | |
| 175 | + | return nil, fmt.Errorf("%w: a pushed commit has too many parents", domain.ErrInvalid) | |
| 176 | + | case errors.Is(err, io.EOF) && len(line) == 0: | |
| 177 | + | return out, nil | |
| 178 | + | case err != nil && !errors.Is(err, io.EOF): | |
| 179 | + | return nil, err | |
| 180 | + | } | |
| 181 | + | f := strings.Fields(string(line)) | |
| 182 | + | if len(f) == 0 { | |
| 183 | + | continue | |
| 184 | + | } | |
| 185 | + | if len(out) == maxCommits { | |
| 186 | + | return nil, fmt.Errorf("%w: the push brings more than %d commits", domain.ErrInvalid, maxCommits) | |
| 187 | + | } | |
| 188 | + | if ids += len(f); ids > maxIDs { | |
| 189 | + | return nil, fmt.Errorf("%w: the push brings commits with more than %d ids (commits and parents) in all", domain.ErrInvalid, maxIDs) | |
| 190 | + | } | |
| 191 | + | for _, id := range f { | |
| 192 | + | if _, err := domain.ParseObjectID(id); err != nil { | |
| 193 | + | return nil, fmt.Errorf("%w: unexpected rev-list output", domain.ErrInvalid) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | id, _ := domain.ParseObjectID(f[0]) | |
| 197 | + | c := domain.PushedCommit{ID: id} | |
| 198 | + | for _, p := range f[1:] { | |
| 199 | + | id, _ := domain.ParseObjectID(p) | |
| 200 | + | c.Parents = append(c.Parents, id) | |
| 201 | + | } | |
| 202 | + | out = append(out, c) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // subtreesAt reads the mode, id and type of entries without traversing them. | |
| 207 | + | func (g *Git) subtreesAt(ctx context.Context, dir string, extra []string, ids []string, path string) (map[string]domain.Subtree, error) { | |
| 208 | + | out := make(map[string]domain.Subtree, len(ids)) | |
| 209 | + | if len(ids) == 0 { | |
| 210 | + | return out, nil | |
| 211 | + | } | |
| 212 | + | specs := make([]string, len(ids)) | |
| 213 | + | for i, id := range ids { | |
| 214 | + | specs[i] = id + ":" + path | |
| 215 | + | } | |
| 216 | + | args := readArgs(dir, "cat-file", "--batch-check=%(objectmode) %(objectname) %(objecttype)") | |
| 217 | + | err := g.pipeWith(ctx, extra, strings.NewReader(strings.Join(specs, "\n")+"\n"), args, func(r *bufio.Reader) error { | |
| 218 | + | for _, key := range ids { | |
| 219 | + | line, err := r.ReadSlice('\n') | |
| 220 | + | if err != nil { | |
| 221 | + | return fmt.Errorf("read object answers: %w", err) | |
| 222 | + | } | |
| 223 | + | f := strings.Fields(string(line)) | |
| 224 | + | if len(f) != 3 { | |
| 225 | + | continue | |
| 226 | + | } // missing entry | |
| 227 | + | mode, err := strconv.ParseUint(f[0], 8, 32) | |
| 228 | + | if err != nil { | |
| 229 | + | continue | |
| 230 | + | } | |
| 231 | + | id, err := domain.ParseObjectID(f[1]) | |
| 232 | + | if err != nil { | |
| 233 | + | return err | |
| 234 | + | } | |
| 235 | + | kind := domain.EntryKindOf(uint32(mode)) | |
| 236 | + | if kind == domain.EntryDirectory && (f[0] != "040000" || f[2] != "tree") { | |
| 237 | + | kind = "" | |
| 238 | + | } | |
| 239 | + | out[key] = domain.Subtree{Kind: kind, ID: id} | |
| 240 | + | } | |
| 241 | + | return nil | |
| 242 | + | }) | |
| 243 | + | if err != nil { | |
| 244 | + | return nil, fmt.Errorf("subtrees of a push: %w", err) | |
| 245 | + | } | |
| 246 | + | return out, nil | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // FirstDifferentEntry streams the first differing top-level entry of two trees. | |
| 250 | + | func (g *Git) FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, oldID, newID domain.ObjectID) (string, error) { | |
| 251 | + | dir, err := g.repoDir(repo) | |
| 252 | + | if err != nil { | |
| 253 | + | return "", err | |
| 254 | + | } | |
| 255 | + | extra, err := quarantineEnv(dir, objects) | |
| 256 | + | if err != nil { | |
| 257 | + | return "", err | |
| 258 | + | } | |
| 259 | + | from := oldID.String() | |
| 260 | + | if from == "" { | |
| 261 | + | from = emptyTree | |
| 262 | + | } | |
| 263 | + | to := newID.String() | |
| 264 | + | args := readArgs(dir, "diff-tree", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, to) | |
| 265 | + | var name string | |
| 266 | + | err = g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { | |
| 267 | + | n, found, err := firstPath(r) | |
| 268 | + | name = n | |
| 269 | + | if err != nil || !found { | |
| 270 | + | return err // no name: git's exit status is checked, so a failure refuses | |
| 271 | + | } | |
| 272 | + | return errStop // the first name is all that is needed: stop git | |
| 273 | + | }) | |
| 274 | + | if err != nil { | |
| 275 | + | return "", fmt.Errorf("different tree entries %s..%s: %w", from, to, err) | |
| 276 | + | } | |
| 277 | + | return name, nil | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // firstPath reads the first NUL-terminated path of r; found is false at the end | |
| 281 | + | // of the output without one. A path longer than maxDiffPath is an error. | |
| 282 | + | func firstPath(r *bufio.Reader) (path string, found bool, err error) { | |
| 283 | + | var b []byte | |
| 284 | + | for { | |
| 285 | + | c, err := r.ReadByte() | |
| 286 | + | switch { | |
| 287 | + | case err != nil: | |
| 288 | + | return "", false, nil // the end of the output; a failing exit status is reported by pipe | |
| 289 | + | case c == 0: | |
| 290 | + | return string(b), true, nil | |
| 291 | + | case len(b) >= maxDiffPath: | |
| 292 | + | return "", false, fmt.Errorf("%w: a changed path is longer than %d bytes", domain.ErrInvalid, maxDiffPath) | |
| 293 | + | } | |
| 294 | + | b = append(b, c) | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // batchCheck asks `cat-file --batch-check=<format>` about <oid><suffix> for the | |
| 299 | + | // old (unless the ref is created) and the new end of each update and returns | |
| 300 | + | // the answers in that order, two per update, "" for the old end of a created | |
| 301 | + | // ref. With suffix "^{}" an object that is missing, malformed or neither a | |
| 302 | + | // commit, a tree nor a blob is an error (the push is refused); with any other | |
| 303 | + | // suffix an answer "<input> missing" is kept as the answer. | |
| 304 | + | func (g *Git) batchCheck(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, suffix, format string) ([]string, error) { | |
| 305 | + | var in strings.Builder | |
| 306 | + | want := 0 | |
| 307 | + | for _, up := range ups { | |
| 308 | + | for _, oid := range []string{up.Old, up.New} { | |
| 309 | + | if oid == domain.ZeroOID { | |
| 310 | + | continue | |
| 311 | + | } | |
| 312 | + | if _, err := domain.ParseObjectID(oid); err != nil { | |
| 313 | + | return nil, fmt.Errorf("%w: %v", domain.ErrInvalid, err) | |
| 314 | + | } | |
| 315 | + | in.WriteString(oid + suffix + "\n") | |
| 316 | + | want++ | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | got := make([]string, 0, want) | |
| 320 | + | args := readArgs(dir, "cat-file", "--batch-check="+format) | |
| 321 | + | err := g.pipeWith(ctx, extra, strings.NewReader(in.String()), args, func(r *bufio.Reader) error { | |
| 322 | + | for range want { | |
| 323 | + | line, err := r.ReadSlice('\n') | |
| 324 | + | if err != nil { | |
| 325 | + | if errors.Is(err, bufio.ErrBufferFull) { | |
| 326 | + | return fmt.Errorf("%w: unexpected cat-file output", domain.ErrInvalid) | |
| 327 | + | } | |
| 328 | + | return fmt.Errorf("read object answers: %w", err) | |
| 329 | + | } | |
| 330 | + | got = append(got, strings.TrimSuffix(string(line), "\n")) | |
| 331 | + | } | |
| 332 | + | return nil | |
| 333 | + | }) | |
| 334 | + | if err != nil { | |
| 335 | + | return nil, fmt.Errorf("objects of a push: %w", err) | |
| 336 | + | } | |
| 337 | + | out := make([]string, 0, 2*len(ups)) | |
| 338 | + | i := 0 | |
| 339 | + | for _, up := range ups { | |
| 340 | + | for _, oid := range []string{up.Old, up.New} { | |
| 341 | + | if oid == domain.ZeroOID { | |
| 342 | + | out = append(out, "") | |
| 343 | + | continue | |
| 344 | + | } | |
| 345 | + | if suffix == "^{}" { | |
| 346 | + | switch got[i] { | |
| 347 | + | case "commit", "tree", "blob": | |
| 348 | + | default: // "<input> missing", or a type that cannot be compared | |
| 349 | + | return nil, fmt.Errorf("%w: object %s of the push is missing or of an unexpected type", domain.ErrInvalid, oid) | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | out = append(out, got[i]) | |
| 353 | + | i++ | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | return out, nil | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | // quarantineEnv returns the environment that makes git read the objects of a | |
| 360 | + | // push in quarantine. Each directory must exist and, once symbolic links are | |
| 361 | + | // resolved, be inside the objects directory of the repository dir (itself | |
| 362 | + | // resolved): a hook request cannot send git to read anything else. The resolved | |
| 363 | + | // paths are the ones given to git. | |
| 364 | + | func quarantineEnv(repoDir string, o ports.PushObjects) ([]string, error) { | |
| 365 | + | if o.Dir == "" && o.Alternates == "" { | |
| 366 | + | return nil, nil | |
| 367 | + | } | |
| 368 | + | root, err := filepath.EvalSymlinks(filepath.Join(repoDir, "objects")) | |
| 369 | + | if err != nil { | |
| 370 | + | return nil, fmt.Errorf("resolve the objects directory of %s: %w", repoDir, err) | |
| 371 | + | } | |
| 372 | + | inside := func(p string) (string, bool) { | |
| 373 | + | if !filepath.IsAbs(p) { | |
| 374 | + | return "", false | |
| 375 | + | } | |
| 376 | + | real, err := filepath.EvalSymlinks(p) | |
| 377 | + | if err != nil { | |
| 378 | + | return "", false | |
| 379 | + | } | |
| 380 | + | rel, err := filepath.Rel(root, real) | |
| 381 | + | return real, err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | |
| 382 | + | } | |
| 383 | + | dir, ok := inside(o.Dir) | |
| 384 | + | if o.Dir == "" || !ok { | |
| 385 | + | return nil, fmt.Errorf("%w: the objects directory of the push is outside the repository", domain.ErrInvalid) | |
| 386 | + | } | |
| 387 | + | env := []string{"GIT_OBJECT_DIRECTORY=" + dir} | |
| 388 | + | if o.Alternates != "" { | |
| 389 | + | var alts []string | |
| 390 | + | for _, a := range filepath.SplitList(o.Alternates) { | |
| 391 | + | real, ok := inside(a) | |
| 392 | + | if !ok { | |
| 393 | + | return nil, fmt.Errorf("%w: an alternate objects directory of the push is outside the repository", domain.ErrInvalid) | |
| 394 | + | } | |
| 395 | + | alts = append(alts, real) | |
| 396 | + | } | |
| 397 | + | env = append(env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+strings.Join(alts, string(filepath.ListSeparator))) | |
| 398 | + | } | |
| 399 | + | return env, nil | |
| 400 | + | } | |
R internal/adapters/gitcli/workflow_guard_test.go → internal/adapters/gitcli/push_trees_test.go +158 -217 renamed, 53% similar
| @@ -51,7 +51,7 @@ func TestChangedWorkflow(t *testing.T) { | |||
| 51 | 51 | return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"} | |
| 52 | 52 | } | |
| 53 | 53 | changed := func(ups ...domain.RefUpdate) string { | |
| 54 | - | p, err := g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{}) | |
| 54 | + | p, err := changedWorkflow(g, ctx, f.Name, ups, ports.PushObjects{}) | |
| 55 | 55 | require.NoError(t, err) | |
| 56 | 56 | return p | |
| 57 | 57 | } | |
| @@ -68,11 +68,11 @@ func TestChangedWorkflow(t *testing.T) { | |||
| 68 | 68 | assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(docs)), up(oid(docs), oid(edited))), "the first ref that changes one") | |
| 69 | 69 | assert.Equal(t, "", changed(), "no update") | |
| 70 | 70 | ||
| 71 | - | _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{}) | |
| 71 | + | _, err := changedWorkflow(g, ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{}) | |
| 72 | 72 | assert.Error(t, err, "an object git does not have is a failure, not a pass") | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | - | func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) { | |
| 75 | + | func TestPushSubtreesReadsTheQuarantineOfThePushOnly(t *testing.T) { | |
| 76 | 76 | g, data := newGit(t) | |
| 77 | 77 | ctx := context.Background() | |
| 78 | 78 | f := readertest.NewFixture(t, data, "proj") | |
| @@ -90,23 +90,23 @@ func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) { | |||
| 90 | 90 | "an alternate outside": {Dir: objects + "/incoming-1", Alternates: "/tmp"}, | |
| 91 | 91 | "alternates without a dir": {Alternates: objects}, | |
| 92 | 92 | } { | |
| 93 | - | _, err := g.ChangedWorkflow(ctx, f.Name, up, o) | |
| 93 | + | _, err := g.PushSubtrees(ctx, f.Name, up, o, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 94 | 94 | assert.ErrorIs(t, err, domain.ErrInvalid, name) | |
| 95 | 95 | } | |
| 96 | 96 | // An existing directory inside the repository is accepted as a location | |
| 97 | 97 | // (the object is missing: a refusal of the object, not of the path). | |
| 98 | 98 | require.NoError(t, os.Mkdir(filepath.Join(objects, "incoming-1"), 0o700)) | |
| 99 | - | _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects}) | |
| 99 | + | _, err := g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 100 | 100 | require.Error(t, err) | |
| 101 | 101 | assert.NotContains(t, err.Error(), "outside the repository") | |
| 102 | 102 | // One that does not exist is refused. | |
| 103 | - | _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"}) | |
| 103 | + | _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 104 | 104 | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | 107 | // A symbolic link in the way cannot take the objects of a push out of the | |
| 108 | 108 | // repository, and a data directory reached through links still works. | |
| 109 | - | func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) { | |
| 109 | + | func TestPushSubtreesResolvesSymbolicLinks(t *testing.T) { | |
| 110 | 110 | g, data := newGit(t) | |
| 111 | 111 | ctx := context.Background() | |
| 112 | 112 | f := readertest.NewFixture(t, data, "proj") | |
| @@ -119,20 +119,20 @@ func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) { | |||
| 119 | 119 | // Inside objects/, a link to elsewhere is outside once resolved. | |
| 120 | 120 | elsewhere := t.TempDir() | |
| 121 | 121 | require.NoError(t, os.Symlink(elsewhere, filepath.Join(objects, "incoming-link"))) | |
| 122 | - | _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")}) | |
| 122 | + | _, err := g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 123 | 123 | assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/") | |
| 124 | - | _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")}) | |
| 124 | + | _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 125 | 125 | assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/") | |
| 126 | 126 | ||
| 127 | 127 | // A link to somewhere inside objects/ is fine. | |
| 128 | 128 | require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700)) | |
| 129 | 129 | require.NoError(t, os.Symlink(filepath.Join(objects, "q1"), filepath.Join(objects, "q-link"))) | |
| 130 | - | _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")}) | |
| 130 | + | _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 131 | 131 | require.Error(t, err) | |
| 132 | 132 | assert.NotContains(t, err.Error(), "outside the repository") | |
| 133 | 133 | } | |
| 134 | 134 | ||
| 135 | - | func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) { | |
| 135 | + | func TestPushSubtreesThroughALinkedDataDirectory(t *testing.T) { | |
| 136 | 136 | _, data := newGit(t) | |
| 137 | 137 | ctx := context.Background() | |
| 138 | 138 | f := readertest.NewFixture(t, data, "proj") | |
| @@ -152,10 +152,10 @@ func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) { | |||
| 152 | 152 | q := filepath.Join(f.Bare, "objects", "incoming-9") | |
| 153 | 153 | require.NoError(t, os.Mkdir(q, 0o700)) | |
| 154 | 154 | for _, dir := range []string{q, filepath.Join(link, "repos", "proj.git", "objects", "incoming-9")} { | |
| 155 | - | p, err := g2.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}}, | |
| 156 | - | ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")}) | |
| 155 | + | p, err := g2.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}}, | |
| 156 | + | ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 157 | 157 | require.NoError(t, err, dir) | |
| 158 | - | assert.Equal(t, ".github/workflows/ci.yml", p, dir) | |
| 158 | + | assert.Equal(t, domain.EntryDirectory, p.Subtrees[oidOf(t, wf).String()].Kind, dir) | |
| 159 | 159 | } | |
| 160 | 160 | } | |
| 161 | 161 | ||
| @@ -182,17 +182,7 @@ func commitOf(t *testing.T, f *readertest.Fixture, tree string, parents ...strin | |||
| 182 | 182 | return strings.TrimSpace(string(out)) | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | - | // withWorkflows is a commit whose .github/workflows is the tree given. | |
| 186 | - | func withWorkflows(t *testing.T, f *readertest.Fixture, workflows string) string { | |
| 187 | - | t.Helper() | |
| 188 | - | gh := treeOf(t, f, "040000 tree "+workflows+"\tworkflows") | |
| 189 | - | return commitOf(t, f, treeOf(t, f, "040000 tree "+gh+"\t.github")) | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | // The trees of a push are the pusher's: a workflows directory that fans out | |
| 193 | - | // into ten million paths is answered with its first path, at once, by a git | |
| 194 | - | // that is stopped, without the diff ever being held in memory. | |
| 195 | - | func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) { | |
| 185 | + | func TestFirstDifferentEntryDoesNotBufferTheDiff(t *testing.T) { | |
| 196 | 186 | g, data := newGit(t) | |
| 197 | 187 | ctx := context.Background() | |
| 198 | 188 | f := readertest.NewFixture(t, data, "proj") | |
| @@ -206,17 +196,17 @@ func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) { | |||
| 206 | 196 | } | |
| 207 | 197 | level = treeOf(t, f, entries...) | |
| 208 | 198 | } | |
| 209 | - | amp := withWorkflows(t, f, level) | |
| 199 | + | amp := level | |
| 210 | 200 | ||
| 211 | 201 | var before, after runtime.MemStats | |
| 212 | 202 | runtime.GC() | |
| 213 | 203 | runtime.ReadMemStats(&before) | |
| 214 | 204 | start := time.Now() | |
| 215 | - | p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: amp, Ref: "refs/heads/x"}}, ports.PushObjects{}) | |
| 205 | + | p, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level)) | |
| 216 | 206 | took := time.Since(start) | |
| 217 | 207 | runtime.ReadMemStats(&after) | |
| 218 | 208 | require.NoError(t, err) | |
| 219 | - | assert.True(t, strings.HasPrefix(p, ".github/workflows/d"), p) | |
| 209 | + | assert.True(t, strings.HasPrefix(p, "d"), p) | |
| 220 | 210 | assert.Less(t, took, 2*time.Second, "answered from the first path") | |
| 221 | 211 | assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory") | |
| 222 | 212 | // And no git is left behind. | |
| @@ -225,69 +215,50 @@ func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) { | |||
| 225 | 215 | } | |
| 226 | 216 | ||
| 227 | 217 | // A path longer than the bound fails closed: the push is refused, not read on. | |
| 228 | - | func TestChangedWorkflowRefusesAnEndlessPath(t *testing.T) { | |
| 218 | + | func TestFirstDifferentEntryRefusesAnEndlessPath(t *testing.T) { | |
| 229 | 219 | g, data := newGit(t) | |
| 230 | 220 | ctx := context.Background() | |
| 231 | 221 | f := readertest.NewFixture(t, data, "proj") | |
| 232 | 222 | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 233 | 223 | level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf") | |
| 234 | 224 | level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes | |
| 235 | - | _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: withWorkflows(t, f, level), Ref: "refs/heads/x"}}, ports.PushObjects{}) | |
| 225 | + | _, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level)) | |
| 236 | 226 | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 237 | 227 | } | |
| 238 | 228 | ||
| 239 | 229 | // A tag may point to a blob, a tree or a commit; only the commits and trees can | |
| 240 | 230 | // hold a workflow, and an object that is not there refuses the push. | |
| 241 | - | func TestChangedWorkflowPeelsAndClassifiesBothEnds(t *testing.T) { | |
| 231 | + | func TestPushSubtreesPeelsAndClassifiesBothEnds(t *testing.T) { | |
| 242 | 232 | g, data := newGit(t) | |
| 243 | 233 | ctx := context.Background() | |
| 244 | 234 | f := readertest.NewFixture(t, data, "proj") | |
| 245 | 235 | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 246 | 236 | f.Write("README", "r\n") | |
| 247 | - | plain := f.Commit("plain") | |
| 248 | - | f.Write(".github/workflows/ci.yml", "on: push\n") | |
| 249 | - | wf := f.Commit("workflow") | |
| 250 | - | f.Write(".github/workflows/ci.yml", "on: [push]\n") | |
| 251 | - | f.Commit("the default branch moves on to other workflows") // HEAD is not wf | |
| 237 | + | plain := oidOf(t, f.Commit("plain")).String() | |
| 252 | 238 | blob := f.WriteObject("blob", "hello\n") | |
| 253 | - | annotatedBlob := f.WriteObject("tag", "object "+blob+"\ntype blob\ntag b\ntagger a <a@b> 1 +0000\n\nm\n") | |
| 254 | - | annotatedWF := f.WriteObject("tag", "object "+oidOf(t, wf).String()+"\ntype commit\ntag c\ntagger a <a@b> 1 +0000\n\nm\n") | |
| 255 | - | rootTree := f.Git("rev-parse", wf+"^{tree}") | |
| 256 | - | ref := func(old, new string) []domain.RefUpdate { | |
| 257 | - | return []domain.RefUpdate{{Old: old, New: new, Ref: "refs/tags/t"}} | |
| 258 | - | } | |
| 259 | - | changed := func(ups []domain.RefUpdate) (string, error) { | |
| 260 | - | return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{}) | |
| 261 | - | } | |
| 262 | - | for name, ups := range map[string][]domain.RefUpdate{ | |
| 263 | - | "create a lightweight tag on a blob": ref(domain.ZeroOID, blob), | |
| 264 | - | "create an annotated tag on a blob": ref(domain.ZeroOID, annotatedBlob), | |
| 265 | - | "a tag from a blob to another blob": ref(blob, annotatedBlob), | |
| 266 | - | "a tag from a commit with workflows to a blob": ref(oidOf(t, wf).String(), blob), | |
| 267 | - | } { | |
| 268 | - | p, err := changed(ups) | |
| 269 | - | require.NoError(t, err, name) | |
| 270 | - | assert.Empty(t, p, name) | |
| 239 | + | tag := func(id, kind string) string { | |
| 240 | + | return f.WriteObject("tag", "object "+id+"\ntype "+kind+"\ntag t\ntagger a <a@b> 1 +0000\n\nm\n") | |
| 271 | 241 | } | |
| 272 | - | for name, ups := range map[string][]domain.RefUpdate{ | |
| 273 | - | "a tag from a blob to a commit with workflows": ref(blob, oidOf(t, wf).String()), | |
| 274 | - | "a tag from an annotated blob to a commit with workflows": ref(annotatedBlob, oidOf(t, wf).String()), | |
| 275 | - | "an annotated tag on a commit with workflows": ref(domain.ZeroOID, annotatedWF), | |
| 276 | - | "a tag on a tree with workflows": ref(domain.ZeroOID, rootTree), | |
| 277 | - | "from a commit without to one with": ref(oidOf(t, plain).String(), annotatedWF), | |
| 278 | - | } { | |
| 279 | - | p, err := changed(ups) | |
| 280 | - | require.NoError(t, err, name) | |
| 281 | - | assert.Equal(t, ".github/workflows/ci.yml", p, name) | |
| 242 | + | tree := f.Git("rev-parse", plain+"^{tree}") | |
| 243 | + | for _, r := range []struct { | |
| 244 | + | id string | |
| 245 | + | kind domain.PushObjectType | |
| 246 | + | }{{plain, domain.PushObjectCommit}, {tag(plain, "commit"), domain.PushObjectCommit}, {blob, domain.PushObjectBlob}, {tag(blob, "blob"), domain.PushObjectBlob}, {tree, domain.PushObjectTree}, {tag(tag(plain, "commit"), "tag"), domain.PushObjectCommit}} { | |
| 247 | + | got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: r.id}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 248 | + | require.NoError(t, err) | |
| 249 | + | assert.Equal(t, r.kind, got.Ends[r.id]) | |
| 250 | + | if r.kind == domain.PushObjectBlob { | |
| 251 | + | assert.NotContains(t, got.Subtrees, r.id) | |
| 252 | + | } | |
| 282 | 253 | } | |
| 283 | - | for name, ups := range map[string][]domain.RefUpdate{ | |
| 284 | - | "a missing new object": ref(domain.ZeroOID, "1111111111111111111111111111111111111111"), | |
| 285 | - | "a missing old object": ref("2222222222222222222222222222222222222222", oidOf(t, wf).String()), | |
| 286 | - | "a malformed object": ref(domain.ZeroOID, "ZZZZ"), | |
| 287 | - | } { | |
| 288 | - | _, err := changed(ups) | |
| 289 | - | assert.Error(t, err, name) | |
| 254 | + | for _, r := range []domain.RefUpdate{{Old: domain.ZeroOID, New: strings.Repeat("1", 40)}, {Old: strings.Repeat("2", 40), New: plain}, {Old: domain.ZeroOID, New: "ZZZZ"}} { | |
| 255 | + | _, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{r}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 256 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 290 | 257 | } | |
| 258 | + | got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: "invalid but deleted", New: domain.ZeroOID}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 259 | + | require.NoError(t, err) | |
| 260 | + | assert.Empty(t, got.Ends) | |
| 261 | + | assert.Empty(t, got.Commits) | |
| 291 | 262 | } | |
| 292 | 263 | ||
| 293 | 264 | // rawTree writes a tree object by hand: entries are "mode name hexid", in the | |
| @@ -308,73 +279,35 @@ func rawTree(t *testing.T, f *readertest.Fixture, entries ...[3]string) string { | |||
| 308 | 279 | // mode 040000 below a directory .github of mode 040000. The same tree id as a | |
| 309 | 280 | // gitlink, a symbolic link or a file is nothing, and turning it into a | |
| 310 | 281 | // directory activates it; the object id alone does not tell. | |
| 311 | - | func TestChangedWorkflowComparesTheModeOfTheEntries(t *testing.T) { | |
| 282 | + | func TestPushSubtreesEntryKinds(t *testing.T) { | |
| 312 | 283 | g, data := newGit(t) | |
| 313 | 284 | ctx := context.Background() | |
| 314 | 285 | f := readertest.NewFixture(t, data, "proj") | |
| 315 | 286 | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 316 | - | f.Write("README", "r\n") | |
| 317 | - | f.Commit("base") | |
| 318 | - | yml := f.WriteObject("blob", "on: push\n") | |
| 319 | - | wfTree := rawTree(t, f, [3]string{"100644", "ci.yml", yml}) // the workflows | |
| 320 | - | link := f.WriteObject("blob", ".github/other") | |
| 321 | - | file := f.WriteObject("blob", "not a directory\n") | |
| 322 | - | readme := f.WriteObject("blob", "r\n") | |
| 323 | - | ||
| 324 | - | // A commit whose .github/workflows has the mode and id given. | |
| 325 | - | withWorkflows := func(mode, id string) string { | |
| 326 | - | gh := rawTree(t, f, [3]string{mode, "workflows", id}) | |
| 327 | - | return commitOf(t, f, rawTree(t, f, [3]string{"040000", ".github", gh})) | |
| 328 | - | } | |
| 329 | - | // A commit whose .github has the mode and id given. | |
| 330 | - | withGithub := func(mode, id string) string { | |
| 331 | - | return commitOf(t, f, rawTree(t, f, [3]string{mode, ".github", id})) | |
| 332 | - | } | |
| 333 | - | asDir := withWorkflows("040000", wfTree) | |
| 334 | - | asGitlink := withWorkflows("160000", wfTree) // the same id, a submodule | |
| 335 | - | asSymlink := withWorkflows("120000", link) | |
| 336 | - | asFile := withWorkflows("100644", file) | |
| 337 | - | none := commitOf(t, f, rawTree(t, f, [3]string{"100644", "README", readme})) | |
| 338 | - | ghDir := rawTree(t, f, [3]string{"040000", "workflows", wfTree}) | |
| 339 | - | githubDir := withGithub("040000", ghDir) | |
| 340 | - | githubGitlink := withGithub("160000", ghDir) // the same id, a submodule | |
| 341 | - | githubFile := withGithub("100644", file) | |
| 342 | - | githubLink := withGithub("120000", link) | |
| 343 | - | ||
| 344 | - | for name, c := range map[string]struct { | |
| 345 | - | old, new string | |
| 346 | - | want string | |
| 347 | - | }{ | |
| 348 | - | "gitlink to a directory, same id (the bypass)": {asGitlink, asDir, ".github/workflows/ci.yml"}, | |
| 349 | - | "a directory to a gitlink removes it": {asDir, asGitlink, ""}, | |
| 350 | - | "symlink to a directory": {asSymlink, asDir, ".github/workflows/ci.yml"}, | |
| 351 | - | "a directory to a symlink removes it": {asDir, asSymlink, ""}, | |
| 352 | - | "file to a directory": {asFile, asDir, ".github/workflows/ci.yml"}, | |
| 353 | - | "a directory to a file removes it": {asDir, asFile, ""}, | |
| 354 | - | "nothing to a directory": {none, asDir, ".github/workflows/ci.yml"}, | |
| 355 | - | "a directory to nothing": {asDir, none, ""}, | |
| 356 | - | "the same gitlink": {asGitlink, asGitlink, ""}, | |
| 357 | - | "gitlink to symlink": {asGitlink, asSymlink, ""}, | |
| 358 | - | ".github a gitlink to a directory, same id": {githubGitlink, githubDir, ".github/workflows/ci.yml"}, | |
| 359 | - | ".github a directory to a gitlink": {githubDir, githubGitlink, ""}, | |
| 360 | - | ".github a file to a directory": {githubFile, githubDir, ".github/workflows/ci.yml"}, | |
| 361 | - | ".github a symlink to a directory": {githubLink, githubDir, ".github/workflows/ci.yml"}, | |
| 362 | - | ".github a directory to a symlink": {githubDir, githubLink, ""}, | |
| 363 | - | "a new ref with a gitlink": {domain.ZeroOID, asGitlink, ""}, | |
| 364 | - | "a new ref with a symlink": {domain.ZeroOID, asSymlink, ""}, | |
| 365 | - | "a new ref with .github a gitlink": {domain.ZeroOID, githubGitlink, ""}, | |
| 366 | - | "a new ref with a directory": {domain.ZeroOID, asDir, ".github/workflows/ci.yml"}, | |
| 367 | - | } { | |
| 368 | - | p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: c.old, New: c.new, Ref: "refs/heads/x"}}, ports.PushObjects{}) | |
| 369 | - | require.NoError(t, err, name) | |
| 370 | - | assert.Equal(t, c.want, p, name) | |
| 287 | + | blob := f.WriteObject("blob", "x\n") | |
| 288 | + | tree := rawTree(t, f, [3]string{"100644", "ci.yml", blob}) | |
| 289 | + | for _, r := range []struct { | |
| 290 | + | mode, id string | |
| 291 | + | kind domain.EntryKind | |
| 292 | + | }{{"040000", tree, domain.EntryDirectory}, {"160000", tree, domain.EntrySubmodule}, {"120000", blob, domain.EntrySymlink}, {"100644", blob, domain.EntryFile}, {"100755", blob, domain.EntryExecutable}} { | |
| 293 | + | t.Run(r.mode, func(t *testing.T) { | |
| 294 | + | gh := rawTree(t, f, [3]string{r.mode, "workflows", r.id}) | |
| 295 | + | root := rawTree(t, f, [3]string{"040000", ".github", gh}) | |
| 296 | + | oid := commitOf(t, f, root) | |
| 297 | + | got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 298 | + | require.NoError(t, err) | |
| 299 | + | assert.Equal(t, r.kind, got.Subtrees[oid].Kind) | |
| 300 | + | assert.Equal(t, r.id, got.Subtrees[oid].ID.String()) | |
| 301 | + | assert.NotContains(t, got.Subtrees, domain.HeadKey) | |
| 302 | + | if r.mode != "040000" { | |
| 303 | + | root = rawTree(t, f, [3]string{r.mode, ".github", r.id}) | |
| 304 | + | oid = commitOf(t, f, root) | |
| 305 | + | got, err = g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 306 | + | require.NoError(t, err) | |
| 307 | + | assert.NotContains(t, got.Subtrees, oid) | |
| 308 | + | } | |
| 309 | + | }) | |
| 371 | 310 | } | |
| 372 | - | ||
| 373 | - | // A tag that peels to the commit is judged the same way. | |
| 374 | - | tag := f.WriteObject("tag", "object "+asDir+"\ntype commit\ntag t\ntagger a <a@b> 1 +0000\n\nm\n") | |
| 375 | - | p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: asGitlink, New: tag, Ref: "refs/tags/t"}}, ports.PushObjects{}) | |
| 376 | - | require.NoError(t, err) | |
| 377 | - | assert.Equal(t, ".github/workflows/ci.yml", p) | |
| 378 | 311 | } | |
| 379 | 312 | ||
| 380 | 313 | // A history for the rules on the commits a push brings: main holds workflows A, | |
| @@ -418,91 +351,37 @@ func (h *guardHistory) commit(workflows, _ string, parents ...string) string { | |||
| 418 | 351 | return commitOf(h.t, h.f, rawTree(h.t, h.f, entries...), parents...) | |
| 419 | 352 | } | |
| 420 | 353 | ||
| 421 | - | func TestChangedWorkflowJudgesThePushedCommits(t *testing.T) { | |
| 422 | - | g, data := newGit(t) | |
| 423 | - | ctx := context.Background() | |
| 424 | - | h := newGuardHistory(t, g, data) | |
| 425 | - | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 426 | - | f := h.f | |
| 427 | - | run := func(ups ...domain.RefUpdate) (string, error) { | |
| 428 | - | return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{}) | |
| 429 | - | } | |
| 430 | - | create := func(ref, to string) domain.RefUpdate { return domain.RefUpdate{Old: domain.ZeroOID, New: to, Ref: ref} } | |
| 431 | - | move := func(ref, from, to string) domain.RefUpdate { return domain.RefUpdate{Old: from, New: to, Ref: ref} } | |
| 432 | - | const ci = ".github/workflows/ci.yml" | |
| 433 | - | ||
| 434 | - | allowed := func(name string, ups ...domain.RefUpdate) { | |
| 435 | - | t.Helper() | |
| 436 | - | p, err := run(ups...) | |
| 437 | - | require.NoError(t, err, name) | |
| 438 | - | assert.Empty(t, p, name) | |
| 439 | - | } | |
| 440 | - | refused := func(name string, ups ...domain.RefUpdate) { | |
| 441 | - | t.Helper() | |
| 442 | - | p, err := run(ups...) | |
| 443 | - | require.NoError(t, err, name) | |
| 444 | - | assert.Equal(t, ci, p, name) | |
| 445 | - | } | |
| 446 | - | ||
| 447 | - | // The staging repro: a new branch off main with a commit that touches no workflow. | |
| 448 | - | allowed("new branch off main with a plain commit", create("refs/heads/smoke", h.commit("A", "", h.m))) | |
| 449 | - | allowed("a new branch at the head of main", create("refs/heads/same", h.m)) | |
| 450 | - | allowed("a new tag at the head of main", create("refs/tags/v1", h.m)) | |
| 451 | - | allowed("a new branch whose commits carry no workflows at all", create("refs/heads/bare", h.commit("", ""))) | |
| 452 | - | allowed("a fast-forward with plain commits", move("refs/heads/main", h.m, h.commit("A", "", h.commit("A", "", h.m)))) | |
| 453 | - | allowed("a commit that removes the workflows", move("refs/heads/main", h.m, h.commit("", "", h.m))) | |
| 454 | - | ||
| 455 | - | // A pushed commit that changes them is refused wherever it goes. | |
| 456 | - | refused("new branch with a commit that changes workflows", create("refs/heads/w", h.commit("C", "", h.m))) | |
| 457 | - | refused("a root commit that carries workflows", create("refs/heads/root", h.commit("C", ""))) | |
| 458 | - | refused("an update with a commit that changes workflows", move("refs/heads/main", h.m, h.commit("C", "", h.m))) | |
| 459 | - | n4 := h.commit("C", "", h.m) | |
| 460 | - | n5 := h.commit("A", "", n4) // reverts the change of the commit before | |
| 461 | - | refused("a fast-forward through a commit that changes them and a later one that reverts it", move("refs/heads/main", h.m, n5)) | |
| 462 | - | refused("a new branch through the same", create("refs/heads/through", n5)) | |
| 463 | - | n6 := h.commit("A", "", h.commit("C", "", h.o)) | |
| 464 | - | refused("a commit that changes them below plain ones, on an old base", create("refs/heads/below", h.commit("C", "", n6))) | |
| 465 | - | ||
| 466 | - | // A ref cannot be moved onto existing commits with other workflows. | |
| 467 | - | refused("a new branch at an old commit whose workflows differ from main", create("refs/heads/b", h.o)) | |
| 468 | - | refused("a new tag at that commit", create("refs/tags/old", h.o)) | |
| 469 | - | refused("an update moving back to an older commit with other workflows", move("refs/heads/main", h.m, h.o)) | |
| 470 | - | allowed("an update to a commit with the same workflows", move("refs/heads/old", h.o, h.commit("B", "", h.o))) | |
| 471 | - | allowed("an update that moves a ref to a commit without workflows (removes them)", move("refs/heads/old", h.o, h.commit("", "", h.o))) | |
| 472 | - | ||
| 473 | - | // Merges: a change only if it differs from every parent. | |
| 474 | - | allowed("a merge of existing commits that keeps main's workflows", move("refs/heads/main", h.m, h.commit("A", "", h.m, h.o))) | |
| 475 | - | allowed("a new branch with a merge keeping one parent's workflows", create("refs/heads/merge", h.commit("A", "", h.m, h.o))) | |
| 476 | - | refused("a merge that differs from both parents", create("refs/heads/merge2", h.commit("D", "", h.m, h.o))) | |
| 477 | - | refused("a merge of a pushed side that changes them (the side commit is itself new)", create("refs/heads/merge3", h.commit("A", "", h.m, h.commit("C", "", h.o)))) | |
| 478 | - | // One that takes the old side's workflows is a change of the ref, not of a commit. | |
| 479 | - | refused("a merge taking the other parent's workflows, onto main", move("refs/heads/main", h.m, h.commit("B", "", h.m, h.o))) | |
| 480 | - | ||
| 481 | - | // Several updates: the first refused refuses the push. | |
| 482 | - | refused("a plain ref and a refused one", create("refs/heads/ok", h.commit("A", "", h.m)), create("refs/heads/w2", h.commit("C", "", h.m))) | |
| 483 | - | allowed("deletes", move("refs/heads/old", h.o, domain.ZeroOID)) | |
| 484 | - | } | |
| 485 | - | ||
| 486 | - | // With no default branch head, a new ref is compared with no workflows at all. | |
| 487 | - | func TestChangedWorkflowOfANewRefWithoutADefaultBranchHead(t *testing.T) { | |
| 354 | + | func TestPushSubtreesCommitsAndParents(t *testing.T) { | |
| 488 | 355 | g, data := newGit(t) | |
| 489 | 356 | ctx := context.Background() | |
| 490 | 357 | h := newGuardHistory(t, g, data) | |
| 491 | 358 | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 492 | - | h.f.Git("symbolic-ref", "HEAD", "refs/heads/nothing") // the default branch does not exist | |
| 493 | - | run := func(to string) string { | |
| 494 | - | p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: to, Ref: "refs/tags/t"}}, ports.PushObjects{}) | |
| 495 | - | require.NoError(t, err) | |
| 496 | - | return p | |
| 359 | + | side := h.commit("B", "", h.o) | |
| 360 | + | merge := h.commit("A", "", h.m, side) | |
| 361 | + | got, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}, {Old: domain.ZeroOID, New: merge}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 362 | + | require.NoError(t, err) | |
| 363 | + | want := h.f.Git("rev-list", "--parents", merge, "--not", "--all") | |
| 364 | + | var lines []string | |
| 365 | + | for _, c := range got.Commits { | |
| 366 | + | line := c.ID.String() | |
| 367 | + | for _, p := range c.Parents { | |
| 368 | + | line += " " + p.String() | |
| 369 | + | } | |
| 370 | + | lines = append(lines, line) | |
| 497 | 371 | } | |
| 498 | - | assert.Equal(t, ".github/workflows/ci.yml", run(h.m), "an existing commit with workflows") | |
| 499 | - | plain := h.commit("", "") | |
| 500 | - | h.f.Git("update-ref", "refs/heads/plain", plain) | |
| 501 | - | assert.Empty(t, run(plain), "an existing commit without") | |
| 372 | + | assert.Equal(t, strings.TrimSpace(want), strings.Join(lines, "\n")) | |
| 373 | + | assert.Len(t, got.Commits, 2) | |
| 374 | + | assert.Equal(t, h.trees["A"], got.Subtrees[domain.HeadKey].ID.String()) | |
| 375 | + | assert.Equal(t, h.trees["B"], got.Subtrees[h.o].ID.String()) | |
| 376 | + | // Any directory path is accepted; the reader has no workflow path policy. | |
| 377 | + | other, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}}, ports.PushObjects{}, "README", domain.DefaultPushWalkBounds()) | |
| 378 | + | require.NoError(t, err) | |
| 379 | + | assert.Equal(t, domain.EntryFile, other.Subtrees[merge].Kind) | |
| 380 | + | assert.NotContains(t, other.Subtrees, domain.HeadKey, "HEAD is lazy for non-creates") | |
| 502 | 381 | } | |
| 503 | 382 | ||
| 504 | 383 | // The commits of a push are bounded: more than the bound fails closed. | |
| 505 | - | func TestChangedWorkflowBoundsThePushedCommits(t *testing.T) { | |
| 384 | + | func TestPushSubtreesBoundsThePushedCommits(t *testing.T) { | |
| 506 | 385 | g, data := newGit(t) | |
| 507 | 386 | ctx := context.Background() | |
| 508 | 387 | h := newGuardHistory(t, g, data) | |
| @@ -525,10 +404,10 @@ func TestChangedWorkflowBoundsThePushedCommits(t *testing.T) { | |||
| 525 | 404 | } | |
| 526 | 405 | // Plain commits carry the workflows of main (the fast-import tree has none, | |
| 527 | 406 | // so the chain is "removing" them): allowed, whatever their number, up to the bound. | |
| 528 | - | p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits), Ref: "refs/heads/main"}}, ports.PushObjects{}) | |
| 407 | + | p, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(20), Ref: "refs/heads/main"}}, ports.PushObjects{}, domain.WorkflowDir, domain.PushWalkBounds{Commits: 20, IDs: 80}) | |
| 529 | 408 | require.NoError(t, err) | |
| 530 | - | assert.Empty(t, p) | |
| 531 | - | _, err = g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits + 1), Ref: "refs/heads/main"}}, ports.PushObjects{}) | |
| 409 | + | assert.Len(t, p.Commits, 20) | |
| 410 | + | _, err = g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(21), Ref: "refs/heads/main"}}, ports.PushObjects{}, domain.WorkflowDir, domain.PushWalkBounds{Commits: 20, IDs: 80}) | |
| 532 | 411 | assert.ErrorIs(t, err, domain.ErrInvalid, "more commits than the bound refuses the push") | |
| 533 | 412 | } | |
| 534 | 413 | ||
| @@ -565,18 +444,18 @@ func TestParseRevListRejectsManyParentsEarly(t *testing.T) { | |||
| 565 | 444 | b.WriteString(" " + id(1+i%3)) // few distinct parents, repeated: the distinct count alone would stay small | |
| 566 | 445 | } | |
| 567 | 446 | b.WriteString("\n") | |
| 568 | - | r := &countingReader{lines: gitcli.MaxPushedCommits, line: []byte(b.String())} | |
| 569 | - | kept, err := gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects) | |
| 447 | + | r := &countingReader{lines: domain.MaxPushedCommits, line: []byte(b.String())} | |
| 448 | + | kept, err := gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs) | |
| 570 | 449 | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 571 | 450 | assert.Contains(t, err.Error(), "ids (commits and parents)", "refused by the bound on ids, not by the line length") | |
| 572 | 451 | assert.Zero(t, kept, "nothing is returned with the error") | |
| 573 | - | total := gitcli.MaxPushedCommits * len(b.String()) | |
| 452 | + | total := domain.MaxPushedCommits * len(b.String()) | |
| 574 | 453 | assert.Less(t, r.read, 2<<20, "read only the head of %d bytes", total) | |
| 575 | 454 | assert.Less(t, r.read*20, total) | |
| 576 | 455 | ||
| 577 | 456 | // Within the bounds the same lines are kept: 100 commits of 198 parents. | |
| 578 | 457 | r = &countingReader{lines: 100, line: []byte(b.String())} | |
| 579 | - | kept, err = gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects) | |
| 458 | + | kept, err = gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs) | |
| 580 | 459 | require.NoError(t, err) | |
| 581 | 460 | assert.Equal(t, 100, kept) | |
| 582 | 461 | ||
| @@ -590,3 +469,65 @@ func TestParseRevListRejectsManyParentsEarly(t *testing.T) { | |||
| 590 | 469 | _, err = gitcli.ParseRevList(strings.NewReader(id(0)+strings.Repeat(" "+id(1), 300)+"\n"), 10, 1000) | |
| 591 | 470 | assert.ErrorIs(t, err, domain.ErrInvalid, "a line over the bound") | |
| 592 | 471 | } | |
| 472 | + | ||
| 473 | + | func changedWorkflow(g *gitcli.Git, ctx context.Context, repo domain.RepoName, ups []domain.RefUpdate, objects ports.PushObjects) (string, error) { | |
| 474 | + | t, err := g.PushSubtrees(ctx, repo, ups, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 475 | + | if err != nil { | |
| 476 | + | return "", err | |
| 477 | + | } | |
| 478 | + | c, found, err := domain.WorkflowChangeOf(ups, t) | |
| 479 | + | if err != nil || !found { | |
| 480 | + | return "", err | |
| 481 | + | } | |
| 482 | + | entry, err := g.FirstDifferentEntry(ctx, repo, objects, c.Old, c.New) | |
| 483 | + | if err != nil { | |
| 484 | + | return "", err | |
| 485 | + | } | |
| 486 | + | if entry == "" { | |
| 487 | + | return domain.WorkflowDir, nil | |
| 488 | + | } | |
| 489 | + | return domain.WorkflowDir + "/" + entry, nil | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | func TestFirstDifferentEntry(t *testing.T) { | |
| 493 | + | g, data := newGit(t) | |
| 494 | + | ctx := context.Background() | |
| 495 | + | f := readertest.NewFixture(t, data, "proj") | |
| 496 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 497 | + | tree := oidOf(t, treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tci.yml")) | |
| 498 | + | entry, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, tree) | |
| 499 | + | require.NoError(t, err) | |
| 500 | + | assert.Equal(t, "ci.yml", entry) | |
| 501 | + | entry, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, tree, tree) | |
| 502 | + | require.NoError(t, err) | |
| 503 | + | assert.Empty(t, entry) | |
| 504 | + | _, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{Dir: t.TempDir()}, domain.ObjectID{}, tree) | |
| 505 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | func TestChangedWorkflowPushHistories(t *testing.T) { | |
| 509 | + | g, data := newGit(t) | |
| 510 | + | ctx := context.Background() | |
| 511 | + | h := newGuardHistory(t, g, data) | |
| 512 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 513 | + | run := func(old, tip, want string) { | |
| 514 | + | t.Helper() | |
| 515 | + | got, err := changedWorkflow(g, ctx, h.f.Name, []domain.RefUpdate{{Old: old, New: tip, Ref: "refs/heads/pushed"}}, ports.PushObjects{}) | |
| 516 | + | require.NoError(t, err) | |
| 517 | + | assert.Equal(t, want, got) | |
| 518 | + | } | |
| 519 | + | run(domain.ZeroOID, h.commit("A", "", h.m), "") | |
| 520 | + | change := h.commit("C", "", h.m) | |
| 521 | + | revert := h.commit("A", "", change) | |
| 522 | + | run(h.m, revert, ".github/workflows/ci.yml") | |
| 523 | + | run(domain.ZeroOID, h.commit("D", "", h.m, h.o), ".github/workflows/ci.yml") | |
| 524 | + | h.f.Git("symbolic-ref", "HEAD", "refs/heads/nothing") | |
| 525 | + | run(domain.ZeroOID, h.m, ".github/workflows/ci.yml") | |
| 526 | + | ups := []domain.RefUpdate{{Old: domain.ZeroOID, New: h.m, Ref: "refs/tags/t"}} | |
| 527 | + | trees, err := g.PushSubtrees(ctx, h.f.Name, ups, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 528 | + | require.NoError(t, err) | |
| 529 | + | assert.NotContains(t, trees.Subtrees, domain.HeadKey) | |
| 530 | + | plain := h.commit("", "") | |
| 531 | + | h.f.Git("update-ref", "refs/heads/plain", plain) | |
| 532 | + | run(domain.ZeroOID, plain, "") | |
| 533 | + | } | |
M internal/adapters/gitcli/read.go +31 -60
| @@ -35,8 +35,6 @@ func notFound(err error) bool { return errors.Is(err, errObjectMissing) } | |||
| 35 | 35 | // ListRefs lists HEAD, the branches and the tags with `git for-each-ref`. | |
| 36 | 36 | // An annotated tag is followed to its commit; a tag of a tag is followed with | |
| 37 | 37 | // one `cat-file` request each. | |
| 38 | - | // | |
| 39 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain refs | |
| 40 | 38 | func (g *Git) ListRefs(ctx context.Context, repo domain.RepoName) ([]domain.Ref, error) { | |
| 41 | 39 | dir, err := g.repoDir(repo) | |
| 42 | 40 | if err != nil { | |
| @@ -155,66 +153,45 @@ func readHead(dir string) (string, error) { | |||
| 155 | 153 | return ref, nil | |
| 156 | 154 | } | |
| 157 | 155 | ||
| 158 | - | // revCandidate is one way to read a revision as a commit. | |
| 159 | - | type revCandidate struct { | |
| 160 | - | spec string // object expression for cat-file | |
| 161 | - | ref string // full ref name it stands for, "" for ids and HEAD | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // candidates lists, in order, how a revision is tried. A full object id is an | |
| 165 | - | // object id first: a branch or a tag that happens to be named like a commit | |
| 166 | - | // must not redirect a link to that commit; refs are tried only when no such | |
| 167 | - | // object exists. Otherwise it is the branch, then the tag, then (for "HEAD", | |
| 168 | - | // full ref names and abbreviations) as written. Other forms Git understands, | |
| 169 | - | // such as "main~2" or "FETCH_HEAD", are not revisions of ermite. | |
| 170 | - | func candidates(rev domain.Revision) []revCandidate { | |
| 171 | - | s := rev.String() | |
| 172 | - | switch { | |
| 173 | - | case rev.IsHead() || s == "HEAD": | |
| 174 | - | return []revCandidate{{spec: "HEAD^{commit}"}} | |
| 175 | - | } | |
| 176 | - | refs := []revCandidate{ | |
| 177 | - | {spec: headsRefPrefix + s + "^{commit}", ref: headsRefPrefix + s}, | |
| 178 | - | {spec: tagsRefPrefix + s + "^{commit}", ref: tagsRefPrefix + s}, | |
| 179 | - | } | |
| 180 | - | switch { | |
| 181 | - | case rev.IsObjectID(): | |
| 182 | - | return append([]revCandidate{{spec: s + "^{commit}"}}, refs...) | |
| 183 | - | case strings.HasPrefix(s, "refs/"): | |
| 184 | - | return append(refs, revCandidate{spec: s + "^{commit}", ref: s}) | |
| 185 | - | case rev.IsAbbrevID(): | |
| 186 | - | return append(refs, revCandidate{spec: s + "^{commit}"}) | |
| 187 | - | } | |
| 188 | - | return refs | |
| 189 | - | } | |
| 190 | - | ||
| 191 | - | // resolve resolves rev on an open cat-file connection. | |
| 192 | - | // | |
| 193 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps a revision to git object names, a git detail of the reader | |
| 156 | + | // resolve resolves the domain candidates on an open cat-file connection. | |
| 194 | 157 | func resolve(c *catConn, rev domain.Revision) (domain.ResolvedRevision, error) { | |
| 195 | - | for _, cand := range candidates(rev) { | |
| 196 | - | h, err := c.info(cand.spec) | |
| 197 | - | if notFound(err) { | |
| 198 | - | continue | |
| 199 | - | } | |
| 158 | + | for _, cand := range rev.Candidates() { | |
| 159 | + | id, ok, err := lookupRevisionCandidate(c, cand) | |
| 200 | 160 | if err != nil { | |
| 201 | 161 | return domain.ResolvedRevision{}, err | |
| 202 | 162 | } | |
| 203 | - | if h.Type != "commit" { | |
| 163 | + | if !ok { | |
| 204 | 164 | continue | |
| 205 | 165 | } | |
| 206 | - | id, err := domain.ParseObjectID(h.ID) | |
| 207 | - | if err != nil { | |
| 208 | - | return domain.ResolvedRevision{}, c.fail(err) | |
| 209 | - | } | |
| 210 | - | return domain.ResolvedRevision{CommitID: id, Ref: cand.ref}, nil | |
| 166 | + | return domain.ResolvedRevision{CommitID: id, Ref: cand.ResolvedRef()}, nil | |
| 211 | 167 | } | |
| 212 | - | return domain.ResolvedRevision{}, fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound) | |
| 168 | + | return domain.ResolvedRevision{}, revisionNotFound(rev) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func revisionNotFound(rev domain.Revision) error { | |
| 172 | + | return fmt.Errorf("revision %q: %w", rev, domain.ErrNotFound) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // lookupRevisionCandidate asks cat-file to peel a candidate to a commit. | |
| 176 | + | func lookupRevisionCandidate(c *catConn, candidate domain.RevisionCandidate) (domain.ObjectID, bool, error) { | |
| 177 | + | h, err := c.info(candidate.Name + "^{commit}") | |
| 178 | + | if notFound(err) { | |
| 179 | + | return domain.ObjectID{}, false, nil | |
| 180 | + | } | |
| 181 | + | if err != nil { | |
| 182 | + | return domain.ObjectID{}, false, err | |
| 183 | + | } | |
| 184 | + | if h.Type != "commit" { | |
| 185 | + | return domain.ObjectID{}, false, nil | |
| 186 | + | } | |
| 187 | + | id, err := domain.ParseObjectID(h.ID) | |
| 188 | + | if err != nil { | |
| 189 | + | return domain.ObjectID{}, false, c.fail(err) | |
| 190 | + | } | |
| 191 | + | return id, true, nil | |
| 213 | 192 | } | |
| 214 | 193 | ||
| 215 | 194 | // ResolveRevision resolves a revision with `cat-file`. | |
| 216 | - | // | |
| 217 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | |
| 218 | 195 | func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) { | |
| 219 | 196 | dir, err := g.repoDir(repo) | |
| 220 | 197 | if err != nil { | |
| @@ -235,8 +212,6 @@ func (g *Git) ResolveRevision(ctx context.Context, repo domain.RepoName, rev dom | |||
| 235 | 212 | // tag, full ref name, HEAD; a single segment may also be a commit id), longest | |
| 236 | 213 | // first, with up to one `cat-file` request per candidate on the repository's | |
| 237 | 214 | // process. At most domain.MaxRefPathTries segments are tried. | |
| 238 | - | // | |
| 239 | - | //hexcheck:ignore suspicious-business-logic-in-adapter the split is domain.SplitRefPath; this only runs it on a cat-file connection | |
| 240 | 215 | func (g *Git) ResolveRefPath(ctx context.Context, repo domain.RepoName, path string) (domain.RefPath, error) { | |
| 241 | 216 | dir, err := g.repoDir(repo) | |
| 242 | 217 | if err != nil { | |
| @@ -264,6 +239,8 @@ type object struct { | |||
| 264 | 239 | // lookup resolves rev and reads the header of the object at path, in one | |
| 265 | 240 | // request series on the repository's process. A missing path is | |
| 266 | 241 | // domain.ErrNotFound. | |
| 242 | + | // | |
| 243 | + | //hexcheck:ignore suspicious-business-logic-in-adapter translates a root path to Git tree syntax and a non-root path to Git colon syntax, then reads the cat-file header | |
| 267 | 244 | func (g *Git) lookup(ctx context.Context, dir string, rev domain.Revision, path domain.TreePath) (object, error) { | |
| 268 | 245 | var obj object | |
| 269 | 246 | err := g.cat.do(ctx, dir, func(c *catConn) error { | |
| @@ -392,8 +369,6 @@ func (g *Git) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revi | |||
| 392 | 369 | } | |
| 393 | 370 | ||
| 394 | 371 | // parseTreeEntry parses "<mode> <type> <id> <size>\t<name>" of ls-tree -l. | |
| 395 | - | // | |
| 396 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | |
| 397 | 372 | func parseTreeEntry(rec string, parent domain.TreePath) (domain.TreeEntry, error) { | |
| 398 | 373 | meta, name, ok := strings.Cut(rec, "\t") | |
| 399 | 374 | f := strings.Fields(meta) | |
| @@ -487,8 +462,6 @@ feed: | |||
| 487 | 462 | ||
| 488 | 463 | // lastCommit returns the newest commit reachable from head that changed path; | |
| 489 | 464 | // an empty path means head itself. | |
| 490 | - | // | |
| 491 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | |
| 492 | 465 | func (g *Git) lastCommit(ctx context.Context, dir string, head domain.ObjectID, path string) (domain.Commit, error) { | |
| 493 | 466 | var c domain.Commit | |
| 494 | 467 | found := false | |
| @@ -520,8 +493,6 @@ const commitFormat = "%H%x00%P%x00%an%x00%ae%x00%at%x00%cn%x00%ce%x00%ct%x00%B" | |||
| 520 | 493 | ||
| 521 | 494 | // readCommit reads one commit in commitFormat. It returns io.EOF at the end | |
| 522 | 495 | // of the output. | |
| 523 | - | // | |
| 524 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses git output into domain values; limits and rules come from the port | |
| 525 | 496 | func readCommit(r *bufio.Reader) (domain.Commit, error) { | |
| 526 | 497 | var f [9]string | |
| 527 | 498 | for i := range f { | |
M internal/adapters/gitcli/repos.go +1 -3
| @@ -102,7 +102,7 @@ func (g *Git) Discard(_ context.Context, id ports.StagedID) error { | |||
| 102 | 102 | ||
| 103 | 103 | // PurgeStaging removes everything under repos/.staging/. | |
| 104 | 104 | // | |
| 105 | - | //hexcheck:ignore suspicious-business-logic-in-adapter port method name only | |
| 105 | + | //hexcheck:ignore suspicious-business-logic-in-adapter removes staging leftovers from disk, tolerates a missing directory, and logs each removal | |
| 106 | 106 | func (g *Git) PurgeStaging(ctx context.Context) error { | |
| 107 | 107 | entries, err := os.ReadDir(g.stagingDir()) | |
| 108 | 108 | if err != nil { | |
| @@ -280,8 +280,6 @@ func (g *Git) DefaultBranch(ctx context.Context, name domain.RepoName) (string, | |||
| 280 | 280 | // HasBranch checks refs/heads/<branch> with `git for-each-ref` after | |
| 281 | 281 | // `git check-ref-format`, which rejects the glob characters for-each-ref | |
| 282 | 282 | // would otherwise interpret. | |
| 283 | - | // | |
| 284 | - | //hexcheck:ignore suspicious-business-logic-in-adapter port method name only | |
| 285 | 283 | func (g *Git) HasBranch(ctx context.Context, name domain.RepoName, branch string) (bool, error) { | |
| 286 | 284 | dir, err := g.repoDir(name) | |
| 287 | 285 | if err != nil { | |
M internal/adapters/gitcli/stats.go +0 -6
| @@ -18,8 +18,6 @@ import ( | |||
| 18 | 18 | ||
| 19 | 19 | // CountCommits runs `git rev-list --count`, from the commit graph when the | |
| 20 | 20 | // repository has one. | |
| 21 | - | // | |
| 22 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads the count git prints | |
| 23 | 21 | func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domain.ObjectID) (int, error) { | |
| 24 | 22 | if head.IsZero() { | |
| 25 | 23 | return 0, fmt.Errorf("count commits: %w: no head", domain.ErrInvalid) | |
| @@ -45,8 +43,6 @@ func (g *Git) CountCommits(ctx context.Context, repo domain.RepoName, head domai | |||
| 45 | 43 | // CommitTimes runs `git rev-list --since-as-filter --format=%ct`. The filter | |
| 46 | 44 | // looks at every reachable commit: --since stops the walk at the first older | |
| 47 | 45 | // one, and misses newer commits behind it when dates are out of order. | |
| 48 | - | // | |
| 49 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads the dates git prints; the bound comes from the port | |
| 50 | 46 | func (g *Git) CommitTimes(ctx context.Context, repo domain.RepoName, head domain.ObjectID, since time.Time) ([]time.Time, bool, error) { | |
| 51 | 47 | if head.IsZero() { | |
| 52 | 48 | return nil, false, fmt.Errorf("commit times: %w: no head", domain.ErrInvalid) | |
| @@ -91,8 +87,6 @@ func (g *Git) CommitTimes(ctx context.Context, repo domain.RepoName, head domain | |||
| 91 | 87 | } | |
| 92 | 88 | ||
| 93 | 89 | // LatestTag runs `git for-each-ref --sort=-creatordate --count=1` on the tags. | |
| 94 | - | // | |
| 95 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads the line git prints | |
| 96 | 90 | func (g *Git) LatestTag(ctx context.Context, repo domain.RepoName) (domain.TagInfo, bool, error) { | |
| 97 | 91 | dir, err := g.repoDir(repo) | |
| 98 | 92 | if err != nil { | |
D internal/adapters/gitcli/workflow_guard.go +0 -438
| @@ -1,438 +0,0 @@ | |||
| 1 | - | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | - | ||
| 3 | - | package gitcli | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "bufio" | |
| 7 | - | "context" | |
| 8 | - | "errors" | |
| 9 | - | "fmt" | |
| 10 | - | "io" | |
| 11 | - | "path/filepath" | |
| 12 | - | "strconv" | |
| 13 | - | "strings" | |
| 14 | - | ||
| 15 | - | "bnema.dev/ermite/internal/domain" | |
| 16 | - | "bnema.dev/ermite/internal/ports" | |
| 17 | - | ) | |
| 18 | - | ||
| 19 | - | var _ ports.WorkflowGuard = (*Git)(nil) | |
| 20 | - | ||
| 21 | - | // workflowsDir is where workflow files live, as a literal pathspec directory. | |
| 22 | - | const workflowsDir = ".github/workflows/" | |
| 23 | - | ||
| 24 | - | // maxWorkflowPath bounds the one path the guard reads from git: a longer one | |
| 25 | - | // fails closed (the push is refused), whatever the trees hold. | |
| 26 | - | const maxWorkflowPath = 4096 | |
| 27 | - | ||
| 28 | - | // Bounds of the walk over the commits a push brings: more than that, or a | |
| 29 | - | // commit with more parents than a line of rev-list holds, fails closed (the | |
| 30 | - | // push is refused), whatever the commits hold. maxPushedObjects counts every | |
| 31 | - | // commit and every parent listed, repeats included (so it bounds the distinct | |
| 32 | - | // ids too), and is enforced as the lines are read, before anything more is | |
| 33 | - | // kept: a few thousand commits with two hundred parents each are refused after | |
| 34 | - | // the first lines, not after they have been held. | |
| 35 | - | const ( | |
| 36 | - | maxPushedCommits = 10000 | |
| 37 | - | maxPushedObjects = 4 * maxPushedCommits | |
| 38 | - | maxRevListLine = 8192 | |
| 39 | - | ) | |
| 40 | - | ||
| 41 | - | // ChangedWorkflow tells whether a push changes the workflows of a repository. | |
| 42 | - | // It returns the path of the first change, "" for none. Deleting a ref changes | |
| 43 | - | // nothing, and a workflows directory that disappears is not a change. | |
| 44 | - | // | |
| 45 | - | // Two things are judged, as GitHub does for the token of a job (it refuses a | |
| 46 | - | // push when the commits pushed change workflow files, not when a new branch is | |
| 47 | - | // cut from a commit that has them): | |
| 48 | - | // | |
| 49 | - | // 1. Every commit the push brings (`rev-list <new> --not --all`, so the commits | |
| 50 | - | // no ref has yet) must leave .github/workflows as it found it: it is | |
| 51 | - | // compared with each of its parents (a root commit with nothing) and is a | |
| 52 | - | // change when it is present and differs from every one of them. A merge of | |
| 53 | - | // existing commits is thus no change; a side that brings a change is itself | |
| 54 | - | // a new commit, and judged as one. | |
| 55 | - | // 2. The directory a ref ends with must not be one it did not have: for an | |
| 56 | - | // update the final .github/workflows must be the old one or absent, for a | |
| 57 | - | // new ref the one of the default branch (HEAD) or absent. This keeps a job | |
| 58 | - | // token from moving a ref onto existing commits that carry other workflows | |
| 59 | - | // (an older commit, a commit of a branch with different ones). | |
| 60 | - | // | |
| 61 | - | // Presence is the directory the workflow loader lists (CI.workflowFiles, on both | |
| 62 | - | // git readers): .github and workflows both entries of mode 040000 and type | |
| 63 | - | // tree. A path through a gitlink (160000), a symbolic link (120000, which the | |
| 64 | - | // loader does not follow) or a file is no workflows, so the same tree id that is | |
| 65 | - | // no workflows as a gitlink and becomes workflows as a directory is a change, | |
| 66 | - | // which the object id alone would not tell. Mode and id of | |
| 67 | - | // "<commit>:.github/workflows" are what is compared: equal means the same | |
| 68 | - | // content, whatever it holds, so nothing is read. | |
| 69 | - | // | |
| 70 | - | // Nothing is walked recursively: the trees of a push are the pusher's, and a | |
| 71 | - | // recursive diff of trees that share subtrees fans out into more paths than the | |
| 72 | - | // push has bytes (git builds the whole diff before it prints a line, so reading | |
| 73 | - | // it as a stream would not help). The shallow diff that names the path reads one | |
| 74 | - | // directory, as large as its tree object, as a stream, up to its first name, and | |
| 75 | - | // git is stopped and reaped then; a name longer than maxWorkflowPath fails | |
| 76 | - | // closed. | |
| 77 | - | // | |
| 78 | - | // The ends of each update are peeled first (`cat-file --batch-check` on | |
| 79 | - | // `<oid>^{}`): a commit or a tree is compared, a blob (a tag may point to one) | |
| 80 | - | // holds no workflows; a missing or malformed object fails closed. Git reads the | |
| 81 | - | // objects of a push still in quarantine through the environment of the hook | |
| 82 | - | // (ports.PushObjects), accepted only inside the repository's objects directory. | |
| 83 | - | // | |
| 84 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads the object ids and the path git prints for the compared trees; the rule that refuses is the use case's | |
| 85 | - | func (g *Git) ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error) { | |
| 86 | - | dir, err := g.repoDir(repo) | |
| 87 | - | if err != nil { | |
| 88 | - | return "", err | |
| 89 | - | } | |
| 90 | - | extra, err := quarantineEnv(dir, objects) | |
| 91 | - | if err != nil { | |
| 92 | - | return "", err | |
| 93 | - | } | |
| 94 | - | kept := make([]domain.RefUpdate, 0, len(updates)) | |
| 95 | - | for _, up := range updates { | |
| 96 | - | if !up.IsDelete() { | |
| 97 | - | kept = append(kept, up) | |
| 98 | - | } | |
| 99 | - | } | |
| 100 | - | if len(kept) == 0 { | |
| 101 | - | return "", nil | |
| 102 | - | } | |
| 103 | - | types, err := g.batchCheck(ctx, dir, extra, kept, "^{}", "%(objecttype)") | |
| 104 | - | if err != nil { | |
| 105 | - | return "", err | |
| 106 | - | } | |
| 107 | - | if p, err := g.changedByNewCommits(ctx, dir, extra, kept, types); p != "" || err != nil { | |
| 108 | - | return p, err | |
| 109 | - | } | |
| 110 | - | trees, err := g.batchCheck(ctx, dir, extra, kept, ":"+strings.TrimSuffix(workflowsDir, "/"), "%(objectmode) %(objectname) %(objecttype)") | |
| 111 | - | if err != nil { | |
| 112 | - | return "", err | |
| 113 | - | } | |
| 114 | - | headW, headRead := "", false | |
| 115 | - | for i, up := range kept { | |
| 116 | - | if types[2*i+1] == "blob" { | |
| 117 | - | continue // a blob holds no workflow, whatever it replaces | |
| 118 | - | } | |
| 119 | - | base := workflowsOf(types[2*i], trees[2*i]) | |
| 120 | - | if up.IsCreate() { | |
| 121 | - | if !headRead { | |
| 122 | - | w, err := g.workflowsAt(ctx, dir, extra, []string{"HEAD:" + strings.TrimSuffix(workflowsDir, "/")}) | |
| 123 | - | if err != nil { | |
| 124 | - | return "", err | |
| 125 | - | } | |
| 126 | - | headW, headRead = w["HEAD:"+strings.TrimSuffix(workflowsDir, "/")], true | |
| 127 | - | } | |
| 128 | - | base = headW // a new ref may end with the workflows of the default branch | |
| 129 | - | } | |
| 130 | - | newW := workflowsOf(types[2*i+1], trees[2*i+1]) | |
| 131 | - | if base == newW || newW == "" { | |
| 132 | - | continue // the same directory, or none left | |
| 133 | - | } | |
| 134 | - | return g.firstChanged(ctx, dir, extra, base, newW) | |
| 135 | - | } | |
| 136 | - | return "", nil | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | // changedByNewCommits judges the commits the updates bring (rule 1 of | |
| 140 | - | // ChangedWorkflow). | |
| 141 | - | func (g *Git) changedByNewCommits(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, types []string) (string, error) { | |
| 142 | - | var tips []string | |
| 143 | - | seen := map[string]bool{} | |
| 144 | - | for i, up := range ups { | |
| 145 | - | if types[2*i+1] == "commit" && !seen[up.New] { | |
| 146 | - | seen[up.New] = true | |
| 147 | - | tips = append(tips, up.New) | |
| 148 | - | } | |
| 149 | - | } | |
| 150 | - | if len(tips) == 0 { | |
| 151 | - | return "", nil | |
| 152 | - | } | |
| 153 | - | commits, err := g.newCommits(ctx, dir, extra, tips) | |
| 154 | - | if err != nil { | |
| 155 | - | return "", err | |
| 156 | - | } | |
| 157 | - | const suffix = ":.github/workflows" | |
| 158 | - | var specs []string | |
| 159 | - | asked := map[string]bool{} | |
| 160 | - | for _, c := range commits { | |
| 161 | - | for _, id := range append([]string{c.id}, c.parents...) { | |
| 162 | - | if !asked[id] { | |
| 163 | - | asked[id] = true | |
| 164 | - | specs = append(specs, id+suffix) | |
| 165 | - | } | |
| 166 | - | } | |
| 167 | - | } | |
| 168 | - | ws, err := g.workflowsAt(ctx, dir, extra, specs) | |
| 169 | - | if err != nil { | |
| 170 | - | return "", err | |
| 171 | - | } | |
| 172 | - | for _, c := range commits { | |
| 173 | - | cur := ws[c.id+suffix] | |
| 174 | - | if cur == "" { | |
| 175 | - | continue // none: nothing new is active | |
| 176 | - | } | |
| 177 | - | first, same := "", false | |
| 178 | - | for i, p := range c.parents { | |
| 179 | - | if i == 0 { | |
| 180 | - | first = ws[p+suffix] | |
| 181 | - | } | |
| 182 | - | if ws[p+suffix] == cur { | |
| 183 | - | same = true | |
| 184 | - | } | |
| 185 | - | } | |
| 186 | - | if !same { | |
| 187 | - | return g.firstChanged(ctx, dir, extra, first, cur) | |
| 188 | - | } | |
| 189 | - | } | |
| 190 | - | return "", nil | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | type pushedCommit struct { | |
| 194 | - | id string | |
| 195 | - | parents []string | |
| 196 | - | } | |
| 197 | - | ||
| 198 | - | // newCommits lists the commits reachable from the tips and from no ref | |
| 199 | - | // (`rev-list --parents <tips> --not --all`), at most maxPushedCommits: more | |
| 200 | - | // fails closed. | |
| 201 | - | func (g *Git) newCommits(ctx context.Context, dir string, extra []string, tips []string) ([]pushedCommit, error) { | |
| 202 | - | args := readArgs(dir, append(append([]string{"rev-list", "--parents", "--max-count=" + strconv.Itoa(maxPushedCommits+1)}, tips...), "--not", "--all")...) | |
| 203 | - | var out []pushedCommit | |
| 204 | - | err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { | |
| 205 | - | var err error | |
| 206 | - | out, err = parseRevList(r, maxPushedCommits, maxPushedObjects) | |
| 207 | - | return err | |
| 208 | - | }) | |
| 209 | - | if err != nil { | |
| 210 | - | return nil, fmt.Errorf("commits of a push: %w", err) | |
| 211 | - | } | |
| 212 | - | return out, nil | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | // parseRevList reads the lines of `rev-list --parents` ("<commit> <parent>..."): | |
| 216 | - | // at most maxCommits lines and maxIDs ids in all (a commit and each of its | |
| 217 | - | // parents count, repeats too). A line over maxRevListLine, a limit crossed or an | |
| 218 | - | // id that is not one is an error, and it is raised as soon as the line that | |
| 219 | - | // crosses it is read: nothing past it is read or kept. | |
| 220 | - | func parseRevList(r *bufio.Reader, maxCommits, maxIDs int) ([]pushedCommit, error) { | |
| 221 | - | var out []pushedCommit | |
| 222 | - | ids := 0 | |
| 223 | - | for { | |
| 224 | - | line, err := r.ReadSlice('\n') | |
| 225 | - | switch { | |
| 226 | - | case errors.Is(err, bufio.ErrBufferFull) || len(line) > maxRevListLine: | |
| 227 | - | return nil, fmt.Errorf("%w: a pushed commit has too many parents", domain.ErrInvalid) | |
| 228 | - | case errors.Is(err, io.EOF) && len(line) == 0: | |
| 229 | - | return out, nil | |
| 230 | - | case err != nil && !errors.Is(err, io.EOF): | |
| 231 | - | return nil, err | |
| 232 | - | } | |
| 233 | - | f := strings.Fields(string(line)) | |
| 234 | - | if len(f) == 0 { | |
| 235 | - | continue | |
| 236 | - | } | |
| 237 | - | if len(out) == maxCommits { | |
| 238 | - | return nil, fmt.Errorf("%w: the push brings more than %d commits", domain.ErrInvalid, maxCommits) | |
| 239 | - | } | |
| 240 | - | if ids += len(f); ids > maxIDs { | |
| 241 | - | return nil, fmt.Errorf("%w: the push brings commits with more than %d ids (commits and parents) in all", domain.ErrInvalid, maxIDs) | |
| 242 | - | } | |
| 243 | - | for _, id := range f { | |
| 244 | - | if _, err := domain.ParseObjectID(id); err != nil { | |
| 245 | - | return nil, fmt.Errorf("%w: unexpected rev-list output", domain.ErrInvalid) | |
| 246 | - | } | |
| 247 | - | } | |
| 248 | - | out = append(out, pushedCommit{id: f[0], parents: f[1:]}) | |
| 249 | - | } | |
| 250 | - | } | |
| 251 | - | ||
| 252 | - | // workflowsAt returns, for each spec ("<rev>:.github/workflows"), the id of the | |
| 253 | - | // directory it names, or "" when there is none (see workflowsOf). | |
| 254 | - | func (g *Git) workflowsAt(ctx context.Context, dir string, extra []string, specs []string) (map[string]string, error) { | |
| 255 | - | out := make(map[string]string, len(specs)) | |
| 256 | - | if len(specs) == 0 { | |
| 257 | - | return out, nil | |
| 258 | - | } | |
| 259 | - | args := readArgs(dir, "cat-file", "--batch-check=%(objectmode) %(objectname) %(objecttype)") | |
| 260 | - | err := g.pipeWith(ctx, extra, strings.NewReader(strings.Join(specs, "\n")+"\n"), args, func(r *bufio.Reader) error { | |
| 261 | - | for _, spec := range specs { | |
| 262 | - | line, err := r.ReadSlice('\n') | |
| 263 | - | if err != nil { | |
| 264 | - | return fmt.Errorf("read object answers: %w", err) | |
| 265 | - | } | |
| 266 | - | out[spec] = workflowsOf("", strings.TrimSuffix(string(line), "\n")) | |
| 267 | - | } | |
| 268 | - | return nil | |
| 269 | - | }) | |
| 270 | - | if err != nil { | |
| 271 | - | return nil, fmt.Errorf("workflows of a push: %w", err) | |
| 272 | - | } | |
| 273 | - | return out, nil | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | // workflowsOf is the id of the tree that is the .github/workflows of a commit | |
| 277 | - | // or a tree, "" when there is none: the answer for "<it>:.github/workflows" must | |
| 278 | - | // be a tree of mode 040000, and the object peeled to a tree-ish, not a blob. | |
| 279 | - | func workflowsOf(objType, answer string) string { | |
| 280 | - | if objType == "blob" { | |
| 281 | - | return "" | |
| 282 | - | } | |
| 283 | - | mode, rest, _ := strings.Cut(answer, " ") | |
| 284 | - | id, typ, _ := strings.Cut(rest, " ") | |
| 285 | - | if mode != "040000" || typ != "tree" { | |
| 286 | - | return "" | |
| 287 | - | } | |
| 288 | - | return id | |
| 289 | - | } | |
| 290 | - | ||
| 291 | - | // firstChanged names the first top-level entry of .github/workflows that | |
| 292 | - | // differs between two trees (oldID may be "": none, the empty tree). | |
| 293 | - | func (g *Git) firstChanged(ctx context.Context, dir string, extra []string, oldID, newID string) (string, error) { | |
| 294 | - | from := oldID | |
| 295 | - | if from == "" { | |
| 296 | - | from = emptyTree | |
| 297 | - | } | |
| 298 | - | to := newID | |
| 299 | - | args := readArgs(dir, "diff-tree", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, to) | |
| 300 | - | var name string | |
| 301 | - | err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { | |
| 302 | - | n, found, err := firstPath(r) | |
| 303 | - | name = n | |
| 304 | - | if err != nil || !found { | |
| 305 | - | return err // no name: git's exit status is checked, so a failure refuses | |
| 306 | - | } | |
| 307 | - | return errStop // the first name is all that is needed: stop git | |
| 308 | - | }) | |
| 309 | - | if err != nil { | |
| 310 | - | return "", fmt.Errorf("changed workflows %s..%s: %w", from, to, err) | |
| 311 | - | } | |
| 312 | - | if name == "" { | |
| 313 | - | return strings.TrimSuffix(workflowsDir, "/"), nil | |
| 314 | - | } | |
| 315 | - | return workflowsDir + name, nil | |
| 316 | - | } | |
| 317 | - | ||
| 318 | - | // firstPath reads the first NUL-terminated path of r; found is false at the end | |
| 319 | - | // of the output without one. A path longer than maxWorkflowPath is an error. | |
| 320 | - | func firstPath(r *bufio.Reader) (path string, found bool, err error) { | |
| 321 | - | var b []byte | |
| 322 | - | for { | |
| 323 | - | c, err := r.ReadByte() | |
| 324 | - | switch { | |
| 325 | - | case err != nil: | |
| 326 | - | return "", false, nil // the end of the output; a failing exit status is reported by pipe | |
| 327 | - | case c == 0: | |
| 328 | - | return string(b), true, nil | |
| 329 | - | case len(b) >= maxWorkflowPath: | |
| 330 | - | return "", false, fmt.Errorf("%w: a changed path is longer than %d bytes", domain.ErrInvalid, maxWorkflowPath) | |
| 331 | - | } | |
| 332 | - | b = append(b, c) | |
| 333 | - | } | |
| 334 | - | } | |
| 335 | - | ||
| 336 | - | // batchCheck asks `cat-file --batch-check=<format>` about <oid><suffix> for the | |
| 337 | - | // old (unless the ref is created) and the new end of each update and returns | |
| 338 | - | // the answers in that order, two per update, "" for the old end of a created | |
| 339 | - | // ref. With suffix "^{}" an object that is missing, malformed or neither a | |
| 340 | - | // commit, a tree nor a blob is an error (the push is refused); with any other | |
| 341 | - | // suffix an answer "<input> missing" is kept as the answer. | |
| 342 | - | func (g *Git) batchCheck(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, suffix, format string) ([]string, error) { | |
| 343 | - | var in strings.Builder | |
| 344 | - | want := 0 | |
| 345 | - | for _, up := range ups { | |
| 346 | - | for _, oid := range []string{up.Old, up.New} { | |
| 347 | - | if oid == domain.ZeroOID { | |
| 348 | - | continue | |
| 349 | - | } | |
| 350 | - | if _, err := domain.ParseObjectID(oid); err != nil { | |
| 351 | - | return nil, fmt.Errorf("%w: %v", domain.ErrInvalid, err) | |
| 352 | - | } | |
| 353 | - | in.WriteString(oid + suffix + "\n") | |
| 354 | - | want++ | |
| 355 | - | } | |
| 356 | - | } | |
| 357 | - | got := make([]string, 0, want) | |
| 358 | - | args := readArgs(dir, "cat-file", "--batch-check="+format) | |
| 359 | - | err := g.pipeWith(ctx, extra, strings.NewReader(in.String()), args, func(r *bufio.Reader) error { | |
| 360 | - | for range want { | |
| 361 | - | line, err := r.ReadSlice('\n') | |
| 362 | - | if err != nil { | |
| 363 | - | if errors.Is(err, bufio.ErrBufferFull) { | |
| 364 | - | return fmt.Errorf("%w: unexpected cat-file output", domain.ErrInvalid) | |
| 365 | - | } | |
| 366 | - | return fmt.Errorf("read object answers: %w", err) | |
| 367 | - | } | |
| 368 | - | got = append(got, strings.TrimSuffix(string(line), "\n")) | |
| 369 | - | } | |
| 370 | - | return nil | |
| 371 | - | }) | |
| 372 | - | if err != nil { | |
| 373 | - | return nil, fmt.Errorf("objects of a push: %w", err) | |
| 374 | - | } | |
| 375 | - | out := make([]string, 0, 2*len(ups)) | |
| 376 | - | i := 0 | |
| 377 | - | for _, up := range ups { | |
| 378 | - | for _, oid := range []string{up.Old, up.New} { | |
| 379 | - | if oid == domain.ZeroOID { | |
| 380 | - | out = append(out, "") | |
| 381 | - | continue | |
| 382 | - | } | |
| 383 | - | if suffix == "^{}" { | |
| 384 | - | switch got[i] { | |
| 385 | - | case "commit", "tree", "blob": | |
| 386 | - | default: // "<input> missing", or a type that cannot be compared | |
| 387 | - | return nil, fmt.Errorf("%w: object %s of the push is missing or of an unexpected type", domain.ErrInvalid, oid) | |
| 388 | - | } | |
| 389 | - | } | |
| 390 | - | out = append(out, got[i]) | |
| 391 | - | i++ | |
| 392 | - | } | |
| 393 | - | } | |
| 394 | - | return out, nil | |
| 395 | - | } | |
| 396 | - | ||
| 397 | - | // quarantineEnv returns the environment that makes git read the objects of a | |
| 398 | - | // push in quarantine. Each directory must exist and, once symbolic links are | |
| 399 | - | // resolved, be inside the objects directory of the repository dir (itself | |
| 400 | - | // resolved): a hook request cannot send git to read anything else. The resolved | |
| 401 | - | // paths are the ones given to git. | |
| 402 | - | func quarantineEnv(repoDir string, o ports.PushObjects) ([]string, error) { | |
| 403 | - | if o.Dir == "" && o.Alternates == "" { | |
| 404 | - | return nil, nil | |
| 405 | - | } | |
| 406 | - | root, err := filepath.EvalSymlinks(filepath.Join(repoDir, "objects")) | |
| 407 | - | if err != nil { | |
| 408 | - | return nil, fmt.Errorf("resolve the objects directory of %s: %w", repoDir, err) | |
| 409 | - | } | |
| 410 | - | inside := func(p string) (string, bool) { | |
| 411 | - | if !filepath.IsAbs(p) { | |
| 412 | - | return "", false | |
| 413 | - | } | |
| 414 | - | real, err := filepath.EvalSymlinks(p) | |
| 415 | - | if err != nil { | |
| 416 | - | return "", false | |
| 417 | - | } | |
| 418 | - | rel, err := filepath.Rel(root, real) | |
| 419 | - | return real, err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | |
| 420 | - | } | |
| 421 | - | dir, ok := inside(o.Dir) | |
| 422 | - | if o.Dir == "" || !ok { | |
| 423 | - | return nil, fmt.Errorf("%w: the objects directory of the push is outside the repository", domain.ErrInvalid) | |
| 424 | - | } | |
| 425 | - | env := []string{"GIT_OBJECT_DIRECTORY=" + dir} | |
| 426 | - | if o.Alternates != "" { | |
| 427 | - | var alts []string | |
| 428 | - | for _, a := range filepath.SplitList(o.Alternates) { | |
| 429 | - | real, ok := inside(a) | |
| 430 | - | if !ok { | |
| 431 | - | return nil, fmt.Errorf("%w: an alternate objects directory of the push is outside the repository", domain.ErrInvalid) | |
| 432 | - | } | |
| 433 | - | alts = append(alts, real) | |
| 434 | - | } | |
| 435 | - | env = append(env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+strings.Join(alts, string(filepath.ListSeparator))) | |
| 436 | - | } | |
| 437 | - | return env, nil | |
| 438 | - | } | |
M internal/adapters/githttp/handler.go +4 -4
| @@ -174,6 +174,8 @@ var gitSuffixes = []string{"/info/refs", "/" + serviceUpload, "/" + serviceRecei | |||
| 174 | 174 | // and /<repo>/git-receive-pack, and the same under /<owner>/ for the owner of | |
| 175 | 175 | // the forge. info/refs without a service is the dumb protocol, which ermite | |
| 176 | 176 | // does not serve. | |
| 177 | + | // | |
| 178 | + | //hexcheck:ignore suspicious-business-logic-in-adapter decodes smart-HTTP repository routes and owner aliases, validating methods and service query parameters | |
| 177 | 179 | func (h *Handler) parseRoute(r *http.Request) (route, int) { | |
| 178 | 180 | p := strings.TrimPrefix(r.URL.Path, "/") | |
| 179 | 181 | var head, rest string | |
| @@ -224,8 +226,6 @@ func (h *Handler) parseRoute(r *http.Request) (route, int) { | |||
| 224 | 226 | } | |
| 225 | 227 | ||
| 226 | 228 | // ServeHTTP implements http.Handler. | |
| 227 | - | // | |
| 228 | - | //hexcheck:ignore suspicious-business-logic-in-adapter transport orchestration: the authorization decisions are the use cases' | |
| 229 | 229 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 230 | 230 | var rt route | |
| 231 | 231 | var name domain.RepoName | |
| @@ -328,6 +328,8 @@ func credentials(r *http.Request) (secret string, present bool) { | |||
| 328 | 328 | // denied answers an authorization failure. A caller without valid | |
| 329 | 329 | // credentials is asked for them wherever the repository does not show itself, | |
| 330 | 330 | // which keeps a private repository and a missing one indistinguishable. | |
| 331 | + | // | |
| 332 | + | //hexcheck:ignore suspicious-business-logic-in-adapter implements the smart-HTTP protocol visibility/auth challenge policy (401 vs 404) for hidden or missing repositories | |
| 331 | 333 | func (h *Handler) denied(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error) { | |
| 332 | 334 | switch { | |
| 333 | 335 | case errors.Is(err, domain.ErrUnauthenticated): | |
| @@ -418,8 +420,6 @@ func (h *Handler) acquire(ctx context.Context, w http.ResponseWriter, rt route, | |||
| 418 | 420 | } | |
| 419 | 421 | ||
| 420 | 422 | // serveBackend runs git http-backend for an authorized request. | |
| 421 | - | // | |
| 422 | - | //hexcheck:ignore suspicious-business-logic-in-adapter process and CGI plumbing: no business rule is decided here | |
| 423 | 423 | func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal, client string) { | |
| 424 | 424 | ctx := r.Context() | |
| 425 | 425 | logger := zerowrap.FromCtx(ctx) | |
M internal/adapters/githttp/hooks.go +0 -2
| @@ -175,8 +175,6 @@ func (s *HookServer) Close(ctx context.Context) { | |||
| 175 | 175 | } | |
| 176 | 176 | ||
| 177 | 177 | // handle serves one connection: one request line, one reply line. | |
| 178 | - | // | |
| 179 | - | //hexcheck:ignore suspicious-business-logic-in-adapter hook protocol dispatch: the decisions are the use cases' | |
| 180 | 178 | func (s *HookServer) handle(ctx context.Context, conn net.Conn) { | |
| 181 | 179 | log := zerowrap.FromCtx(ctx) | |
| 182 | 180 | _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) | |
M internal/adapters/kvstore/ci.go +11 -18
| @@ -310,6 +310,9 @@ func decodeRun(item kv.Item) (domain.CIRun, error) { | |||
| 310 | 310 | }, nil | |
| 311 | 311 | } | |
| 312 | 312 | ||
| 313 | + | // encodeJob serializes a job for storage. | |
| 314 | + | // | |
| 315 | + | //hexcheck:ignore suspicious-business-logic-in-adapter encodes a job as protobuf, rejecting an empty repository and omitting an absent token hash | |
| 313 | 316 | func encodeJob(j domain.CIJob) ([]byte, error) { | |
| 314 | 317 | if j.Repo.IsZero() { | |
| 315 | 318 | return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName) | |
| @@ -346,7 +349,6 @@ func encodeJob(j domain.CIJob) ([]byte, error) { | |||
| 346 | 349 | return mustMarshal(pb), nil | |
| 347 | 350 | } | |
| 348 | 351 | ||
| 349 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps the stored record to the domain job field by field: no rule is decided here | |
| 350 | 352 | func decodeJob(item kv.Item) (domain.CIJob, error) { | |
| 351 | 353 | var pb storagepb.CIJob | |
| 352 | 354 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| @@ -530,8 +532,6 @@ func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (gu | |||
| 530 | 532 | } | |
| 531 | 533 | ||
| 532 | 534 | // Apply writes the records in one batch. | |
| 533 | - | // | |
| 534 | - | //hexcheck:ignore suspicious-business-logic-in-adapter stages records and their derived indexes under version preconditions: a storage concern, no policy | |
| 535 | 535 | func (c *CI) Apply(ctx context.Context, w ports.CIWrite) (uint64, error) { | |
| 536 | 536 | var b kv.Batch | |
| 537 | 537 | guarded, err := c.stageRecords(ctx, &b, w) | |
| @@ -576,18 +576,14 @@ func stageSeq(b *kv.Batch, key []byte, item kv.Item, ok bool, last int64) { | |||
| 576 | 576 | ||
| 577 | 577 | const createRunTries = 20 | |
| 578 | 578 | ||
| 579 | - | // CreateRun takes the next run id and number and stores the draft. | |
| 579 | + | // CreateRun takes the next run id and number and stores the draft. The batch | |
| 580 | + | // requires the workflow's disabled key absent, so a disable that races the | |
| 581 | + | // caller's check fails the batch and is reported as domain.ErrWorkflowDisabled. | |
| 580 | 582 | // | |
| 581 | - | //hexcheck:ignore suspicious-business-logic-in-adapter takes the next numbers under version preconditions: a storage concern, no policy | |
| 583 | + | //hexcheck:ignore suspicious-business-logic-in-adapter atomic precondition only: maps a conflict on the disabled-workflow key to its domain error; the usecase decides | |
| 582 | 584 | func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) { | |
| 583 | 585 | offKey := workflowOffKey(repo, workflowPath) | |
| 584 | 586 | for range createRunTries { | |
| 585 | - | // A workflow the repository disabled gets no run, whatever started it. | |
| 586 | - | // The key is also required absent by the batch below, so a disable that | |
| 587 | - | // lands after this read fails the batch, and the next try sees it. | |
| 588 | - | if _, off := c.db.Get(ctx, offKey); off { | |
| 589 | - | return ports.CIRunDraft{}, domain.ErrWorkflowDisabled | |
| 590 | - | } | |
| 591 | 587 | lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey)) | |
| 592 | 588 | if err != nil { | |
| 593 | 589 | return ports.CIRunDraft{}, err | |
| @@ -620,6 +616,10 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s | |||
| 620 | 616 | } | |
| 621 | 617 | v, err := c.db.Apply(ctx, &b) | |
| 622 | 618 | if errors.Is(err, kv.ErrConflict) { | |
| 619 | + | // Translate a failed disabled-workflow precondition; other conflicts retry. | |
| 620 | + | if _, off := c.db.Get(ctx, offKey); off { | |
| 621 | + | return ports.CIRunDraft{}, domain.ErrWorkflowDisabled | |
| 622 | + | } | |
| 623 | 623 | continue | |
| 624 | 624 | } | |
| 625 | 625 | if err != nil { | |
| @@ -669,8 +669,6 @@ func (c *CI) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, | |||
| 669 | 669 | } | |
| 670 | 670 | ||
| 671 | 671 | // ListRuns returns the runs of the repository, newest first. | |
| 672 | - | // | |
| 673 | - | //hexcheck:ignore suspicious-business-logic-in-adapter port method name only: reads a key range and reverses it | |
| 674 | 672 | func (c *CI) ListRuns(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error) { | |
| 675 | 673 | start := []byte(runsPrefix(repo)) | |
| 676 | 674 | end := []byte(runPrefix + repo.String() + "0") // '/' + 1 | |
| @@ -906,8 +904,6 @@ func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]by | |||
| 906 | 904 | const sweepChunk = 500 | |
| 907 | 905 | ||
| 908 | 906 | // Sweep removes the runs completed before the time. | |
| 909 | - | // | |
| 910 | - | //hexcheck:ignore suspicious-business-logic-in-adapter chunked delete of expired records: a storage concern, the retention is decided by the caller | |
| 911 | 907 | func (c *CI) Sweep(ctx context.Context, before time.Time) ([]int64, error) { | |
| 912 | 908 | snap := c.db.Snapshot(ctx) | |
| 913 | 909 | var ids []int64 | |
| @@ -955,8 +951,6 @@ func runnerNameKey(n string) []byte { return []byte(runnerName + stri | |||
| 955 | 951 | func runnerTokenKey(h domain.TokenHash) []byte { return []byte(runnerTokPref + h.String()) } | |
| 956 | 952 | ||
| 957 | 953 | // CreateRunner stores the runner, its name index and its token index. | |
| 958 | - | // | |
| 959 | - | //hexcheck:ignore suspicious-business-logic-in-adapter replaces a runner of the same name in one batch: a storage concern, the rule is decided by the caller | |
| 960 | 954 | func (c *CI) CreateRunner(ctx context.Context, r domain.CIRunner, replace bool) ([]domain.CIRunner, error) { | |
| 961 | 955 | for range createRunTries { | |
| 962 | 956 | var b kv.Batch | |
| @@ -1115,7 +1109,6 @@ func encodeCall(c *domain.CICall) *storagepb.CICall { | |||
| 1115 | 1109 | } | |
| 1116 | 1110 | } | |
| 1117 | 1111 | ||
| 1118 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps the stored call to the domain call field by field: no rule is decided here | |
| 1119 | 1112 | func decodeCall(pb *storagepb.CICall) *domain.CICall { | |
| 1120 | 1113 | if pb == nil { | |
| 1121 | 1114 | return nil | |
M internal/adapters/kvstore/ci_artifact.go +3 -5
| @@ -79,6 +79,9 @@ var artifactStates = []struct { | |||
| 79 | 79 | {domain.CIArtifactFinal, storagepb.CIArtifactState_CI_ARTIFACT_STATE_FINAL}, | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | + | // encodeArtifact serializes an artifact for storage. | |
| 83 | + | // | |
| 84 | + | //hexcheck:ignore suspicious-business-logic-in-adapter validates artifact storage identifiers, UTF-8 and enum encoding before protobuf serialization | |
| 82 | 85 | func encodeArtifact(a domain.CIArtifact) ([]byte, error) { | |
| 83 | 86 | if a.Repo.IsZero() || a.ID < 1 || a.RunID < 1 { | |
| 84 | 87 | return nil, fmt.Errorf("%w: artifact without id, run or repository", domain.ErrInvalid) | |
| @@ -106,7 +109,6 @@ func encodeArtifact(a domain.CIArtifact) ([]byte, error) { | |||
| 106 | 109 | return mustMarshal(pb), nil | |
| 107 | 110 | } | |
| 108 | 111 | ||
| 109 | - | //hexcheck:ignore suspicious-business-logic-in-adapter decodes a record into the domain type: a storage concern, no policy | |
| 110 | 112 | func decodeArtifact(item kv.Item) (domain.CIArtifact, error) { | |
| 111 | 113 | var pb storagepb.CIArtifact | |
| 112 | 114 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| @@ -141,8 +143,6 @@ func decodeArtifact(item kv.Item) (domain.CIArtifact, error) { | |||
| 141 | 143 | const artifactIDTries = 1000 | |
| 142 | 144 | ||
| 143 | 145 | // NextArtifactID takes the next id in its own batch. | |
| 144 | - | // | |
| 145 | - | //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy | |
| 146 | 146 | func (c *CIArtifacts) NextArtifactID(ctx context.Context) (int64, error) { | |
| 147 | 147 | ci := &CI{db: c.db} | |
| 148 | 148 | for range artifactIDTries { | |
| @@ -274,8 +274,6 @@ func (c *CIArtifacts) BlockArtifactIDs(ctx context.Context) ([]int64, error) { | |||
| 274 | 274 | // --- writes --- | |
| 275 | 275 | ||
| 276 | 276 | // ApplyArtifacts writes w in one batch. | |
| 277 | - | // | |
| 278 | - | //hexcheck:ignore suspicious-business-logic-in-adapter stages records and their derived keys under version preconditions: a storage concern, no policy | |
| 279 | 277 | func (c *CIArtifacts) ApplyArtifacts(ctx context.Context, w ports.CIArtifactWrite) error { | |
| 280 | 278 | var b kv.Batch | |
| 281 | 279 | guard := false | |
M internal/adapters/kvstore/ci_concurrency.go +0 -4
| @@ -103,7 +103,6 @@ func encodeConcurrencyRecord(r domain.CIConcurrencyRecord) []byte { | |||
| 103 | 103 | return mustMarshal(pb) | |
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps the stored record to the domain record field by field: no rule is decided here | |
| 107 | 106 | func decodeConcurrencyRecord(val []byte) (domain.CIConcurrencyRecord, error) { | |
| 108 | 107 | var pb storagepb.CIConcurrencyRecord | |
| 109 | 108 | if err := proto.Unmarshal(val, &pb); err != nil { | |
| @@ -146,7 +145,6 @@ func encodeConcurrencyMark(m domain.CIConcurrencyMark) []byte { | |||
| 146 | 145 | return mustMarshal(pb) | |
| 147 | 146 | } | |
| 148 | 147 | ||
| 149 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps the stored mark to the domain mark field by field: no rule is decided here | |
| 150 | 148 | func decodeConcurrencyMark(val []byte) (domain.CIConcurrencyMark, error) { | |
| 151 | 149 | var pb storagepb.CIConcurrencyMark | |
| 152 | 150 | if err := proto.Unmarshal(val, &pb); err != nil { | |
| @@ -257,8 +255,6 @@ func (c *CI) UpdateConcurrency(ctx context.Context, repo domain.RepoName, group | |||
| 257 | 255 | } | |
| 258 | 256 | ||
| 259 | 257 | // ListConcurrency returns every stored group with its marks. | |
| 260 | - | // | |
| 261 | - | //hexcheck:ignore suspicious-business-logic-in-adapter lists and decodes records and marks: no rule is decided here | |
| 262 | 258 | func (c *CI) ListConcurrency(ctx context.Context) ([]ports.CIConcurrencyGroup, error) { | |
| 263 | 259 | type id struct{ repo, group string } | |
| 264 | 260 | snap := c.db.Snapshot(ctx) | |
M internal/adapters/kvstore/ci_settings.go +6 -4
| @@ -85,6 +85,8 @@ func settingsKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) | |||
| 85 | 85 | ||
| 86 | 86 | // BindSecretsKey stores the key check when there is none and compares it | |
| 87 | 87 | // otherwise. | |
| 88 | + | // | |
| 89 | + | //hexcheck:ignore suspicious-business-logic-in-adapter initializes or compares the persisted encryption-key check with compare-and-swap retries | |
| 88 | 90 | func (s *Settings) BindSecretsKey(ctx context.Context, check []byte) error { | |
| 89 | 91 | key := []byte(secretsKeyCheck) | |
| 90 | 92 | for range updateAttempts { | |
| @@ -111,6 +113,8 @@ func (s *Settings) BindSecretsKey(ctx context.Context, check []byte) error { | |||
| 111 | 113 | ||
| 112 | 114 | // stage makes the batch depend on the scope: the repository key is guarded | |
| 113 | 115 | // and, for an environment, its record is required at the version read. | |
| 116 | + | // | |
| 117 | + | //hexcheck:ignore suspicious-business-logic-in-adapter validates a settings scope and adds repository and environment existence/version preconditions to a storage batch | |
| 114 | 118 | func (s *Settings) stage(ctx context.Context, b *kv.Batch, scope domain.SettingsScope) error { | |
| 115 | 119 | if !scope.Valid() { | |
| 116 | 120 | return fmt.Errorf("%w: scope", domain.ErrInvalid) | |
| @@ -203,6 +207,8 @@ func (s *Settings) GetSealedSecret(ctx context.Context, scope domain.SettingsSco | |||
| 203 | 207 | } | |
| 204 | 208 | ||
| 205 | 209 | // existsIn is exists on a snapshot, so a listing and its check agree. | |
| 210 | + | // | |
| 211 | + | //hexcheck:ignore suspicious-business-logic-in-adapter validates a settings scope and checks repository and environment keys in the same snapshot as a listing | |
| 206 | 212 | func (s *Settings) existsIn(ctx context.Context, snap *kv.Snapshot, scope domain.SettingsScope) error { | |
| 207 | 213 | if !scope.Valid() { | |
| 208 | 214 | return fmt.Errorf("%w: scope", domain.ErrInvalid) | |
| @@ -309,8 +315,6 @@ func decodeEnvironment(val []byte) (domain.Environment, error) { | |||
| 309 | 315 | } | |
| 310 | 316 | ||
| 311 | 317 | // CreateEnvironment implements ports.CISettingsStore. | |
| 312 | - | // | |
| 313 | - | //hexcheck:ignore suspicious-business-logic-in-adapter create-if-absent under a version precondition: a storage concern, no policy | |
| 314 | 318 | func (s *Settings) CreateEnvironment(ctx context.Context, repo domain.RepoName, name string, at time.Time) (domain.Environment, error) { | |
| 315 | 319 | key := envKey(repo, name) | |
| 316 | 320 | for range updateAttempts { | |
| @@ -353,8 +357,6 @@ func (s *Settings) ListEnvironments(ctx context.Context, repo domain.RepoName) ( | |||
| 353 | 357 | } | |
| 354 | 358 | ||
| 355 | 359 | // DeleteEnvironment implements ports.CISettingsStore. | |
| 356 | - | // | |
| 357 | - | //hexcheck:ignore suspicious-business-logic-in-adapter removes the keys of an environment in one batch: a storage concern, no policy | |
| 358 | 360 | func (s *Settings) DeleteEnvironment(ctx context.Context, repo domain.RepoName, name string) error { | |
| 359 | 361 | key := envKey(repo, name) | |
| 360 | 362 | scope := domain.EnvironmentScope(repo, name) | |
M internal/adapters/kvstore/issues.go +0 -8
| @@ -84,8 +84,6 @@ func issueKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][ | |||
| 84 | 84 | // Create takes the next number of the repository from mrseq/<repo>, the | |
| 85 | 85 | // sequence of the merge requests, and stores the issue under it; see | |
| 86 | 86 | // createNumbered. A missing repository gives domain.ErrNotFound. | |
| 87 | - | // | |
| 88 | - | //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy | |
| 89 | 87 | func (i *Issues) Create(ctx context.Context, issue domain.Issue) (domain.Issue, error) { | |
| 90 | 88 | err := createNumbered(ctx, i.db, i.locks, issue.Repo, func(b *kv.Batch, n int64) error { | |
| 91 | 89 | issue.Number = n | |
| @@ -118,8 +116,6 @@ func (i *Issues) Get(ctx context.Context, repo domain.RepoName, number int64) (d | |||
| 118 | 116 | ||
| 119 | 117 | // List returns a page of the issues of the repository, highest number first. The | |
| 120 | 118 | // page token is the last number of the previous page. | |
| 121 | - | // | |
| 122 | - | //hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy | |
| 123 | 119 | func (i *Issues) List(ctx context.Context, repo domain.RepoName, params ports.ListIssuesParams) (ports.IssuePage, error) { | |
| 124 | 120 | before := int64(0) // 0: from the highest | |
| 125 | 121 | if params.PageToken != "" { | |
| @@ -225,8 +221,6 @@ func (i *Issues) ListOpenAll(ctx context.Context) ([]domain.Issue, error) { | |||
| 225 | 221 | // Update replaces the issue with a version precondition and moves its index key | |
| 226 | 222 | // in the same batch. The repository must exist (see guardRepoKey), so that a | |
| 227 | 223 | // concurrent delete of the repository leaves no index key behind. | |
| 228 | - | // | |
| 229 | - | //hexcheck:ignore suspicious-business-logic-in-adapter port method name only | |
| 230 | 224 | func (i *Issues) Update(ctx context.Context, issue domain.Issue) error { | |
| 231 | 225 | val, err := encodeIssue(issue) | |
| 232 | 226 | if err != nil { | |
| @@ -330,7 +324,6 @@ func encodeIssue(issue domain.Issue) ([]byte, error) { | |||
| 330 | 324 | }), nil | |
| 331 | 325 | } | |
| 332 | 326 | ||
| 333 | - | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |
| 334 | 327 | func decodeIssue(repo domain.RepoName, item kv.Item) (domain.Issue, error) { | |
| 335 | 328 | var pb storagepb.Issue | |
| 336 | 329 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| @@ -373,7 +366,6 @@ func encodeIssueComment(c domain.IssueComment) ([]byte, error) { | |||
| 373 | 366 | }), nil | |
| 374 | 367 | } | |
| 375 | 368 | ||
| 376 | - | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |
| 377 | 369 | func decodeIssueComment(item kv.Item) (domain.IssueComment, error) { | |
| 378 | 370 | var pb storagepb.IssueComment | |
| 379 | 371 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
M internal/adapters/kvstore/issues_pending.go +0 -5
| @@ -94,8 +94,6 @@ func (s *PendingIssues) Get(ctx context.Context, repo domain.RepoName, id domain | |||
| 94 | 94 | // creation time then repository then id. There are at most | |
| 95 | 95 | // domain.MaxPendingIssues of them (a little more under a race), so they are | |
| 96 | 96 | // read and sorted. The page token is the position of the last item returned. | |
| 97 | - | // | |
| 98 | - | //hexcheck:ignore suspicious-business-logic-in-adapter pages through the keys of a prefix: a storage concern, no policy | |
| 99 | 97 | func (s *PendingIssues) List(ctx context.Context, repo domain.RepoName, page ports.ListPendingParams) (ports.PendingIssuePage, error) { | |
| 100 | 98 | prefix := pendingPrefix | |
| 101 | 99 | if !repo.IsZero() { | |
| @@ -216,8 +214,6 @@ func (s *PendingIssues) Count(ctx context.Context) (int, error) { | |||
| 216 | 214 | // Approve numbers the pending issue from mrseq/<repo> (see createNumbered), | |
| 217 | 215 | // writes the open issue and its index key and deletes the pending record, in | |
| 218 | 216 | // one batch that requires the pending record to be unchanged. | |
| 219 | - | // | |
| 220 | - | //hexcheck:ignore suspicious-business-logic-in-adapter only sets the number under a version precondition; the issue itself is built by domain.ApprovedIssue | |
| 221 | 217 | func (s *PendingIssues) Approve(ctx context.Context, repo domain.RepoName, id domain.PendingIssueID, now time.Time) (domain.Issue, error) { | |
| 222 | 218 | var number int64 | |
| 223 | 219 | err := createNumbered(ctx, s.db, s.locks, repo, func(b *kv.Batch, n int64) error { | |
| @@ -291,7 +287,6 @@ func encodePending(p domain.PendingIssue) ([]byte, error) { | |||
| 291 | 287 | }), nil | |
| 292 | 288 | } | |
| 293 | 289 | ||
| 294 | - | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |
| 295 | 290 | func decodePending(repo domain.RepoName, id domain.PendingIssueID, item kv.Item) (domain.PendingIssue, error) { | |
| 296 | 291 | var pb storagepb.PendingIssue | |
| 297 | 292 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
M internal/adapters/kvstore/mergerequests.go +3 -9
| @@ -96,6 +96,8 @@ func indexKey(prefix string, repo domain.RepoName, branch string, n int64) []byt | |||
| 96 | 96 | // when old is not known, as for a new request or a comment: the keys of mr are | |
| 97 | 97 | // then put or deleted, whatever they were). Deleting a key that is not there is | |
| 98 | 98 | // not an error. | |
| 99 | + | // | |
| 100 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maintains source and target indexes to match the stored merge request open state and branch names | |
| 99 | 101 | func indexOpen(b *kv.Batch, old *domain.MergeRequest, mr domain.MergeRequest) { | |
| 100 | 102 | if old != nil && old.IsOpen() { | |
| 101 | 103 | if !mr.IsOpen() || old.Source != mr.Source { | |
| @@ -204,8 +206,6 @@ func (m *MergeRequests) guardRepo(ctx context.Context, b *kv.Batch, repo domain. | |||
| 204 | 206 | // rewrites it (see guardRepo), so that a repository deleted meanwhile is not | |
| 205 | 207 | // left with a stray merge request (a push recording pushed_at makes it retry). A missing repository gives | |
| 206 | 208 | // domain.ErrNotFound. | |
| 207 | - | // | |
| 208 | - | //hexcheck:ignore suspicious-business-logic-in-adapter takes the next number under a version precondition: a storage concern, no policy | |
| 209 | 209 | func (m *MergeRequests) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) { | |
| 210 | 210 | err := createNumbered(ctx, m.db, m.locks, mr.Repo, func(b *kv.Batch, n int64) error { | |
| 211 | 211 | mr.Number = n | |
| @@ -451,7 +451,7 @@ func (m *MergeRequests) CountOpen(ctx context.Context, repo domain.RepoName) (in | |||
| 451 | 451 | ||
| 452 | 452 | // Update replaces the merge request with a version precondition. | |
| 453 | 453 | // | |
| 454 | - | //hexcheck:ignore suspicious-business-logic-in-adapter port method name only | |
| 454 | + | //hexcheck:ignore suspicious-business-logic-in-adapter replaces a versioned record and maintains open-request indexes and the derived mergeability record | |
| 455 | 455 | func (m *MergeRequests) Update(ctx context.Context, mr domain.MergeRequest) error { | |
| 456 | 456 | val, err := encodeMergeRequest(mr) | |
| 457 | 457 | if err != nil { | |
| @@ -564,8 +564,6 @@ func (m *MergeRequests) GetRead(ctx context.Context, repo domain.RepoName, numbe | |||
| 564 | 564 | ||
| 565 | 565 | // SetRead writes mrread/<repo>/<number>. The repository must exist (see | |
| 566 | 566 | // guardRepo): domain.ErrNotFound otherwise. | |
| 567 | - | // | |
| 568 | - | //hexcheck:ignore suspicious-business-logic-in-adapter retries a write that lost a race with the repository key: a storage concern, no policy | |
| 569 | 567 | func (m *MergeRequests) SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error { | |
| 570 | 568 | if head.IsZero() { | |
| 571 | 569 | return fmt.Errorf("%w: empty read position", domain.ErrInvalidObjectID) | |
| @@ -748,7 +746,6 @@ func encodeMergeRequest(mr domain.MergeRequest) ([]byte, error) { | |||
| 748 | 746 | }), nil | |
| 749 | 747 | } | |
| 750 | 748 | ||
| 751 | - | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |
| 752 | 749 | func decodeMergeRequest(repo domain.RepoName, item kv.Item) (domain.MergeRequest, error) { | |
| 753 | 750 | var pb storagepb.MergeRequest | |
| 754 | 751 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| @@ -815,7 +812,6 @@ func encodeComment(c domain.Comment) ([]byte, error) { | |||
| 815 | 812 | }), nil | |
| 816 | 813 | } | |
| 817 | 814 | ||
| 818 | - | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |
| 819 | 815 | func decodeComment(item kv.Item) (domain.Comment, error) { | |
| 820 | 816 | var pb storagepb.Comment | |
| 821 | 817 | if err := proto.Unmarshal(item.Value, &pb); err != nil { | |
| @@ -931,8 +927,6 @@ func encodeSettings(s domain.RepoSettings) (*storagepb.RepoSettings, error) { | |||
| 931 | 927 | ||
| 932 | 928 | // decodeSettings returns the defaults for a repository stored before merge | |
| 933 | 929 | // requests existed, and for settings left with no mode that this build knows. | |
| 934 | - | // | |
| 935 | - | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding with the documented defaults | |
| 936 | 930 | func decodeSettings(pb *storagepb.RepoSettings) domain.RepoSettings { | |
| 937 | 931 | if pb == nil { | |
| 938 | 932 | return domain.DefaultRepoSettings() | |
M internal/adapters/kvstore/passkeys.go +0 -2
| @@ -134,8 +134,6 @@ func (s *Passkeys) Create(ctx context.Context, p domain.Passkey) error { | |||
| 134 | 134 | } | |
| 135 | 135 | ||
| 136 | 136 | // Update replaces a stored passkey, or returns domain.ErrNotFound. | |
| 137 | - | // | |
| 138 | - | //hexcheck:ignore suspicious-business-logic-in-adapter version-checked replace, not a policy | |
| 139 | 137 | func (s *Passkeys) Update(ctx context.Context, p domain.Passkey) error { | |
| 140 | 138 | val, err := encodePasskey(p) | |
| 141 | 139 | if err != nil { | |
M internal/adapters/kvstore/repos.go +2 -10
| @@ -67,8 +67,6 @@ func (s *Store) List(ctx context.Context) ([]domain.Repo, error) { | |||
| 67 | 67 | const repoMutationTries = 8 | |
| 68 | 68 | ||
| 69 | 69 | // Mutate applies a retry-safe mutation under the version it read. | |
| 70 | - | // | |
| 71 | - | //hexcheck:ignore suspicious-business-logic-in-adapter optimistic concurrency is a storage concern | |
| 72 | 70 | func (s *Store) Mutate(ctx context.Context, name domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) { | |
| 73 | 71 | key := repoKey(name) | |
| 74 | 72 | for range repoMutationTries { | |
| @@ -158,8 +156,6 @@ const deleteTries = 32 | |||
| 158 | 156 | // got in after the listing makes the batch fail and the keys are listed again, | |
| 159 | 157 | // and a write after the batch finds no repository: no key outlives the | |
| 160 | 158 | // repository to attach itself to a repository created again. | |
| 161 | - | // | |
| 162 | - | //hexcheck:ignore suspicious-business-logic-in-adapter chunked delete under a version precondition: a storage concern, no policy | |
| 163 | 159 | func (s *Store) Delete(ctx context.Context, name domain.RepoName) error { | |
| 164 | 160 | for range deleteTries { | |
| 165 | 161 | snap := s.db.Snapshot(ctx) | |
| @@ -211,12 +207,8 @@ func encodeRepo(r domain.Repo) ([]byte, error) { | |||
| 211 | 207 | if err != nil { | |
| 212 | 208 | return nil, err | |
| 213 | 209 | } | |
| 214 | - | settings := r.Settings | |
| 215 | - | if settings.IsZero() { | |
| 216 | - | // Settings are always stored explicitly, so that a later change of | |
| 217 | - | // the defaults never alters a stored repository. | |
| 218 | - | settings = domain.DefaultRepoSettings() | |
| 219 | - | } | |
| 210 | + | // Store defaults explicitly so future default changes do not alter stored repositories. | |
| 211 | + | settings := r.EffectiveSettings() | |
| 220 | 212 | pbSettings, err := encodeSettings(settings) | |
| 221 | 213 | if err != nil { | |
| 222 | 214 | return nil, err | |
M internal/adapters/kvstore/store.go +0 -2
| @@ -186,8 +186,6 @@ var preIssueScopes = []domain.Scope{ | |||
| 186 | 186 | // for, so it gets each of them if missing (this is no escalation). Every other | |
| 187 | 187 | // token is left as it is. The batch requires each token to be at the version it | |
| 188 | 188 | // was read at. | |
| 189 | - | // | |
| 190 | - | //hexcheck:ignore suspicious-business-logic-in-adapter one-time storage upgrade of the stored tokens: a storage concern, no policy | |
| 191 | 189 | func (s *Store) upgradeTokens(ctx context.Context, b *kv.Batch) error { | |
| 192 | 190 | for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(tokenPrefix)) { | |
| 193 | 191 | pb := &storagepb.Token{} | |
M internal/adapters/kvstore/tokens.go +0 -6
| @@ -153,8 +153,6 @@ func (s *Tokens) Revoke(ctx context.Context, id string) error { | |||
| 153 | 153 | ||
| 154 | 154 | // Touch sets last_used_at when the given time is later than the stored one, | |
| 155 | 155 | // so that racing writers never move it back. | |
| 156 | - | // | |
| 157 | - | //hexcheck:ignore suspicious-business-logic-in-adapter monotonic write guard, not a policy | |
| 158 | 156 | func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error { | |
| 159 | 157 | if !validTokenID(id) { | |
| 160 | 158 | return domain.ErrNotFound | |
| @@ -194,8 +192,6 @@ func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error { | |||
| 194 | 192 | // encodeToken refuses what the use case already refused (scopes, branch | |
| 195 | 193 | // prefixes): a malformed prefix must never reach the store, where it would | |
| 196 | 194 | // limit a token wrongly or not at all. | |
| 197 | - | // | |
| 198 | - | //hexcheck:ignore suspicious-business-logic-in-adapter schema encoding: it re-checks the syntax the use case enforced, it decides no policy | |
| 199 | 195 | func encodeToken(t domain.Token) ([]byte, error) { | |
| 200 | 196 | if !validTokenID(t.ID) { | |
| 201 | 197 | return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID) | |
| @@ -237,8 +233,6 @@ func encodeToken(t domain.Token) ([]byte, error) { | |||
| 237 | 233 | ||
| 238 | 234 | // decodeToken drops scopes it does not know (written by a newer ermite): a | |
| 239 | 235 | // token never gains rights by being read by an older build. | |
| 240 | - | // | |
| 241 | - | //hexcheck:ignore suspicious-business-logic-in-adapter schema decoding, not a policy | |
| 242 | 236 | func decodeToken(val []byte) (domain.Token, error) { | |
| 243 | 237 | var pb storagepb.Token | |
| 244 | 238 | if err := proto.Unmarshal(val, &pb); err != nil { | |
M internal/adapters/rpc/api.go +2 -4
| @@ -87,9 +87,7 @@ type Config struct { | |||
| 87 | 87 | Secrets ports.SecretService | |
| 88 | 88 | Variables ports.VariableService | |
| 89 | 89 | Environments ports.EnvironmentService | |
| 90 | - | // OIDCKeys rotates the key of the ID tokens of CI jobs; nil when the server | |
| 91 | - | // issues none (GetVersion then does not list "ci.oidc"). Same rule as | |
| 92 | - | // Sessions about typed nil pointers. | |
| 90 | + | // OIDCKeys manages ID-token signing keys, including when OIDC is disabled. | |
| 93 | 91 | OIDCKeys ports.OIDCKeyService | |
| 94 | 92 | // JobArtifacts and Artifacts serve JobArtifactService and ArtifactService, | |
| 95 | 93 | // and GetVersion lists "ci.artifacts", when both are set and Runner is. | |
| @@ -138,7 +136,7 @@ func New(cfg Config) *API { | |||
| 138 | 136 | if artifacts { | |
| 139 | 137 | features = append(features, FeatureCIArtifacts) | |
| 140 | 138 | } | |
| 141 | - | if cfg.OIDCKeys != nil { | |
| 139 | + | if cfg.OIDCKeys != nil && cfg.OIDCKeys.Enabled() { | |
| 142 | 140 | features = append(features, FeatureCIOIDC) | |
| 143 | 141 | } | |
| 144 | 142 | if cfg.TrustedKeys != nil { | |
M internal/adapters/rpc/convert.go +1 -4
| @@ -58,10 +58,7 @@ func visibilityFromPB(v pb.Visibility) (domain.Visibility, error) { | |||
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | func repoToPB(v ports.RepoView) *pb.Repo { | |
| 61 | - | settings := v.Settings | |
| 62 | - | if settings.IsZero() { | |
| 63 | - | settings = domain.DefaultRepoSettings() | |
| 64 | - | } | |
| 61 | + | settings := v.EffectiveSettings() | |
| 65 | 62 | return &pb.Repo{ | |
| 66 | 63 | Name: v.Name.String(), | |
| 67 | 64 | Description: v.Description, | |
M internal/adapters/rpc/git.go +0 -1
| @@ -187,7 +187,6 @@ func (s *gitService) GetCommit(ctx context.Context, req *connect.Request[pb.GetC | |||
| 187 | 187 | return connect.NewResponse(commitDetailToPB(detail)), nil | |
| 188 | 188 | } | |
| 189 | 189 | ||
| 190 | - | //hexcheck:ignore suspicious-business-logic-in-adapter port method name only | |
| 191 | 190 | func (s *gitService) ResolveRevision(ctx context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) { | |
| 192 | 191 | name, rev, err := target(req.Msg.GetRepo(), req.Msg.GetRevision()) | |
| 193 | 192 | if err != nil { | |
M internal/adapters/rpc/mergerequest.go +0 -2
| @@ -212,8 +212,6 @@ func commentID(s string) int64 { | |||
| 212 | 212 | } | |
| 213 | 213 | ||
| 214 | 214 | // headID parses a commit id of a request; the empty string is the zero id. | |
| 215 | - | // | |
| 216 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses a request field into the domain id and names the field in the error; no rule is decided here | |
| 217 | 215 | func headID(s string) (domain.ObjectID, error) { | |
| 218 | 216 | if s == "" { | |
| 219 | 217 | return domain.ObjectID{}, nil | |
M internal/adapters/rpc/repo.go +0 -2
| @@ -83,8 +83,6 @@ func (s *repoService) UpdateRepo(ctx context.Context, req *connect.Request[pb.Up | |||
| 83 | 83 | // "settings.allowed_merge_modes" and "settings.failing_checks_block", and | |
| 84 | 84 | // nothing else; the optional issues field stands outside the mask, and alone | |
| 85 | 85 | // it is a valid update. | |
| 86 | - | // | |
| 87 | - | //hexcheck:ignore suspicious-business-logic-in-adapter maps the field mask of the contract to the optional fields of the use case; no rule is decided here | |
| 88 | 86 | func updateParams(msg *pb.UpdateRepoRequest) (ports.UpdateRepoParams, error) { | |
| 89 | 87 | var params ports.UpdateRepoParams | |
| 90 | 88 | paths := msg.GetUpdateMask().GetPaths() | |
M internal/adapters/rpc/server.go +5 -8
| @@ -10,7 +10,6 @@ import ( | |||
| 10 | 10 | "connectrpc.com/connect" | |
| 11 | 11 | ||
| 12 | 12 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 13 | - | "bnema.dev/ermite/internal/domain" | |
| 14 | 13 | "bnema.dev/ermite/internal/ports" | |
| 15 | 14 | ) | |
| 16 | 15 | ||
| @@ -19,7 +18,7 @@ type serverService struct { | |||
| 19 | 18 | version string | |
| 20 | 19 | features []string | |
| 21 | 20 | key ports.SigningKeyService | |
| 22 | - | // oidc rotates the key of the ID tokens; nil when the server issues none. | |
| 21 | + | // oidc manages key rotation, including the disabled-provider response. | |
| 23 | 22 | oidc ports.OIDCKeyService | |
| 24 | 23 | } | |
| 25 | 24 | ||
| @@ -41,15 +40,13 @@ func (s *serverService) GetSigningKey(context.Context, *connect.Request[pb.GetSi | |||
| 41 | 40 | } | |
| 42 | 41 | ||
| 43 | 42 | // RotateOIDCKey replaces the key that signs the ID tokens of CI jobs. Only the | |
| 44 | - | // owner may; failed_precondition when the server issues no ID tokens. | |
| 43 | + | // owner may; failed_precondition when the server issues no ID tokens. An API | |
| 44 | + | // built without an OIDC key service answers unimplemented. | |
| 45 | 45 | func (s *serverService) RotateOIDCKey(ctx context.Context, _ *connect.Request[pb.RotateOIDCKeyRequest]) (*connect.Response[pb.RotateOIDCKeyResponse], error) { | |
| 46 | - | p := caller(ctx) | |
| 47 | 46 | if s.oidc == nil { | |
| 48 | - | if !p.HasOwnerRights() { | |
| 49 | - | return nil, connectError(ctx, domain.ErrForbidden) | |
| 50 | - | } | |
| 51 | - | return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY")) | |
| 47 | + | return nil, connect.NewError(connect.CodeUnimplemented, errors.New("this API serves no OIDC key service")) | |
| 52 | 48 | } | |
| 49 | + | p := caller(ctx) | |
| 53 | 50 | r, err := s.oidc.RotateKey(ctx, p) | |
| 54 | 51 | if err != nil { | |
| 55 | 52 | return nil, connectError(ctx, err) | |
M internal/adapters/rpc/server_test.go +11 -0
| @@ -49,6 +49,17 @@ func TestServerServiceGetSigningKey(t *testing.T) { | |||
| 49 | 49 | assert.Equal(t, "ermite@example.com ssh-ed25519 AAAA", resp.Msg.GetAllowedSigners()) | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | + | // An API built without an OIDC key service answers unimplemented instead of | |
| 53 | + | // panicking, for the owner and for anyone else. | |
| 54 | + | func TestRotateOIDCKeyWithoutService(t *testing.T) { | |
| 55 | + | t.Parallel() | |
| 56 | + | for _, p := range []domain.Principal{domain.Owner(), {}} { | |
| 57 | + | ctx := domain.ContextWithPrincipal(context.Background(), p) | |
| 58 | + | _, err := (&serverService{}).RotateOIDCKey(ctx, connect.NewRequest(&pb.RotateOIDCKeyRequest{})) | |
| 59 | + | assert.Equal(t, connect.CodeUnimplemented, connect.CodeOf(err)) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 52 | 63 | // GetVersion lists "passkeys" only when the API is built with passkeys. | |
| 53 | 64 | func TestAPIListsPasskeysFeatureWhenAvailable(t *testing.T) { | |
| 54 | 65 | t.Parallel() | |
M internal/adapters/sigverify/gpg.go +0 -2
| @@ -41,8 +41,6 @@ var _ ports.SignatureVerifier = GPG{} | |||
| 41 | 41 | ||
| 42 | 42 | // Verify implements ports.SignatureVerifier. signedAt is ignored: see the time | |
| 43 | 43 | // policy of GPG. | |
| 44 | - | // | |
| 45 | - | //hexcheck:ignore suspicious-business-logic-in-adapter this is the OpenPGP protocol check the port delegates to the adapter; the trust and email rules stay in domain | |
| 46 | 44 | func (GPG) Verify(payload, signature []byte, committerEmail string, _ int64, keys []domain.TrustedKey) (v domain.CommitVerification) { | |
| 47 | 45 | v = domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusBadSignature} | |
| 48 | 46 | defer func() { // a parser bug must not take the server down | |
M internal/adapters/sigverify/ssh.go +0 -2
| @@ -49,8 +49,6 @@ type sshSigBlob struct { | |||
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | 51 | // Verify implements ports.SignatureVerifier. | |
| 52 | - | // | |
| 53 | - | //hexcheck:ignore suspicious-business-logic-in-adapter this is the SSHSIG protocol check the port delegates to the adapter; the trust and email rules stay in domain | |
| 54 | 52 | func (SSH) Verify(payload, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) (v domain.CommitVerification) { | |
| 55 | 53 | v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature} | |
| 56 | 54 | defer func() { // a parser bug must not take the server down | |
M internal/adapters/sigverify/verifier.go +0 -2
| @@ -42,8 +42,6 @@ func (Verifier) Verify(payload, signature []byte, committerEmail string, signedA | |||
| 42 | 42 | // trusted completes the verification of a signature whose cryptography is | |
| 43 | 43 | // valid and whose key is trusted: verified when the key may sign for the | |
| 44 | 44 | // committer email, email_mismatch otherwise. | |
| 45 | - | // | |
| 46 | - | //hexcheck:ignore suspicious-business-logic-in-adapter the email rule is domain.TrustedKey.TrustsEmail; this only maps it to the port's status | |
| 47 | 45 | func trusted(v domain.CommitVerification, key domain.TrustedKey, committerEmail string) domain.CommitVerification { | |
| 48 | 46 | v.KeyID, v.KeyTitle = key.ID, key.Title | |
| 49 | 47 | if key.TrustsEmail(committerEmail) { | |
M internal/adapters/web/actions_artifacts.go +0 -4
| @@ -53,8 +53,6 @@ type ArtifactsView struct { | |||
| 53 | 53 | // runArtifacts is the artifacts section of a run, nil when there is nothing to | |
| 54 | 54 | // show: no artifacts on this server, a visitor who cannot list them, no | |
| 55 | 55 | // artifact, or a failed read, which is logged; the run page never fails for it. | |
| 56 | - | // | |
| 57 | - | //hexcheck:ignore suspicious-business-logic-in-adapter builds the artifacts section from the use case results; which errors hide it is presentation, no rule is decided here | |
| 58 | 56 | func (s *Server) runArtifacts(ctx context.Context, repo domain.RepoName, p RunPage) *ArtifactsView { | |
| 59 | 57 | if s.cfg.Artifacts == nil || !SignedIn(ctx) { | |
| 60 | 58 | return nil | |
| @@ -112,8 +110,6 @@ func parseArtifactPath(r *http.Request) (int64, error) { | |||
| 112 | 110 | // is never served: a zip is application/zip, anything else | |
| 113 | 111 | // application/octet-stream, and the answer is sandboxed and not sniffed, so | |
| 114 | 112 | // that nothing uploaded by a job can run in the origin of the forge. | |
| 115 | - | // | |
| 116 | - | //hexcheck:ignore suspicious-business-logic-in-adapter streams what the use case sends; the headers are about the response, no rule is decided here | |
| 117 | 113 | func (s *Server) artifactDownload(w http.ResponseWriter, r *http.Request) { | |
| 118 | 114 | ctx, cancel := context.WithTimeout(r.Context(), artifactTimeout) | |
| 119 | 115 | defer cancel() | |
M internal/adapters/web/browse.go +0 -2
| @@ -71,8 +71,6 @@ func (s *Server) openBlobAt(ctx context.Context, repo domain.RepoName, rev, path | |||
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | 73 | // resolveRevision resolves rev to the commit it designates. | |
| 74 | - | // | |
| 75 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses the URL text and calls the use case; no rule is decided here | |
| 76 | 74 | func (s *Server) resolveRevision(ctx context.Context, repo domain.RepoName, rev string) (domain.ResolvedRevision, error) { | |
| 77 | 75 | r, err := domain.ParseRevision(rev) | |
| 78 | 76 | if err != nil { | |
M internal/adapters/web/checks.go +2 -0
| @@ -54,6 +54,8 @@ func (s *Server) checksFor(ctx context.Context, repo domain.RepoName, sha string | |||
| 54 | 54 | } | |
| 55 | 55 | ||
| 56 | 56 | // checksSummary reduces checks to the mark a list shows next to a commit. | |
| 57 | + | // | |
| 58 | + | //hexcheck:ignore suspicious-business-logic-in-adapter renders domain check outcomes as a commit-list mark, passed count, label and actions link | |
| 57 | 59 | func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) ChecksSummary { | |
| 58 | 60 | if len(checks) == 0 { | |
| 59 | 61 | return ChecksSummary{} | |
M internal/adapters/web/commit.go +0 -2
| @@ -96,8 +96,6 @@ func diffLines(files []DiffFile) (lines int, invisible bool) { | |||
| 96 | 96 | ||
| 97 | 97 | // commit shows a commit: its message, its totals, the changed files and the | |
| 98 | 98 | // diff against the first parent. ?view=split selects the split layout. | |
| 99 | - | // | |
| 100 | - | //hexcheck:ignore suspicious-business-logic-in-adapter builds the commit page from the use case results; no rule is decided here | |
| 101 | 99 | func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 102 | 100 | sha := r.PathValue("sha") | |
| 103 | 101 | if !isCommitID(sha) { | |
M internal/adapters/web/compare.go +3 -55
| @@ -5,7 +5,6 @@ package web | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "net/http" | |
| 8 | - | "slices" | |
| 9 | 8 | ||
| 10 | 9 | "github.com/a-h/templ" | |
| 11 | 10 | ||
| @@ -13,10 +12,6 @@ import ( | |||
| 13 | 12 | "bnema.dev/ermite/internal/ports" | |
| 14 | 13 | ) | |
| 15 | 14 | ||
| 16 | - | // compareDepth bounds the commits read on each side to find where a branch | |
| 17 | - | // left its target. | |
| 18 | - | const compareDepth = maxComparePages * pageSize | |
| 19 | - | ||
| 20 | 15 | // compare shows a branch against the default branch of the repository: the | |
| 21 | 16 | // commits it is ahead by and, for the owner, the form that opens a merge | |
| 22 | 17 | // request. | |
| @@ -27,8 +22,6 @@ const compareDepth = maxComparePages * pageSize | |||
| 27 | 22 | // own diff is then the whole change). Otherwise it lists the commits, which | |
| 28 | 23 | // link to their own diffs, and says the whole diff comes with the merge | |
| 29 | 24 | // request. | |
| 30 | - | // | |
| 31 | - | //hexcheck:ignore suspicious-business-logic-in-adapter builds the compare page from the use case results; the merge rules are the use case's | |
| 32 | 25 | func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 33 | 26 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 34 | 27 | if err != nil { | |
| @@ -56,7 +49,9 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||
| 56 | 49 | p.Note = "this is the default branch: there is nothing to compare it with." | |
| 57 | 50 | p.AheadText = "0 commits" | |
| 58 | 51 | } else { | |
| 59 | - | ahead, exact, behind, err = s.commitsAhead(ctx, d.repo.Name, src.CommitID.String(), tgt.CommitID.String()) | |
| 52 | + | var comparison ports.CompareResult | |
| 53 | + | comparison, err = s.cfg.MergeRequests.Compare(ctx, principal(ctx), d.repo.Name, tgt.CommitID, src.CommitID) | |
| 54 | + | ahead, exact, behind = comparison.Ahead, comparison.Exact, comparison.Behind | |
| 60 | 55 | if err != nil { | |
| 61 | 56 | return nil, 0, err | |
| 62 | 57 | } | |
| @@ -139,53 +134,6 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, | |||
| 139 | 134 | return Compare(l, p), http.StatusOK, nil | |
| 140 | 135 | } | |
| 141 | 136 | ||
| 142 | - | // commitsAhead lists the commits of the source that the target lacks, newest | |
| 143 | - | // first. exact is false when the walk gave up before finding where the source | |
| 144 | - | // meets the target. behind is true when the target has commits the source | |
| 145 | - | // lacks (the tip of the target is not an ancestor of the source). | |
| 146 | - | // | |
| 147 | - | //hexcheck:ignore suspicious-business-logic-in-adapter walks the history pages the use case returns to find where the branch left its target; the page needs it, the forge decides nothing | |
| 148 | - | func (s *Server) commitsAhead(ctx context.Context, repo domain.RepoName, srcTip, tgtTip string) (ahead []domain.Commit, exact, behind bool, err error) { | |
| 149 | - | if srcTip == tgtTip { | |
| 150 | - | return nil, true, false, nil | |
| 151 | - | } | |
| 152 | - | onTarget := map[string]bool{} | |
| 153 | - | tokenT := "" | |
| 154 | - | for len(onTarget) < compareDepth { | |
| 155 | - | res, err := s.commitList(ctx, repo, tgtTip, "", pageSize, tokenT) | |
| 156 | - | if err != nil { | |
| 157 | - | return nil, false, false, err | |
| 158 | - | } | |
| 159 | - | for _, c := range res.Commits { | |
| 160 | - | onTarget[c.ID.String()] = true | |
| 161 | - | } | |
| 162 | - | if tokenT = res.NextPageToken; tokenT == "" { | |
| 163 | - | break | |
| 164 | - | } | |
| 165 | - | } | |
| 166 | - | token := "" | |
| 167 | - | for n := 0; n < compareDepth; { | |
| 168 | - | res, err := s.commitList(ctx, repo, srcTip, "", pageSize, token) | |
| 169 | - | if err != nil { | |
| 170 | - | return nil, false, false, err | |
| 171 | - | } | |
| 172 | - | for _, c := range res.Commits { | |
| 173 | - | n++ | |
| 174 | - | if onTarget[c.ID.String()] { | |
| 175 | - | // The first commit of the source that the target has: where the | |
| 176 | - | // branch left it. The target moved on when this is not its tip. | |
| 177 | - | return ahead, true, c.ID.String() != tgtTip, nil | |
| 178 | - | } | |
| 179 | - | ahead = append(ahead, c) | |
| 180 | - | } | |
| 181 | - | if token = res.NextPageToken; token == "" { | |
| 182 | - | // The whole history of the source is ahead: no common history. | |
| 183 | - | return ahead, true, true, nil | |
| 184 | - | } | |
| 185 | - | } | |
| 186 | - | return ahead, false, !slices.ContainsFunc(ahead, func(c domain.Commit) bool { return c.ID.String() == tgtTip }), nil | |
| 187 | - | } | |
| 188 | - | ||
| 189 | 137 | // openRequestFor finds the open merge request with this source and target. | |
| 190 | 138 | func (s *Server) openRequestFor(ctx context.Context, repo domain.RepoName, source, target string) (*domain.MergeRequest, error) { | |
| 191 | 139 | token := "" | |
M internal/adapters/web/context.go +1 -1
| @@ -30,7 +30,7 @@ type ContextRows struct { | |||
| 30 | 30 | // the numbers of the first hidden line on each side, which move together (the | |
| 31 | 31 | // lines are unchanged); n the number of lines, 0 for the rest of the file. | |
| 32 | 32 | // | |
| 33 | - | //hexcheck:ignore suspicious-business-logic-in-adapter cuts a file the use case opened into rows of the diff; no rule is decided here | |
| 33 | + | //hexcheck:ignore suspicious-business-logic-in-adapter validates diff-context HTTP parameters, rejects binary or oversized files, and caps rendered context rows | |
| 34 | 34 | func (s *Server) diffContext(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 35 | 35 | sha := r.PathValue("sha") | |
| 36 | 36 | if !isFullID(sha) { | |
M internal/adapters/web/diff_highlight.go +0 -2
| @@ -370,8 +370,6 @@ func computeFile(ctx context.Context, lx chroma.Lexer, hunks []domain.Hunk) (d * | |||
| 370 | 370 | ||
| 371 | 371 | // applyFile puts the colours on the lines of a file; false when the data is | |
| 372 | 372 | // not the one of this file (another number of lines), nothing is changed. | |
| 373 | - | // | |
| 374 | - | //hexcheck:ignore suspicious-business-logic-in-adapter puts coloured runs on the lines of a diff for the page; no rule is decided here | |
| 375 | 373 | func applyFile(d *hlData, f *DiffFile, hunks []domain.Hunk) bool { | |
| 376 | 374 | n := 0 | |
| 377 | 375 | for _, h := range hunks { | |
M internal/adapters/web/events.go +3 -1
| @@ -342,6 +342,8 @@ func (s *Server) canSee(ctx context.Context, rawName string) error { | |||
| 342 | 342 | ||
| 343 | 343 | // issuesOn is domain.ErrNotFound unless the repository has issues on and | |
| 344 | 344 | // s.cfg.Issues is set: the same answer as for a repository the visitor cannot see. | |
| 345 | + | // | |
| 346 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps domain RepoSettings.IssuesEnabled to a not-found stream; decides nothing itself | |
| 345 | 347 | func (s *Server) issuesOn(ctx context.Context, rawName string) error { | |
| 346 | 348 | if s.cfg.Issues == nil { | |
| 347 | 349 | return domain.ErrNotFound | |
| @@ -354,7 +356,7 @@ func (s *Server) issuesOn(ctx context.Context, rawName string) error { | |||
| 354 | 356 | if err != nil { | |
| 355 | 357 | return err | |
| 356 | 358 | } | |
| 357 | - | if v.Settings.Issues.OrDefault() == domain.IssuesOff { | |
| 359 | + | if !v.EffectiveSettings().IssuesEnabled() { | |
| 358 | 360 | return domain.ErrNotFound | |
| 359 | 361 | } | |
| 360 | 362 | return nil | |
M internal/adapters/web/events_test.go +13 -0
| @@ -465,3 +465,16 @@ func TestEventsIssuesViewsAreDeniedLikeAMissingRepo(t *testing.T) { | |||
| 465 | 465 | resp, _ := open(t, ts, "topic=repo:proj&view=issues", nil) | |
| 466 | 466 | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 467 | 467 | } | |
| 468 | + | ||
| 469 | + | // Without the merge request service the commits page still gets a banner when | |
| 470 | + | // its branch moves; it only cannot count the new commits. | |
| 471 | + | func TestCommitsBannerWithoutMergeRequests(t *testing.T) { | |
| 472 | + | t.Parallel() | |
| 473 | + | srv := newTestServer(t, Config{Repos: mocks.NewRepoService(t), Browse: mocks.NewBrowseService(t)}) | |
| 474 | + | old, tip := strings.Repeat("a", 40), strings.Repeat("b", 40) | |
| 475 | + | ls := &liveStream{s: srv, repo: "x", view: "commits", ref: "main", head: old} | |
| 476 | + | got, err := ls.banner(t.Context(), domain.PushEvent{Updates: []domain.RefUpdate{{Ref: "refs/heads/main", Old: old, New: tip}}}) | |
| 477 | + | require.NoError(t, err) | |
| 478 | + | require.Len(t, got, 1) | |
| 479 | + | assert.Contains(t, got[0].html, "the branch moved — reload") | |
| 480 | + | } | |
M internal/adapters/web/fragments.go +43 -32
| @@ -356,7 +356,7 @@ func (f *fanout) lead(key fanKey, e *fanEntry, build func() ([]fragment, error)) | |||
| 356 | 356 | // banner tells the commits page how many commits its branch got since the | |
| 357 | 357 | // head it lists. | |
| 358 | 358 | // | |
| 359 | - | //hexcheck:ignore suspicious-business-logic-in-adapter counts the commits of a page for the banner text; display only | |
| 359 | + | //hexcheck:ignore suspicious-business-logic-in-adapter picks the update of the listed branch from the push event; the count and rewrite come from MergeRequests.PushSummary | |
| 360 | 360 | func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) { | |
| 361 | 361 | if l.ref == "" || !isFullID(l.head) { | |
| 362 | 362 | return nil, nil | |
| @@ -376,37 +376,9 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment | |||
| 376 | 376 | case tip == "": | |
| 377 | 377 | return gap, nil // the branch was deleted | |
| 378 | 378 | } | |
| 379 | - | text := "" | |
| 380 | - | if tip != l.head { | |
| 381 | - | name, err := domain.ParseRepoName(l.repo) | |
| 382 | - | if err != nil { | |
| 383 | - | return nil, err | |
| 384 | - | } | |
| 385 | - | res, err := l.s.commitList(ctx, name, tip, "", pageSize, "") | |
| 386 | - | if err != nil { | |
| 387 | - | return nil, err | |
| 388 | - | } | |
| 389 | - | // The count is approximate for a history with merges: the log is | |
| 390 | - | // ordered by date, not as "head..tip", so commits that are not new may | |
| 391 | - | // be counted before the head is met. And one page of the log being | |
| 392 | - | // all that is read, "100+" cannot be told from a rewritten branch | |
| 393 | - | // whose old head is further back: the use cases have no "is ancestor". | |
| 394 | - | n, found := 0, false | |
| 395 | - | for _, c := range res.Commits { | |
| 396 | - | if c.ID.String() == l.head { | |
| 397 | - | found = true | |
| 398 | - | break | |
| 399 | - | } | |
| 400 | - | n++ | |
| 401 | - | } | |
| 402 | - | switch { | |
| 403 | - | case found: | |
| 404 | - | text = newCommits(strconv.Itoa(n), n) | |
| 405 | - | case res.NextPageToken != "": | |
| 406 | - | text = newCommits(strconv.Itoa(n)+"+", 2) | |
| 407 | - | default: | |
| 408 | - | text = "the branch was rewritten — reload" | |
| 409 | - | } | |
| 379 | + | text, err := l.bannerText(ctx, tip) | |
| 380 | + | if err != nil { | |
| 381 | + | return nil, err | |
| 410 | 382 | } | |
| 411 | 383 | c := LiveSlot("commits-head") | |
| 412 | 384 | if text != "" { | |
| @@ -416,6 +388,45 @@ func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment | |||
| 416 | 388 | return []fragment{f}, err | |
| 417 | 389 | } | |
| 418 | 390 | ||
| 391 | + | func (l *liveStream) bannerText(ctx context.Context, tip string) (string, error) { | |
| 392 | + | if tip == l.head { | |
| 393 | + | return "", nil | |
| 394 | + | } | |
| 395 | + | if l.s.cfg.MergeRequests == nil { | |
| 396 | + | // Without the merge request service there is no history summary: the | |
| 397 | + | // banner only says the branch moved. | |
| 398 | + | return "the branch moved — reload", nil | |
| 399 | + | } | |
| 400 | + | name, err := domain.ParseRepoName(l.repo) | |
| 401 | + | if err != nil { | |
| 402 | + | return "", err | |
| 403 | + | } | |
| 404 | + | oldHead, err := domain.ParseObjectID(l.head) | |
| 405 | + | if err != nil { | |
| 406 | + | return "", err | |
| 407 | + | } | |
| 408 | + | newTip, err := domain.ParseObjectID(tip) | |
| 409 | + | if err != nil { | |
| 410 | + | return "", err | |
| 411 | + | } | |
| 412 | + | res, err := l.s.cfg.MergeRequests.PushSummary(ctx, principal(ctx), name, oldHead, newTip) | |
| 413 | + | if err != nil { | |
| 414 | + | return "", err | |
| 415 | + | } | |
| 416 | + | return pushSummaryText(res), nil | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | func pushSummaryText(res ports.PushSummary) string { | |
| 420 | + | switch { | |
| 421 | + | case res.Rewritten: | |
| 422 | + | return "the branch was rewritten — reload" | |
| 423 | + | case res.More: | |
| 424 | + | return newCommits(strconv.Itoa(res.Count)+"+", 2) | |
| 425 | + | default: | |
| 426 | + | return newCommits(strconv.Itoa(res.Count), res.Count) | |
| 427 | + | } | |
| 428 | + | } | |
| 429 | + | ||
| 419 | 430 | // newCommits is the text of the banner: "1 new commit — reload". | |
| 420 | 431 | func newCommits(count string, n int) string { | |
| 421 | 432 | if n == 1 { | |
M internal/adapters/web/issues.go +3 -5
| @@ -97,7 +97,7 @@ func (s *Server) issueRepo(ctx context.Context, rawName string) (*repoData, erro | |||
| 97 | 97 | if err != nil { | |
| 98 | 98 | return nil, err | |
| 99 | 99 | } | |
| 100 | - | if d.repo.Settings.Issues.OrDefault() == domain.IssuesOff { | |
| 100 | + | if !d.repo.Settings.IssuesEnabled() { | |
| 101 | 101 | return nil, errNotFound | |
| 102 | 102 | } | |
| 103 | 103 | return d, nil | |
| @@ -129,7 +129,7 @@ func (s *Server) issueRow(repo string, i domain.Issue) IssueRow { | |||
| 129 | 129 | ||
| 130 | 130 | // issueList lists the issues of one state, a page at a time. | |
| 131 | 131 | // | |
| 132 | - | //hexcheck:ignore suspicious-business-logic-in-adapter builds the list page from the use case results; whether the New issue button shows only reads the mode | |
| 132 | + | //hexcheck:ignore suspicious-business-logic-in-adapter renders issue-list navigation; signed-in users see New issue, while anonymous users need a configured form and the domain AcceptsAnonymousIssues predicate | |
| 133 | 133 | func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 134 | 134 | d, err := s.issueRepo(ctx, r.PathValue("repo")) | |
| 135 | 135 | if err != nil { | |
| @@ -179,7 +179,7 @@ func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Componen | |||
| 179 | 179 | if token != "" { | |
| 180 | 180 | p.Newest = link("") | |
| 181 | 181 | } | |
| 182 | - | if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.Issues.OrDefault() == domain.IssuesPublic) { | |
| 182 | + | if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.AcceptsAnonymousIssues()) { | |
| 183 | 183 | p.NewHref = issuesBase(d.name()) + "/new" | |
| 184 | 184 | } | |
| 185 | 185 | ||
| @@ -216,8 +216,6 @@ func (s *Server) issueNew(ctx context.Context, r *http.Request) (templ.Component | |||
| 216 | 216 | } | |
| 217 | 217 | ||
| 218 | 218 | // issueShow shows an issue with its comments. | |
| 219 | - | // | |
| 220 | - | //hexcheck:ignore suspicious-business-logic-in-adapter builds the issue page from the use case results; no rule is decided here | |
| 221 | 219 | func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 222 | 220 | n, err := mrNumber(r) | |
| 223 | 221 | if err != nil { | |
M internal/adapters/web/issues_form.go +3 -1
| @@ -245,6 +245,8 @@ func (s *Server) formAgain(ctx context.Context, w http.ResponseWriter, r *http.R | |||
| 245 | 245 | } | |
| 246 | 246 | ||
| 247 | 247 | // issueSubmittedPage is GET /<repo>/issues/submitted. | |
| 248 | + | // | |
| 249 | + | //hexcheck:ignore suspicious-business-logic-in-adapter maps domain RepoSettings.ShowsIssueSubmissionReceipt to a 404; decides nothing itself | |
| 248 | 250 | func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) { | |
| 249 | 251 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 250 | 252 | defer cancel() | |
| @@ -253,7 +255,7 @@ func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) { | |||
| 253 | 255 | s.fail(ctx, w, r, err) | |
| 254 | 256 | return | |
| 255 | 257 | } | |
| 256 | - | if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic { | |
| 258 | + | if !d.repo.Settings.ShowsIssueSubmissionReceipt() { | |
| 257 | 259 | s.fail(ctx, w, r, errNotFound) | |
| 258 | 260 | return | |
| 259 | 261 | } | |
M internal/adapters/web/issues_post.go +0 -1
| @@ -67,7 +67,6 @@ func (s *Server) issueReopen(ctx context.Context, r *http.Request) (string, stri | |||
| 67 | 67 | return s.issueTransition(ctx, r, s.cfg.Issues.Reopen) | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | - | //hexcheck:ignore suspicious-business-logic-in-adapter closes or reopens through the use case that owns the transition; the form only picks which | |
| 71 | 70 | func (s *Server) issueTransition(ctx context.Context, r *http.Request, do func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)) (string, string, error) { | |
| 72 | 71 | repo := r.PathValue("repo") | |
| 73 | 72 | n, err := number(r) | |
M internal/adapters/web/mr.go +96 -88
| @@ -62,9 +62,6 @@ const ( | |||
| 62 | 62 | // maxOpenPages bounds the pages read for the open merge requests, which | |
| 63 | 63 | // the first list page shows in full. | |
| 64 | 64 | maxOpenPages = 5 | |
| 65 | - | // maxComparePages bounds how many pages of history the compare page reads | |
| 66 | - | // to find where the branch left the target. | |
| 67 | - | maxComparePages = 5 | |
| 68 | 65 | // railBranches is how many branches the list offers to compare. | |
| 69 | 66 | railBranches = 15 | |
| 70 | 67 | ) | |
| @@ -546,16 +543,14 @@ func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component, | |||
| 546 | 543 | return s.mrPage(ctx, r, true) | |
| 547 | 544 | } | |
| 548 | 545 | ||
| 549 | - | //hexcheck:ignore suspicious-business-logic-in-adapter builds the merge request page from the use case results; no rule is decided here | |
| 550 | 546 | func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) { | |
| 547 | + | if since && !SignedIn(ctx) { | |
| 548 | + | return nil, 0, errNotFound | |
| 549 | + | } | |
| 551 | 550 | n, err := mrNumber(r) | |
| 552 | 551 | if err != nil { | |
| 553 | 552 | return nil, 0, err | |
| 554 | 553 | } | |
| 555 | - | if since && !SignedIn(ctx) { | |
| 556 | - | // The read position is the owner's: there is nothing to show others. | |
| 557 | - | return nil, 0, errNotFound | |
| 558 | - | } | |
| 559 | 554 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 560 | 555 | if err != nil { | |
| 561 | 556 | return nil, 0, err | |
| @@ -574,7 +569,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 574 | 569 | return nil, 0, errNotFound | |
| 575 | 570 | } | |
| 576 | 571 | m := res.MergeRequest | |
| 577 | - | now := s.cfg.Now() | |
| 578 | 572 | signed := SignedIn(ctx) | |
| 579 | 573 | open := m.State == domain.MRStateOpen | |
| 580 | 574 | head := m.HeadID.String() | |
| @@ -595,13 +589,93 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 595 | 589 | Files: s.hl.files(ctx, res.Diff.Files), | |
| 596 | 590 | } | |
| 597 | 591 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", head)) | |
| 592 | + | if err := s.mrFacts(ctx, r, d, &p, res); err != nil { | |
| 593 | + | return nil, 0, err | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | |
| 597 | + | countNotes(p.Files, res.Comments) | |
| 598 | + | if !res.DiffBaseID.IsZero() { | |
| 599 | + | lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)) | |
| 600 | + | } | |
| 601 | + | p.From = from | |
| 602 | + | ||
| 603 | + | s.mrReadPresentation(&p, d.name(), n, res, since, signed) | |
| 604 | + | ||
| 605 | + | // Comments: under their line, else in the list below the diff. The forms | |
| 606 | + | // are the owner's, on the whole diff of an open request. | |
| 607 | + | canWrite := signed && open && !since | |
| 608 | + | var on *lineRef | |
| 609 | + | if canWrite { | |
| 610 | + | on = parseOn(r.URL.Query().Get("on")) | |
| 611 | + | } | |
| 612 | + | s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on) | |
| 613 | + | lines, invisible := diffLines(p.Files) | |
| 614 | + | p.Invisible = invisible | |
| 615 | + | p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | |
| 616 | + | if signed && open { | |
| 617 | + | p.Box = s.mrBox(ctx, d, res) | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | if mergeErrorOf(ctx) != "" { | |
| 621 | + | p.Canonical = self | |
| 622 | + | } | |
| 623 | + | if p.Box == nil { | |
| 624 | + | p.MergeError = mergeErrorOf(ctx) // the request is not open any more | |
| 625 | + | } | |
| 626 | + | if canWrite { | |
| 627 | + | p.GeneralForm = &CommentForm{ | |
| 628 | + | ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request", | |
| 629 | + | } | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | l := s.repoLayout(ctx, d, "merge-requests") | |
| 633 | + | l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance | |
| 634 | + | if head != "" { | |
| 635 | + | pinRef(&l, target{rev: head, name: m.Source}) | |
| 636 | + | if !d.hasBranch(m.Source) { | |
| 637 | + | pinRef(&l, target{rev: head, name: short(head), detached: true}) | |
| 638 | + | } | |
| 639 | + | } | |
| 640 | + | l.Crumbs[0].Current = false | |
| 641 | + | l.Crumbs = append(l.Crumbs, | |
| 642 | + | Crumb{Label: "merge requests", Href: mrBase(d.name())}, | |
| 643 | + | Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since}, | |
| 644 | + | ) | |
| 645 | + | if since { | |
| 646 | + | l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true}) | |
| 647 | + | } | |
| 648 | + | p.Live = true | |
| 649 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target)) | |
| 650 | + | l.HasRail = true | |
| 651 | + | l.MR = true | |
| 652 | + | return MergeRequest(l, p), http.StatusOK, nil | |
| 653 | + | } | |
| 654 | + | ||
| 655 | + | func (s *Server) mrBox(ctx context.Context, d *repoData, res ports.MergeRequestDetail) *MergeBox { | |
| 656 | + | m := res.MergeRequest | |
| 657 | + | box := s.mergeBox(d, m.Number, m.Source, m.HeadID.String(), res.HeadUnread, mergeabilityView(res.Mergeability, m.Target)) | |
| 658 | + | box.Error = mergeErrorOf(ctx) | |
| 659 | + | if m.IsDraft() { | |
| 660 | + | box.ReadyAction = mrHref(d.name(), m.Number, "ready") | |
| 661 | + | } | |
| 662 | + | return box | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | // mrFacts formats the description, metadata, branch links and checks. | |
| 666 | + | func (s *Server) mrFacts(ctx context.Context, r *http.Request, d *repoData, p *MRPage, res ports.MergeRequestDetail) error { | |
| 667 | + | m := res.MergeRequest | |
| 668 | + | head := m.HeadID.String() | |
| 669 | + | signed := SignedIn(ctx) | |
| 670 | + | open := m.State == domain.MRStateOpen | |
| 671 | + | now := s.cfg.Now() | |
| 598 | 672 | if desc := m.Description.String(); strings.TrimSpace(desc) != "" { | |
| 599 | 673 | // Relative links point into the source branch as it is now (its head | |
| 600 | 674 | // commit); the heading ids carry their own prefix, apart from the | |
| 601 | 675 | // ids of the diff. | |
| 602 | 676 | h, err := s.md.renderNote([]byte(desc), d.name(), head, "mr-desc-", noteIDs(res.Comments)) | |
| 603 | 677 | if err != nil { | |
| 604 | - | return nil, 0, err | |
| 678 | + | return err | |
| 605 | 679 | } | |
| 606 | 680 | p.Desc = h | |
| 607 | 681 | } | |
| @@ -644,45 +718,30 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 644 | 718 | p.Checks.Note = "" | |
| 645 | 719 | } | |
| 646 | 720 | } | |
| 647 | - | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | |
| 648 | - | countNotes(p.Files, res.Comments) | |
| 649 | - | if !res.DiffBaseID.IsZero() { | |
| 650 | - | lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)) | |
| 651 | - | } | |
| 652 | - | p.From = from | |
| 721 | + | return nil | |
| 722 | + | } | |
| 653 | 723 | ||
| 724 | + | // mrReadPresentation maps usecase read tracking to commit rows and navigation. | |
| 725 | + | func (s *Server) mrReadPresentation(p *MRPage, name string, n int64, res ports.MergeRequestDetail, since, signed bool) { | |
| 726 | + | now := s.cfg.Now() | |
| 727 | + | head := res.MergeRequest.HeadID.String() | |
| 654 | 728 | // The commits of the request, newest first. | |
| 655 | - | for _, c := range res.Commits { | |
| 656 | - | row := commitRow(d.name(), c, now) | |
| 729 | + | for i, c := range res.Commits { | |
| 730 | + | row := commitRow(name, c, now) | |
| 657 | 731 | p.Commits = append(p.Commits, MRCommit{ | |
| 658 | 732 | Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt, | |
| 659 | - | Sig: row.Sig, | |
| 733 | + | Sig: row.Sig, New: signed && i < res.NewCommits, | |
| 660 | 734 | }) | |
| 661 | 735 | } | |
| 662 | 736 | lastRead := res.LastReadID.String() | |
| 663 | - | if signed && lastRead != "" { | |
| 664 | - | // The commits are newest first: those before the last read head are new. | |
| 665 | - | // A head that is not among them (the branch was rewritten) marks none. | |
| 666 | - | for i, c := range res.Commits { | |
| 667 | - | if c.ID.String() != lastRead { | |
| 668 | - | continue | |
| 669 | - | } | |
| 670 | - | for j := 0; j < i; j++ { | |
| 671 | - | p.Commits[j].New = true | |
| 672 | - | } | |
| 673 | - | } | |
| 674 | - | } | |
| 675 | 737 | if res.CommitsTruncated { | |
| 676 | 738 | p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed." | |
| 677 | 739 | } | |
| 678 | 740 | ||
| 679 | 741 | // Since the last read: the navigation, and what the page says about it. | |
| 680 | - | if !signed { | |
| 681 | - | lastRead = "" // the read position is the owner's | |
| 682 | - | } | |
| 683 | 742 | if signed && lastRead != "" { | |
| 684 | 743 | p.Since = &SinceNav{ | |
| 685 | - | AllHref: mrHref(d.name(), n), SinceHref: mrHref(d.name(), n, "since"), | |
| 744 | + | AllHref: mrHref(name, n), SinceHref: mrHref(name, n, "since"), | |
| 686 | 745 | ReadShort: short(lastRead), HeadShort: short(head), Current: since, | |
| 687 | 746 | } | |
| 688 | 747 | } | |
| @@ -702,57 +761,6 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 702 | 761 | p.Since.Files = len(p.Files) | |
| 703 | 762 | } | |
| 704 | 763 | ||
| 705 | - | // Comments: under their line, else in the list below the diff. The forms | |
| 706 | - | // are the owner's, on the whole diff of an open request. | |
| 707 | - | canWrite := signed && open && !since | |
| 708 | - | var on *lineRef | |
| 709 | - | if canWrite { | |
| 710 | - | on = parseOn(r.URL.Query().Get("on")) | |
| 711 | - | } | |
| 712 | - | s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on) | |
| 713 | - | lines, invisible := diffLines(p.Files) | |
| 714 | - | p.Invisible = invisible | |
| 715 | - | p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | |
| 716 | - | if signed && open { | |
| 717 | - | p.Box = s.mergeBox(d, n, m.Source, head, lastRead, mergeabilityView(res.Mergeability, m.Target)) | |
| 718 | - | p.Box.Error = mergeErrorOf(ctx) | |
| 719 | - | if m.IsDraft() { | |
| 720 | - | p.Box.ReadyAction = mrHref(d.name(), n, "ready") | |
| 721 | - | } | |
| 722 | - | } | |
| 723 | - | if mergeErrorOf(ctx) != "" { | |
| 724 | - | p.Canonical = self | |
| 725 | - | } | |
| 726 | - | if p.Box == nil { | |
| 727 | - | p.MergeError = mergeErrorOf(ctx) // the request is not open any more | |
| 728 | - | } | |
| 729 | - | if canWrite { | |
| 730 | - | p.GeneralForm = &CommentForm{ | |
| 731 | - | ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request", | |
| 732 | - | } | |
| 733 | - | } | |
| 734 | - | ||
| 735 | - | l := s.repoLayout(ctx, d, "merge-requests") | |
| 736 | - | l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance | |
| 737 | - | if head != "" { | |
| 738 | - | pinRef(&l, target{rev: head, name: m.Source}) | |
| 739 | - | if !d.hasBranch(m.Source) { | |
| 740 | - | pinRef(&l, target{rev: head, name: short(head), detached: true}) | |
| 741 | - | } | |
| 742 | - | } | |
| 743 | - | l.Crumbs[0].Current = false | |
| 744 | - | l.Crumbs = append(l.Crumbs, | |
| 745 | - | Crumb{Label: "merge requests", Href: mrBase(d.name())}, | |
| 746 | - | Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since}, | |
| 747 | - | ) | |
| 748 | - | if since { | |
| 749 | - | l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true}) | |
| 750 | - | } | |
| 751 | - | p.Live = true | |
| 752 | - | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)+"&target="+url.QueryEscape(m.Target)) | |
| 753 | - | l.HasRail = true | |
| 754 | - | l.MR = true | |
| 755 | - | return MergeRequest(l, p), http.StatusOK, nil | |
| 756 | 764 | } | |
| 757 | 765 | ||
| 758 | 766 | // hasBranch tells whether the repository has the branch. | |
| @@ -766,11 +774,11 @@ func (d *repoData) hasBranch(name string) bool { | |||
| 766 | 774 | } | |
| 767 | 775 | ||
| 768 | 776 | // mergeBox is what the owner can do with an open request. | |
| 769 | - | func (s *Server) mergeBox(d *repoData, n int64, source, head, lastRead string, mg MergeabilityView) *MergeBox { | |
| 777 | + | func (s *Server) mergeBox(d *repoData, n int64, source, head string, canMarkRead bool, mg MergeabilityView) *MergeBox { | |
| 770 | 778 | b := &MergeBox{ | |
| 771 | 779 | MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"), | |
| 772 | 780 | ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "", | |
| 773 | - | CanMarkRead: head != "" && head != lastRead, | |
| 781 | + | CanMarkRead: canMarkRead, | |
| 774 | 782 | CanDeleteBranch: source != d.repo.DefaultBranch, | |
| 775 | 783 | Mergeability: mg, | |
| 776 | 784 | } | |
M internal/adapters/web/mr_files.go +0 -2
| @@ -157,8 +157,6 @@ func splitPath(p string) (name, dir string) { | |||
| 157 | 157 | // the files of a window with their hunks and their comments, for a | |
| 158 | 158 | // placeholder of the page. A base or a head that is not the one of the diff | |
| 159 | 159 | // any more answers 409: the page says to reload. | |
| 160 | - | // | |
| 161 | - | //hexcheck:ignore suspicious-business-logic-in-adapter builds a window of the merge request page from the use case results; no rule is decided here | |
| 162 | 160 | func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 163 | 161 | n, err := mrNumber(r) | |
| 164 | 162 | if err != nil { | |
M internal/adapters/web/mr_post.go +0 -2
| @@ -295,8 +295,6 @@ func commentID(s string) int64 { | |||
| 295 | 295 | } | |
| 296 | 296 | ||
| 297 | 297 | // headID parses a commit id of a form; the empty string is the zero id. | |
| 298 | - | // | |
| 299 | - | //hexcheck:ignore suspicious-business-logic-in-adapter parses a form field into the domain id and names the field in the error; no rule is decided here | |
| 300 | 298 | func headID(s string) (domain.ObjectID, error) { | |
| 301 | 299 | if s == "" { | |
| 302 | 300 | return domain.ObjectID{}, nil | |
M internal/adapters/web/mr_test.go +4 -4
| @@ -63,7 +63,7 @@ func TestMergeBoxModes(t *testing.T) { | |||
| 63 | 63 | d := &repoData{repo: repoWithModes(domain.MergeModes()...)} | |
| 64 | 64 | s := &Server{} | |
| 65 | 65 | ||
| 66 | - | behind := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main")) | |
| 66 | + | behind := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main")) | |
| 67 | 67 | require.Len(t, behind.Modes, 3) | |
| 68 | 68 | assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind") | |
| 69 | 69 | assert.NotEmpty(t, behind.Modes[0].Reason) | |
| @@ -73,7 +73,7 @@ func TestMergeBoxModes(t *testing.T) { | |||
| 73 | 73 | assert.True(t, behind.CanMerge()) | |
| 74 | 74 | assert.True(t, behind.Signed) | |
| 75 | 75 | ||
| 76 | - | conflict := s.mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main")) | |
| 76 | + | conflict := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main")) | |
| 77 | 77 | assert.False(t, conflict.CanMerge()) | |
| 78 | 78 | assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled") | |
| 79 | 79 | assert.Equal(t, 0, conflict.Checked()) | |
| @@ -84,7 +84,7 @@ func TestMergeBoxModes(t *testing.T) { | |||
| 84 | 84 | func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) { | |
| 85 | 85 | t.Parallel() | |
| 86 | 86 | d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)} | |
| 87 | - | b := (&Server{}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")) | |
| 87 | + | b := (&Server{}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")) | |
| 88 | 88 | assert.False(t, b.Signed, "no commit is created, nothing to explain") | |
| 89 | 89 | assert.Len(t, b.Modes, 1) | |
| 90 | 90 | } | |
| @@ -94,7 +94,7 @@ func TestMergeBoxNamesTheSigningKey(t *testing.T) { | |||
| 94 | 94 | key := mocks.NewSigningKeyService(t) | |
| 95 | 95 | key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}}) | |
| 96 | 96 | d := &repoData{repo: repoWithModes(domain.MergeModes()...)} | |
| 97 | - | b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", "", mergeabilityView(domain.Mergeability{}, "main")) | |
| 97 | + | b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{}, "main")) | |
| 98 | 98 | assert.Equal(t, "SHA256:abc", b.Fingerprint) | |
| 99 | 99 | } | |
| 100 | 100 | ||
M internal/adapters/web/paths.go +0 -2
| @@ -33,8 +33,6 @@ type pathList struct { | |||
| 33 | 33 | // an ETag that makes it a 304 (answered before the list is loaded, so it never | |
| 34 | 34 | // waits for a build); the owner's copy is private and never stored. The ETag | |
| 35 | 35 | // differs for the gzip and the plain variant. | |
| 36 | - | // | |
| 37 | - | //hexcheck:ignore suspicious-business-logic-in-adapter serves a cached list after the use case authorized it; the headers are about the response, no rule is decided here | |
| 38 | 36 | func (s *Server) paths(w http.ResponseWriter, r *http.Request) { | |
| 39 | 37 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 40 | 38 | defer cancel() | |
M internal/adapters/web/raw.go +0 -2
| @@ -31,8 +31,6 @@ const rawCSP = "default-src 'none'; sandbox" | |||
| 31 | 31 | // image types (by their bytes, never SVG) as images, and anything else as an | |
| 32 | 32 | // attachment of unknown type. A file is never served as HTML or SVG, so what | |
| 33 | 33 | // a repository holds cannot run in the origin of the forge. | |
| 34 | - | // | |
| 35 | - | //hexcheck:ignore suspicious-business-logic-in-adapter streams a file the use case opened; the headers are about the response, no rule is decided here | |
| 36 | 34 | func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |
| 37 | 35 | ctx, cancel := context.WithTimeout(r.Context(), rawTimeout) | |
| 38 | 36 | defer cancel() | |
M internal/adapters/web/refs.go +0 -4
| @@ -15,8 +15,6 @@ import ( | |||
| 15 | 15 | // that is the short name of a branch or tag wins. A branch wins over a tag of | |
| 16 | 16 | // the same name. rest has no leading slash and is "" when the path is the ref | |
| 17 | 17 | // alone. ok is false when no ref matches; the caller may then try a commit id. | |
| 18 | - | // | |
| 19 | - | //hexcheck:ignore suspicious-business-logic-in-adapter finds the ref a URL path starts with; URL routing, no rule is decided here | |
| 20 | 18 | func resolveRef(refs []domain.Ref, p string) (ref domain.Ref, rest string, ok bool) { | |
| 21 | 19 | names := refsByName(refs) | |
| 22 | 20 | p = strings.Trim(p, "/") | |
| @@ -97,8 +95,6 @@ func (t target) commitID() string { | |||
| 97 | 95 | // resolveCommit sets the full id of a target named by an abbreviated commit id, | |
| 98 | 96 | // with one ResolveRevision. The page renders without it (it only lacks the file | |
| 99 | 97 | // finder), so a failure is not one. | |
| 100 | - | // | |
| 101 | - | //hexcheck:ignore suspicious-business-logic-in-adapter learns the full id of the commit a URL names for the finder links; no rule is decided here | |
| 102 | 98 | func (s *Server) resolveCommit(ctx context.Context, repo domain.RepoName, t *target) { | |
| 103 | 99 | if !t.detached || isFullID(t.rev) { | |
| 104 | 100 | return | |
M internal/adapters/web/repo.go +3 -7
| @@ -44,16 +44,12 @@ func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.Repo | |||
| 44 | 44 | ||
| 45 | 45 | // repoRecord reads the repository record only, without Git: its name, | |
| 46 | 46 | // visibility and settings (the defaults when it has none). | |
| 47 | - | // | |
| 48 | - | //hexcheck:ignore suspicious-business-logic-in-adapter reads the repository from the use case; filling in the default settings for display decides no rule | |
| 49 | 47 | func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) { | |
| 50 | 48 | repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name) | |
| 51 | 49 | if err != nil { | |
| 52 | 50 | return domain.Repo{}, err | |
| 53 | 51 | } | |
| 54 | - | if repo.Settings.IsZero() { | |
| 55 | - | repo.Settings = domain.DefaultRepoSettings() | |
| 56 | - | } | |
| 52 | + | repo.Settings = repo.EffectiveSettings() | |
| 57 | 53 | return repo, nil | |
| 58 | 54 | } | |
| 59 | 55 | ||
| @@ -132,7 +128,7 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string { | |||
| 132 | 128 | // switcher on the head ref and the ":" targets. The caller sets the title, | |
| 133 | 129 | // the crumbs, the status and the rail. | |
| 134 | 130 | // | |
| 135 | - | //hexcheck:ignore suspicious-business-logic-in-adapter lists the tabs of the repository; the issues tab follows the setting, no rule is decided here | |
| 131 | + | //hexcheck:ignore suspicious-business-logic-in-adapter renders repository navigation; the issues tab uses the domain IssuesEnabled predicate | |
| 136 | 132 | func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout { | |
| 137 | 133 | l := s.base() | |
| 138 | 134 | l.Repo = d.name() | |
| @@ -157,7 +153,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||
| 157 | 153 | mrs.Count = s.openMRCount(ctx, d.repo.Name) | |
| 158 | 154 | l.Sections = append(l.Sections, mrs) | |
| 159 | 155 | } | |
| 160 | - | if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff { | |
| 156 | + | if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() { | |
| 161 | 157 | l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues")) | |
| 162 | 158 | } | |
| 163 | 159 | l.Sections = s.withActions(l.Sections, d.name(), section) | |
M internal/adapters/web/tree.go +0 -2
| @@ -72,8 +72,6 @@ func (p TreePage) Unit() string { | |||
| 72 | 72 | ||
| 73 | 73 | // tree lists a directory of a ref, with the last commit of each entry. With | |
| 74 | 74 | // ?rows it answers the entries alone, for a directory opened in place. | |
| 75 | - | // | |
| 76 | - | //hexcheck:ignore suspicious-business-logic-in-adapter builds the directory page from the use case results; no rule is decided here | |
| 77 | 75 | func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 78 | 76 | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 79 | 77 | if err != nil { | |
M internal/adapters/webauthn/webauthn.go +0 -2
| @@ -137,8 +137,6 @@ func (v *Verifier) BeginLogin() ([]byte, []byte, error) { | |||
| 137 | 137 | ||
| 138 | 138 | // FinishLogin implements ports.PasskeyVerifier. The library compares the user | |
| 139 | 139 | // handle of the response with userHandle. | |
| 140 | - | // | |
| 141 | - | //hexcheck:ignore suspicious-business-logic-in-adapter copies the counter and backup state the library verified into the stored passkey; it decides nothing: the use case refuses a counter that does not advance | |
| 142 | 140 | func (v *Verifier) FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) { | |
| 143 | 141 | var session gowebauthn.SessionData | |
| 144 | 142 | if err := json.Unmarshal(ceremony, &session); err != nil { | |
M internal/app/forge.go +3 -8
| @@ -151,8 +151,7 @@ type forge struct { | |||
| 151 | 151 | CI *usecase.CI | |
| 152 | 152 | // Settings are the secrets, variables and environments of CI. | |
| 153 | 153 | Settings *usecase.CISettings | |
| 154 | - | // OIDC is the provider of the ID tokens of CI jobs; nil without a secrets | |
| 155 | - | // key. | |
| 154 | + | // OIDC is the provider of ID tokens, disabled without a secrets key. | |
| 156 | 155 | OIDC *usecase.OIDC | |
| 157 | 156 | API *rpc.API | |
| 158 | 157 | Bus ports.EventBus | |
| @@ -313,7 +312,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 313 | 312 | opts.ci(&ciCfg) | |
| 314 | 313 | } | |
| 315 | 314 | f.CI = usecase.NewCI(ctx, ciCfg) | |
| 316 | - | f.Pushes.WithWorkflowTrigger(f.CI).WithWorkflowGuard(git).WithRefTips(store.CI()) | |
| 315 | + | f.Pushes.WithWorkflowTrigger(f.CI).WithPushTreeReader(git).WithRefTips(store.CI()) | |
| 317 | 316 | f.Repos.WithCIRunCleaner(f.CI) | |
| 318 | 317 | f.CI.Start() | |
| 319 | 318 | f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{ | |
| @@ -394,10 +393,6 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 394 | 393 | if f.Passkeys != nil { | |
| 395 | 394 | apiPasskeys = f.Passkeys | |
| 396 | 395 | } | |
| 397 | - | var apiOIDC ports.OIDCKeyService // same rule as apiPasskeys | |
| 398 | - | if f.OIDC != nil { | |
| 399 | - | apiOIDC = f.OIDC | |
| 400 | - | } | |
| 401 | 396 | var jobArtifacts ports.JobArtifactService | |
| 402 | 397 | var artifacts ports.ArtifactService | |
| 403 | 398 | if cfg.CIArtifacts { | |
| @@ -421,7 +416,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 421 | 416 | Secrets: f.Settings, | |
| 422 | 417 | Variables: f.Settings, | |
| 423 | 418 | Environments: f.Settings, | |
| 424 | - | OIDCKeys: apiOIDC, | |
| 419 | + | OIDCKeys: f.OIDC, | |
| 425 | 420 | JobArtifacts: jobArtifacts, | |
| 426 | 421 | Artifacts: artifacts, | |
| 427 | 422 | Version: serverVersion(), | |
A internal/domain/budget.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import "fmt" | |
| 6 | + | ||
| 7 | + | // ValidateCommitObjects checks a request before any repository I/O. max is the | |
| 8 | + | // caller's commit-count limit. | |
| 9 | + | func ValidateCommitObjects(ids []ObjectID, max int) error { | |
| 10 | + | if len(ids) > max { | |
| 11 | + | return fmt.Errorf("%w: %d commit objects asked, at most %d", ErrInvalid, len(ids), max) | |
| 12 | + | } | |
| 13 | + | for _, id := range ids { | |
| 14 | + | if id.IsZero() { | |
| 15 | + | return fmt.Errorf("%w: empty commit id", ErrInvalidObjectID) | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | return nil | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // CommitObjectBudget bounds a batch of raw commits. Its zero value is ready to | |
| 22 | + | // use. Admission is a preview; Charge records only successfully read content. | |
| 23 | + | type CommitObjectBudget struct { | |
| 24 | + | total int64 | |
| 25 | + | seen map[ObjectID]bool | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (b *CommitObjectBudget) Seen(id ObjectID) bool { return b.seen[id] } | |
| 29 | + | ||
| 30 | + | func (b *CommitObjectBudget) Admit(id ObjectID, commit bool, size int64) (take, stop bool) { | |
| 31 | + | if b.Seen(id) || !commit || size < 0 || size > MaxCommitObjectBytes { | |
| 32 | + | return false, false | |
| 33 | + | } | |
| 34 | + | if size > MaxCommitObjectsBytes-b.total { | |
| 35 | + | return false, true | |
| 36 | + | } | |
| 37 | + | return true, false | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (b *CommitObjectBudget) Charge(id ObjectID, size int64) { | |
| 41 | + | if b.seen == nil { | |
| 42 | + | b.seen = make(map[ObjectID]bool) | |
| 43 | + | } | |
| 44 | + | b.seen[id] = true | |
| 45 | + | b.total += size | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // DiffBudget owns file selection and retained hunk accounting. Truncated files | |
| 49 | + | // consume no budget; streaming readers may use a copy while attempting a file. | |
| 50 | + | type DiffBudget struct { | |
| 51 | + | limits DiffLimits | |
| 52 | + | linesLeft, bytesLeft int | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func NewDiffBudget(limits DiffLimits) DiffBudget { | |
| 56 | + | return DiffBudget{limits: limits, linesLeft: limits.MaxTotalLines, bytesLeft: limits.MaxTotalBytes} | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (b DiffBudget) Window(files int) (kept, from, to int, truncated bool) { | |
| 60 | + | kept = files | |
| 61 | + | if b.limits.MaxFiles > 0 && kept > b.limits.MaxFiles { | |
| 62 | + | kept = b.limits.MaxFiles | |
| 63 | + | truncated = true | |
| 64 | + | } | |
| 65 | + | from, to = max(b.limits.From, 0), kept | |
| 66 | + | if b.limits.To > 0 { | |
| 67 | + | to = min(b.limits.To, kept) | |
| 68 | + | } | |
| 69 | + | return | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (b DiffBudget) ChangedLines(binary bool, n int) bool { return !binary && n > 0 } | |
| 73 | + | ||
| 74 | + | func (b DiffBudget) WantsHunks(binary bool, n int) bool { | |
| 75 | + | return b.ChangedLines(binary, n) && n <= b.limits.MaxFileLines && n <= b.linesLeft && b.bytesLeft > 0 | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (b DiffBudget) FileLines() int { return min(b.limits.MaxFileLines, b.linesLeft) } | |
| 79 | + | ||
| 80 | + | // ChargeLine accounts for one decoded hunk line and reports whether it fits. | |
| 81 | + | func (b *DiffBudget) ChargeLine(bytes int) bool { | |
| 82 | + | b.linesLeft-- | |
| 83 | + | b.bytesLeft -= bytes | |
| 84 | + | return b.linesLeft >= 0 && b.bytesLeft >= 0 | |
| 85 | + | } | |
A internal/domain/budget_test.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func budgetID(t *testing.T, s string) ObjectID { | |
| 11 | + | t.Helper() | |
| 12 | + | id, err := ParseObjectID(s) | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | return id | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func TestValidateCommitObjects(t *testing.T) { | |
| 20 | + | id := budgetID(t, "1111111111111111111111111111111111111111") | |
| 21 | + | for _, tt := range []struct { | |
| 22 | + | name string | |
| 23 | + | ids []ObjectID | |
| 24 | + | max int | |
| 25 | + | want error | |
| 26 | + | }{ | |
| 27 | + | {"empty", nil, 1, nil}, {"boundary", []ObjectID{id}, 1, nil}, | |
| 28 | + | {"too many", []ObjectID{id, id}, 1, ErrInvalid}, {"zero", []ObjectID{{}}, 1, ErrInvalidObjectID}, | |
| 29 | + | } { | |
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |
| 31 | + | if err := ValidateCommitObjects(tt.ids, tt.max); !errors.Is(err, tt.want) { | |
| 32 | + | t.Fatalf("got %v, want %v", err, tt.want) | |
| 33 | + | } | |
| 34 | + | }) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestCommitObjectBudget(t *testing.T) { | |
| 39 | + | id := budgetID(t, "1111111111111111111111111111111111111111") | |
| 40 | + | for _, tt := range []struct { | |
| 41 | + | name string | |
| 42 | + | commit bool | |
| 43 | + | size, total int64 | |
| 44 | + | seen, take, stop bool | |
| 45 | + | }{ | |
| 46 | + | {"non commit", false, 1, 0, false, false, false}, | |
| 47 | + | {"oversize", true, MaxCommitObjectBytes + 1, 0, false, false, false}, | |
| 48 | + | {"exact object", true, MaxCommitObjectBytes, 0, false, true, false}, | |
| 49 | + | {"exact total", true, 1, MaxCommitObjectsBytes - 1, false, true, false}, | |
| 50 | + | {"over total", true, 2, MaxCommitObjectsBytes - 1, false, false, true}, | |
| 51 | + | {"duplicate", true, 1, MaxCommitObjectsBytes, true, false, false}, | |
| 52 | + | {"negative", true, -1, 0, false, false, false}, | |
| 53 | + | } { | |
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |
| 55 | + | b := CommitObjectBudget{total: tt.total} | |
| 56 | + | if tt.seen { | |
| 57 | + | b.seen = map[ObjectID]bool{id: true} | |
| 58 | + | } | |
| 59 | + | take, stop := b.Admit(id, tt.commit, tt.size) | |
| 60 | + | if take != tt.take || stop != tt.stop { | |
| 61 | + | t.Fatalf("got %v,%v", take, stop) | |
| 62 | + | } | |
| 63 | + | if take { | |
| 64 | + | b.Charge(id, tt.size) | |
| 65 | + | if !b.Seen(id) || b.total != tt.total+tt.size { | |
| 66 | + | t.Fatal("charge not recorded") | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | }) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestDiffBudgetWindow(t *testing.T) { | |
| 74 | + | for _, tt := range []struct { | |
| 75 | + | name string | |
| 76 | + | limits DiffLimits | |
| 77 | + | kept, from, to int | |
| 78 | + | truncated bool | |
| 79 | + | }{ | |
| 80 | + | {"unlimited", DiffLimits{}, 5, 0, 5, false}, | |
| 81 | + | {"cap", DiffLimits{MaxFiles: 3}, 3, 0, 3, true}, | |
| 82 | + | {"window", DiffLimits{From: 1, To: 4}, 5, 1, 4, false}, | |
| 83 | + | {"clamped", DiffLimits{From: -1, To: 9, MaxFiles: 3}, 3, 0, 3, true}, | |
| 84 | + | {"past end", DiffLimits{From: 8}, 5, 8, 5, false}, | |
| 85 | + | {"reversed", DiffLimits{From: 4, To: 2}, 5, 4, 2, false}, | |
| 86 | + | } { | |
| 87 | + | t.Run(tt.name, func(t *testing.T) { | |
| 88 | + | b := NewDiffBudget(tt.limits) | |
| 89 | + | k, f, to, tr := b.Window(5) | |
| 90 | + | if k != tt.kept || f != tt.from || to != tt.to || tr != tt.truncated { | |
| 91 | + | t.Fatalf("got %d,%d,%d,%v", k, f, to, tr) | |
| 92 | + | } | |
| 93 | + | }) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestDiffBudgetHunks(t *testing.T) { | |
| 98 | + | for _, tt := range []struct { | |
| 99 | + | name string | |
| 100 | + | binary bool | |
| 101 | + | n, lines, bytes int | |
| 102 | + | want bool | |
| 103 | + | }{ | |
| 104 | + | {"fits", false, 3, 3, 1, true}, {"binary", true, 3, 3, 1, false}, | |
| 105 | + | {"no changes", false, 0, 3, 1, false}, {"file limit", false, 4, 5, 1, false}, | |
| 106 | + | {"total lines", false, 3, 2, 1, false}, {"no bytes", false, 1, 3, 0, false}, | |
| 107 | + | } { | |
| 108 | + | t.Run(tt.name, func(t *testing.T) { | |
| 109 | + | b := NewDiffBudget(DiffLimits{MaxFileLines: 3, MaxTotalLines: tt.lines, MaxTotalBytes: tt.bytes}) | |
| 110 | + | if got := b.WantsHunks(tt.binary, tt.n); got != tt.want { | |
| 111 | + | t.Fatalf("got %v", got) | |
| 112 | + | } | |
| 113 | + | }) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func TestDiffBudgetCharge(t *testing.T) { | |
| 118 | + | for _, tt := range []struct { | |
| 119 | + | name string | |
| 120 | + | bytes int | |
| 121 | + | fits bool | |
| 122 | + | }{{"exact", 3, true}, {"over", 4, false}} { | |
| 123 | + | t.Run(tt.name, func(t *testing.T) { | |
| 124 | + | b := NewDiffBudget(DiffLimits{MaxFileLines: 2, MaxTotalLines: 1, MaxTotalBytes: 3}) | |
| 125 | + | before := b | |
| 126 | + | if b.FileLines() != 1 { | |
| 127 | + | t.Fatal("wrong file limit") | |
| 128 | + | } | |
| 129 | + | if b.ChargeLine(tt.bytes) != tt.fits { | |
| 130 | + | t.Fatal("wrong charge result") | |
| 131 | + | } | |
| 132 | + | if b.ChargeLine(0) { | |
| 133 | + | t.Fatal("line budget exceeded") | |
| 134 | + | } | |
| 135 | + | b = before | |
| 136 | + | if !b.WantsHunks(false, 1) { | |
| 137 | + | t.Fatal("discarded file consumed budget") | |
| 138 | + | } | |
| 139 | + | }) | |
| 140 | + | } | |
| 141 | + | } | |
A internal/domain/ci_workflow_guard.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import "fmt" | |
| 6 | + | ||
| 7 | + | // PushObjectType is the type of a peeled Git object. | |
| 8 | + | type PushObjectType string | |
| 9 | + | ||
| 10 | + | // Peeled object types accepted by the push tree reader. | |
| 11 | + | const ( | |
| 12 | + | PushObjectCommit PushObjectType = "commit" | |
| 13 | + | PushObjectTree PushObjectType = "tree" | |
| 14 | + | PushObjectBlob PushObjectType = "blob" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // PushWalkBounds bounds the walk over the commits of a push. | |
| 18 | + | type PushWalkBounds struct{ Commits, IDs int } | |
| 19 | + | ||
| 20 | + | // Bounds fail closed and count repeated parent IDs too. | |
| 21 | + | const ( | |
| 22 | + | MaxPushedCommits = 10000 | |
| 23 | + | MaxPushedIDs = 4 * MaxPushedCommits | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // DefaultPushWalkBounds returns the job-token push walk limits. | |
| 27 | + | func DefaultPushWalkBounds() PushWalkBounds { return PushWalkBounds{MaxPushedCommits, MaxPushedIDs} } | |
| 28 | + | ||
| 29 | + | // Subtree is the kind and identity of an entry at a tree path. | |
| 30 | + | type Subtree struct { | |
| 31 | + | Kind EntryKind | |
| 32 | + | ID ObjectID | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // PushedCommit is a newly reachable commit with its ordered parents. | |
| 36 | + | type PushedCommit struct { | |
| 37 | + | ID ObjectID | |
| 38 | + | Parents []ObjectID | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // HeadKey identifies the default branch head in PushSubtrees.Subtrees. | |
| 42 | + | const HeadKey = "HEAD" | |
| 43 | + | ||
| 44 | + | // PushSubtrees contains endpoint types and entries keyed by original object ID | |
| 45 | + | // (or HeadKey), and newly reachable commits in rev-list order. Absent entries | |
| 46 | + | // mean there is no subtree at the requested path. | |
| 47 | + | type PushSubtrees struct { | |
| 48 | + | Ends map[string]PushObjectType | |
| 49 | + | Commits []PushedCommit | |
| 50 | + | Subtrees map[string]Subtree | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // WorkflowTreeChange is the first forbidden workflow-tree replacement. | |
| 54 | + | // A zero Old denotes no workflow directory. | |
| 55 | + | type WorkflowTreeChange struct{ Old, New ObjectID } | |
| 56 | + | ||
| 57 | + | // WorkflowChangeOf applies the job-token workflow policy. Every new commit must | |
| 58 | + | // keep the workflows of at least one parent; then each ref must keep its old | |
| 59 | + | // workflows (HEAD's for a new ref). Disappearance and ref deletion are allowed. | |
| 60 | + | // New commits are judged before ref ends, in their supplied order. Missing ends | |
| 61 | + | // are a reader contract breach and fail closed with ErrInvalid. | |
| 62 | + | func WorkflowChangeOf(updates []RefUpdate, t PushSubtrees) (WorkflowTreeChange, bool, error) { | |
| 63 | + | for _, up := range updates { | |
| 64 | + | if up.IsDelete() { | |
| 65 | + | continue | |
| 66 | + | } | |
| 67 | + | ids := []string{up.New} | |
| 68 | + | if !up.IsCreate() { | |
| 69 | + | ids = append(ids, up.Old) | |
| 70 | + | } | |
| 71 | + | for _, id := range ids { | |
| 72 | + | if _, ok := t.Ends[id]; !ok { | |
| 73 | + | return WorkflowTreeChange{}, false, fmt.Errorf("%w: missing push end %s", ErrInvalid, id) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | dirOf := func(key string) ObjectID { | |
| 78 | + | s := t.Subtrees[key] | |
| 79 | + | if s.Kind != EntryDirectory { | |
| 80 | + | return ObjectID{} | |
| 81 | + | } | |
| 82 | + | return s.ID | |
| 83 | + | } | |
| 84 | + | for _, c := range t.Commits { | |
| 85 | + | cur := dirOf(c.ID.String()) | |
| 86 | + | if cur.IsZero() { | |
| 87 | + | continue | |
| 88 | + | } | |
| 89 | + | var first ObjectID | |
| 90 | + | same := false | |
| 91 | + | for i, p := range c.Parents { | |
| 92 | + | w := dirOf(p.String()) | |
| 93 | + | if i == 0 { | |
| 94 | + | first = w | |
| 95 | + | } | |
| 96 | + | if w == cur { | |
| 97 | + | same = true | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | if !same { | |
| 101 | + | return WorkflowTreeChange{first, cur}, true, nil | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | for _, up := range updates { | |
| 105 | + | if up.IsDelete() || t.Ends[up.New] == PushObjectBlob { | |
| 106 | + | continue | |
| 107 | + | } | |
| 108 | + | cur := dirOf(up.New) | |
| 109 | + | if cur.IsZero() { | |
| 110 | + | continue | |
| 111 | + | } | |
| 112 | + | var base ObjectID | |
| 113 | + | if up.IsCreate() { | |
| 114 | + | base = dirOf(HeadKey) | |
| 115 | + | } else if t.Ends[up.Old] != PushObjectBlob { | |
| 116 | + | base = dirOf(up.Old) | |
| 117 | + | } | |
| 118 | + | if base != cur { | |
| 119 | + | return WorkflowTreeChange{base, cur}, true, nil | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | return WorkflowTreeChange{}, false, nil | |
| 123 | + | } | |
A internal/domain/ci_workflow_guard_test.go +120 -0
| @@ -0,0 +1,120 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "bnema.dev/ermite/internal/domain" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestWorkflowChangeOf(t *testing.T) { | |
| 15 | + | id := func(n int) domain.ObjectID { | |
| 16 | + | v, err := domain.ParseObjectID(fmt.Sprintf("%040x", n)) | |
| 17 | + | require.NoError(t, err) | |
| 18 | + | return v | |
| 19 | + | } | |
| 20 | + | a, b, c, d := id(1), id(2), id(3), id(4) | |
| 21 | + | x, y, z := id(11), id(12), id(13) | |
| 22 | + | dir := func(v domain.ObjectID) domain.Subtree { return domain.Subtree{Kind: domain.EntryDirectory, ID: v} } | |
| 23 | + | up := func(old, new domain.ObjectID) domain.RefUpdate { | |
| 24 | + | o, n := old.String(), new.String() | |
| 25 | + | if old.IsZero() { | |
| 26 | + | o = domain.ZeroOID | |
| 27 | + | } | |
| 28 | + | if new.IsZero() { | |
| 29 | + | n = domain.ZeroOID | |
| 30 | + | } | |
| 31 | + | return domain.RefUpdate{Old: o, New: n} | |
| 32 | + | } | |
| 33 | + | zero := domain.ObjectID{} | |
| 34 | + | commit := func(v domain.ObjectID, parents ...domain.ObjectID) domain.PushedCommit { | |
| 35 | + | return domain.PushedCommit{ID: v, Parents: parents} | |
| 36 | + | } | |
| 37 | + | type row struct { | |
| 38 | + | name string | |
| 39 | + | ups []domain.RefUpdate | |
| 40 | + | commits []domain.PushedCommit | |
| 41 | + | trees map[string]domain.Subtree | |
| 42 | + | treeEnds []domain.ObjectID | |
| 43 | + | blobs []domain.ObjectID | |
| 44 | + | want domain.WorkflowTreeChange | |
| 45 | + | found bool | |
| 46 | + | missing bool | |
| 47 | + | } | |
| 48 | + | change := func(old, new domain.ObjectID) domain.WorkflowTreeChange { | |
| 49 | + | return domain.WorkflowTreeChange{Old: old, New: new} | |
| 50 | + | } | |
| 51 | + | rows := []row{ | |
| 52 | + | {name: "no updates"}, | |
| 53 | + | {name: "delete", ups: []domain.RefUpdate{up(a, zero)}}, | |
| 54 | + | {name: "new blob ignores even a misleading subtree", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, blobs: []domain.ObjectID{b}}, | |
| 55 | + | {name: "old blob is not a directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, blobs: []domain.ObjectID{a}, want: change(zero, y), found: true}, | |
| 56 | + | {name: "new ref at HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), a.String(): dir(x)}}, | |
| 57 | + | {name: "new ref same directory as HEAD", ups: []domain.RefUpdate{up(zero, b)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), b.String(): dir(x)}}, | |
| 58 | + | {name: "new ref old workflows", ups: []domain.RefUpdate{up(zero, b)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), b.String(): dir(y)}, want: change(x, y), found: true}, | |
| 59 | + | {name: "unborn HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true}, | |
| 60 | + | {name: "HEAD has no workflows", ups: []domain.RefUpdate{up(zero, a)}}, | |
| 61 | + | {name: "same workflow directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}}, | |
| 62 | + | {name: "move back or remove one file from surviving directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, want: change(x, y), found: true}, | |
| 63 | + | {name: "directory disappears", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x)}}, | |
| 64 | + | {name: "lookalike path absent", ups: []domain.RefUpdate{up(a, b)}}, | |
| 65 | + | {name: "root with workflows", commits: []domain.PushedCommit{commit(a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true}, | |
| 66 | + | {name: "root without workflows", commits: []domain.PushedCommit{commit(a)}}, | |
| 67 | + | {name: "plain pushed commit", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}}, | |
| 68 | + | {name: "pushed directory disappears", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}}, | |
| 69 | + | {name: "merge keeps second parent", commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y)}}, | |
| 70 | + | {name: "merge differs from all parents", commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(z)}, want: change(x, z), found: true}, | |
| 71 | + | {name: "merge new side changes workflows", commits: []domain.PushedCommit{commit(d, a, c), commit(c, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(z), d.String(): dir(x)}, want: change(y, z), found: true}, | |
| 72 | + | {name: "merge adopts other parent refused by ref rule", ups: []domain.RefUpdate{up(a, c)}, commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y)}, want: change(x, y), found: true}, | |
| 73 | + | {name: "change then revert", ups: []domain.RefUpdate{up(a, c)}, commits: []domain.PushedCommit{commit(c, b), commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(x)}, want: change(y, x), found: true}, | |
| 74 | + | {name: "change below plain tip", commits: []domain.PushedCommit{commit(d, c), commit(c, b)}, trees: map[string]domain.Subtree{b.String(): dir(x), c.String(): dir(y), d.String(): dir(y)}, want: change(x, y), found: true}, | |
| 75 | + | {name: "all new commits before any ref", ups: []domain.RefUpdate{up(a, b)}, commits: []domain.PushedCommit{commit(d, c)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y), d.String(): dir(z)}, want: change(y, z), found: true}, | |
| 76 | + | {name: "first ref wins", ups: []domain.RefUpdate{up(a, b), up(c, d)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y), d.String(): dir(z)}, want: change(x, y), found: true}, | |
| 77 | + | {name: "plain ref then changed ref", ups: []domain.RefUpdate{up(a, b), up(c, d)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x), c.String(): dir(y), d.String(): dir(z)}, want: change(y, z), found: true}, | |
| 78 | + | {name: "missing ends fail closed", ups: []domain.RefUpdate{up(a, b)}, missing: true}, | |
| 79 | + | } | |
| 80 | + | rows = append(rows, | |
| 81 | + | row{name: "new tree end with workflows", treeEnds: []domain.ObjectID{a}, ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true}, | |
| 82 | + | row{name: "new tree end without workflows", treeEnds: []domain.ObjectID{a}, ups: []domain.RefUpdate{up(zero, a)}}, | |
| 83 | + | row{name: "new blob ref", ups: []domain.RefUpdate{up(zero, a)}, blobs: []domain.ObjectID{a}}, | |
| 84 | + | ) | |
| 85 | + | ||
| 86 | + | for _, kind := range []domain.EntryKind{domain.EntrySubmodule, domain.EntrySymlink, domain.EntryFile, domain.EntryExecutable} { | |
| 87 | + | rows = append(rows, | |
| 88 | + | row{name: string(kind) + " to directory same id", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: kind, ID: x}, b.String(): dir(x)}, want: change(zero, x), found: true}, | |
| 89 | + | row{name: "directory to " + string(kind), ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): {Kind: kind, ID: x}}}, | |
| 90 | + | row{name: "same " + string(kind), ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: kind, ID: x}, b.String(): {Kind: kind, ID: x}}}, | |
| 91 | + | ) | |
| 92 | + | } | |
| 93 | + | rows = append(rows, row{name: "gitlink to symlink", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: domain.EntrySubmodule, ID: x}, b.String(): {Kind: domain.EntrySymlink, ID: y}}}) | |
| 94 | + | for _, r := range rows { | |
| 95 | + | t.Run(r.name, func(t *testing.T) { | |
| 96 | + | data := domain.PushSubtrees{Ends: map[string]domain.PushObjectType{}, Commits: r.commits, Subtrees: r.trees} | |
| 97 | + | if !r.missing { | |
| 98 | + | for _, u := range r.ups { | |
| 99 | + | for _, oid := range []string{u.Old, u.New} { | |
| 100 | + | data.Ends[oid] = domain.PushObjectCommit | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | for _, tree := range r.treeEnds { | |
| 105 | + | data.Ends[tree.String()] = domain.PushObjectTree | |
| 106 | + | } | |
| 107 | + | for _, b := range r.blobs { | |
| 108 | + | data.Ends[b.String()] = domain.PushObjectBlob | |
| 109 | + | } | |
| 110 | + | got, found, err := domain.WorkflowChangeOf(r.ups, data) | |
| 111 | + | if r.missing { | |
| 112 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | require.NoError(t, err) | |
| 116 | + | assert.Equal(t, r.found, found) | |
| 117 | + | assert.Equal(t, r.want, got) | |
| 118 | + | }) | |
| 119 | + | } | |
| 120 | + | } | |
M internal/domain/mergerequest.go +9 -0
| @@ -569,3 +569,12 @@ func BoundBehind(n int) (count int, capped bool) { | |||
| 569 | 569 | } | |
| 570 | 570 | return n, false | |
| 571 | 571 | } | |
| 572 | + | ||
| 573 | + | // IssuesEnabled reports whether the repository exposes issue streams and pages. | |
| 574 | + | func (s RepoSettings) IssuesEnabled() bool { return s.Issues.OrDefault() != IssuesOff } | |
| 575 | + | ||
| 576 | + | // ShowsIssueSubmissionReceipt reports whether submissions await public moderation. | |
| 577 | + | func (s RepoSettings) ShowsIssueSubmissionReceipt() bool { return s.Issues.OrDefault() == IssuesPublic } | |
| 578 | + | ||
| 579 | + | // AcceptsAnonymousIssues reports whether anyone may submit an issue. | |
| 580 | + | func (s RepoSettings) AcceptsAnonymousIssues() bool { return s.Issues.OrDefault() == IssuesPublic } | |
M internal/domain/repo.go +8 -0
| @@ -92,3 +92,11 @@ func validTopic(t string) bool { | |||
| 92 | 92 | } | |
| 93 | 93 | return true | |
| 94 | 94 | } | |
| 95 | + | ||
| 96 | + | // EffectiveSettings returns the repository settings, using defaults for an unset record. | |
| 97 | + | func (r Repo) EffectiveSettings() RepoSettings { | |
| 98 | + | if r.Settings.IsZero() { | |
| 99 | + | return DefaultRepoSettings() | |
| 100 | + | } | |
| 101 | + | return r.Settings | |
| 102 | + | } | |
M internal/domain/repo_test.go +34 -0
| @@ -60,3 +60,37 @@ func TestNormalizeTopics(t *testing.T) { | |||
| 60 | 60 | }) | |
| 61 | 61 | } | |
| 62 | 62 | } | |
| 63 | + | ||
| 64 | + | func TestRepoEffectiveSettings(t *testing.T) { | |
| 65 | + | custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeMode("squash")}, Issues: domain.IssuesPublic} | |
| 66 | + | for _, tc := range []struct { | |
| 67 | + | name string | |
| 68 | + | settings, want domain.RepoSettings | |
| 69 | + | }{ | |
| 70 | + | {"unset", domain.RepoSettings{}, domain.DefaultRepoSettings()}, | |
| 71 | + | {"custom", custom, custom}, | |
| 72 | + | } { | |
| 73 | + | t.Run(tc.name, func(t *testing.T) { | |
| 74 | + | repo := domain.Repo{Settings: tc.settings} | |
| 75 | + | assert.Equal(t, tc.want, repo.EffectiveSettings()) | |
| 76 | + | assert.Equal(t, tc.settings, repo.Settings) | |
| 77 | + | }) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestRepoIssuePresentationRules(t *testing.T) { | |
| 82 | + | for _, tc := range []struct { | |
| 83 | + | mode domain.IssuesMode | |
| 84 | + | enabled, receipt bool | |
| 85 | + | }{ | |
| 86 | + | {"", false, false}, {domain.IssuesOff, false, false}, | |
| 87 | + | {domain.IssuesOwner, true, false}, {domain.IssuesPublic, true, true}, | |
| 88 | + | } { | |
| 89 | + | t.Run(string(tc.mode), func(t *testing.T) { | |
| 90 | + | s := domain.RepoSettings{Issues: tc.mode} | |
| 91 | + | assert.Equal(t, tc.enabled, s.IssuesEnabled()) | |
| 92 | + | assert.Equal(t, tc.receipt, s.ShowsIssueSubmissionReceipt()) | |
| 93 | + | assert.Equal(t, tc.receipt, s.AcceptsAnonymousIssues()) | |
| 94 | + | }) | |
| 95 | + | } | |
| 96 | + | } | |
A internal/domain/revision.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import "strings" | |
| 6 | + | ||
| 7 | + | // RevisionCandidateKind identifies the lookup needed to resolve a revision. | |
| 8 | + | type RevisionCandidateKind uint8 | |
| 9 | + | ||
| 10 | + | const ( | |
| 11 | + | RevisionCandidateHead RevisionCandidateKind = iota | |
| 12 | + | RevisionCandidateObject | |
| 13 | + | RevisionCandidateRef | |
| 14 | + | RevisionCandidateAbbrevID | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // RevisionCandidate is one lookup to try when resolving a revision. | |
| 18 | + | // Name is HEAD, an object id, a full ref name, or an abbreviated object id. | |
| 19 | + | type RevisionCandidate struct { | |
| 20 | + | Kind RevisionCandidateKind | |
| 21 | + | Name string | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // Candidates returns the resolution order. HEAD is special; full object ids | |
| 25 | + | // are objects before refs, so a ref cannot redirect an existing object link. | |
| 26 | + | // Otherwise branches beat tags. Full ref names and abbreviated ids are tried | |
| 27 | + | // as written only after the branch and tag forms. | |
| 28 | + | func (r Revision) Candidates() []RevisionCandidate { | |
| 29 | + | s := r.String() | |
| 30 | + | if r.IsHead() || s == "HEAD" { | |
| 31 | + | return []RevisionCandidate{{Kind: RevisionCandidateHead, Name: "HEAD"}} | |
| 32 | + | } | |
| 33 | + | refs := []RevisionCandidate{ | |
| 34 | + | {Kind: RevisionCandidateRef, Name: "refs/heads/" + s}, | |
| 35 | + | {Kind: RevisionCandidateRef, Name: "refs/tags/" + s}, | |
| 36 | + | } | |
| 37 | + | switch { | |
| 38 | + | case r.IsObjectID(): | |
| 39 | + | return append([]RevisionCandidate{{Kind: RevisionCandidateObject, Name: s}}, refs...) | |
| 40 | + | case strings.HasPrefix(s, "refs/"): | |
| 41 | + | return append(refs, RevisionCandidate{Kind: RevisionCandidateRef, Name: s}) | |
| 42 | + | case r.IsAbbrevID(): | |
| 43 | + | return append(refs, RevisionCandidate{Kind: RevisionCandidateAbbrevID, Name: s}) | |
| 44 | + | } | |
| 45 | + | return refs | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // ResolvedRef returns the full ref name for a ref candidate, or empty otherwise. | |
| 49 | + | func (c RevisionCandidate) ResolvedRef() string { | |
| 50 | + | if c.Kind == RevisionCandidateRef { | |
| 51 | + | return c.Name | |
| 52 | + | } | |
| 53 | + | return "" | |
| 54 | + | } | |
A internal/domain/revision_test.go +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestRevisionCandidates(t *testing.T) { | |
| 16 | + | full := strings.Repeat("a", 40) | |
| 17 | + | ref := func(s string) domain.RevisionCandidate { | |
| 18 | + | return domain.RevisionCandidate{Kind: domain.RevisionCandidateRef, Name: s} | |
| 19 | + | } | |
| 20 | + | branches := func(s string) []domain.RevisionCandidate { | |
| 21 | + | return []domain.RevisionCandidate{ref("refs/heads/" + s), ref("refs/tags/" + s)} | |
| 22 | + | } | |
| 23 | + | for _, tt := range []struct { | |
| 24 | + | name, text string | |
| 25 | + | want []domain.RevisionCandidate | |
| 26 | + | }{ | |
| 27 | + | {"zero HEAD", "", []domain.RevisionCandidate{{Kind: domain.RevisionCandidateHead, Name: "HEAD"}}}, | |
| 28 | + | {"explicit HEAD", "HEAD", []domain.RevisionCandidate{{Kind: domain.RevisionCandidateHead, Name: "HEAD"}}}, | |
| 29 | + | {"full object first", full, append([]domain.RevisionCandidate{{Kind: domain.RevisionCandidateObject, Name: full}}, branches(full)...)}, | |
| 30 | + | {"full branch ref last", "refs/heads/main", append(branches("refs/heads/main"), ref("refs/heads/main"))}, | |
| 31 | + | {"full tag ref last", "refs/tags/v1", append(branches("refs/tags/v1"), ref("refs/tags/v1"))}, | |
| 32 | + | {"other full ref last", "refs/custom/x", append(branches("refs/custom/x"), ref("refs/custom/x"))}, | |
| 33 | + | {"minimum abbreviation last", "abc1234", append(branches("abc1234"), domain.RevisionCandidate{Kind: domain.RevisionCandidateAbbrevID, Name: "abc1234"})}, | |
| 34 | + | {"maximum abbreviation last", full[:39], append(branches(full[:39]), domain.RevisionCandidate{Kind: domain.RevisionCandidateAbbrevID, Name: full[:39]})}, | |
| 35 | + | {"short hex is ref", "abc123", branches("abc123")}, | |
| 36 | + | {"uppercase hex is ref", "ABC1234", branches("ABC1234")}, | |
| 37 | + | {"ordinary name", "main", branches("main")}, | |
| 38 | + | {"slash name", "feature/x", branches("feature/x")}, | |
| 39 | + | } { | |
| 40 | + | t.Run(tt.name, func(t *testing.T) { | |
| 41 | + | r, err := domain.ParseRevision(tt.text) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | assert.Equal(t, tt.want, r.Candidates()) | |
| 44 | + | }) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestRevisionCandidateResolvedRef(t *testing.T) { | |
| 49 | + | for _, tc := range []struct { | |
| 50 | + | kind domain.RevisionCandidateKind | |
| 51 | + | want string | |
| 52 | + | }{ | |
| 53 | + | {domain.RevisionCandidateHead, ""}, {domain.RevisionCandidateObject, ""}, | |
| 54 | + | {domain.RevisionCandidateRef, "refs/heads/main"}, {domain.RevisionCandidateAbbrevID, ""}, | |
| 55 | + | } { | |
| 56 | + | assert.Equal(t, tc.want, (domain.RevisionCandidate{Kind: tc.kind, Name: "refs/heads/main"}).ResolvedRef()) | |
| 57 | + | } | |
| 58 | + | } | |
M internal/ports/ci.go +41 -15
| @@ -407,21 +407,47 @@ type PushObjects struct { | |||
| 407 | 407 | Alternates string | |
| 408 | 408 | } | |
| 409 | 409 | ||
| 410 | - | // WorkflowGuard finds the changes of a push to the workflow files of a | |
| 411 | - | // repository. | |
| 412 | - | type WorkflowGuard interface { | |
| 413 | - | // ChangedWorkflow returns the first path under .github/workflows/ that the | |
| 414 | - | // updates change, "" for none. Two checks, as GitHub judges the token of a | |
| 415 | - | // job: (1) every commit the push brings (those no ref has yet) is compared | |
| 416 | - | // with each of its parents (a root commit with nothing) and is a change when | |
| 417 | - | // it has workflows that differ from every parent's; (2) the workflows a ref | |
| 418 | - | // ends with must be its old ones or none for an update, and the default | |
| 419 | - | // branch head's or none for a new ref (none when there is no head). A | |
| 420 | - | // workflows directory that disappears is no change, and a deleted ref changes | |
| 421 | - | // nothing. objects are those of the push in progress; a directory that is not | |
| 422 | - | // inside the repository is refused. A push beyond the bounds of the check | |
| 423 | - | // (commits, parents) is an error that matches domain.ErrInvalid. | |
| 424 | - | ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects) (path string, err error) | |
| 410 | + | // PushTreeReader reads, for a push still in quarantine, the objects the | |
| 411 | + | // workflow policy (domain.WorkflowChangeOf) judges. It knows nothing of the | |
| 412 | + | // policy: it peels, classifies, walks and compares trees. | |
| 413 | + | type PushTreeReader interface { | |
| 414 | + | // PushSubtrees reads what a push brings, and the entry at path (a | |
| 415 | + | // directory path without leading or trailing slash, e.g. | |
| 416 | + | // domain.WorkflowDir) in each object concerned. | |
| 417 | + | // | |
| 418 | + | // Updates that delete (New == ZeroOID) have no objects: they are skipped. | |
| 419 | + | // For every other update, Old (unless ZeroOID) and New are peeled | |
| 420 | + | // (`<oid>^{}`): the result is in Ends keyed by the oid as given. An end | |
| 421 | + | // that is missing, malformed, or peels to neither commit, tree nor blob is | |
| 422 | + | // an error matching domain.ErrInvalid. | |
| 423 | + | // | |
| 424 | + | // Commits is `rev-list --parents <tips> --not --all` where tips are the | |
| 425 | + | // distinct New ends that peel to a commit: the commits no ref has yet, | |
| 426 | + | // each with its parents, in rev-list order. More than bounds.Commits | |
| 427 | + | // commits, more than bounds.IDs ids (commits and parents, repeats | |
| 428 | + | // included) or a commit whose parent line exceeds the adapter's line | |
| 429 | + | // buffer is an error matching domain.ErrInvalid, raised as soon as the | |
| 430 | + | // line crossing the bound is read. | |
| 431 | + | // | |
| 432 | + | // Subtrees holds the entry at path for every end, every commit, every | |
| 433 | + | // parent, and, when a create has a non-blob New, the default branch head | |
| 434 | + | // (key domain.HeadKey, absent when HEAD is unborn or has no such entry). An object that peels to a blob, or an | |
| 435 | + | // object with no entry at path (including a path that crosses a | |
| 436 | + | // gitlink, symlink or file), has no key. Kind is EntryDirectory only for | |
| 437 | + | // mode 040000 and type tree. | |
| 438 | + | // | |
| 439 | + | // objects are the quarantine directories of the push; one outside the | |
| 440 | + | // repository's objects directory (symlinks resolved) is an error matching | |
| 441 | + | // domain.ErrInvalid, checked before anything is read. | |
| 442 | + | PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) | |
| 443 | + | ||
| 444 | + | // FirstDifferentEntry returns the name of the first top-level entry that | |
| 445 | + | // differs between the trees old and new (`diff-tree --name-only -z | |
| 446 | + | // --no-renames`, read as a stream up to the first name, then git is | |
| 447 | + | // stopped). A zero old is the empty tree. It returns "" when nothing | |
| 448 | + | // differs. A name longer than the adapter's path bound is an error | |
| 449 | + | // matching domain.ErrInvalid. objects as above. | |
| 450 | + | FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects PushObjects, old, new domain.ObjectID) (name string, err error) | |
| 425 | 451 | } | |
| 426 | 452 | ||
| 427 | 453 | // --- inbound --- | |
M internal/ports/dto.go +28 -0
| @@ -249,9 +249,27 @@ type IssueDetail struct { | |||
| 249 | 249 | Comments []domain.IssueComment | |
| 250 | 250 | } | |
| 251 | 251 | ||
| 252 | + | // CompareResult describes source commits absent from target, newest first. | |
| 253 | + | type CompareResult struct { | |
| 254 | + | Ahead []domain.Commit | |
| 255 | + | Behind bool | |
| 256 | + | Exact bool | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // PushSummary counts a bounded range; More means additional commits exist. | |
| 260 | + | type PushSummary struct { | |
| 261 | + | Count int | |
| 262 | + | More bool | |
| 263 | + | Rewritten bool | |
| 264 | + | } | |
| 265 | + | ||
| 252 | 266 | // MergeRequestDetail is a merge request with its review data. | |
| 253 | 267 | type MergeRequestDetail struct { | |
| 254 | 268 | MergeRequest domain.MergeRequest | |
| 269 | + | // NewCommits is the unread prefix of Commits; zero if the read head is absent. | |
| 270 | + | NewCommits int | |
| 271 | + | // HeadUnread reports whether the nonzero head differs from the shared read position. | |
| 272 | + | HeadUnread bool | |
| 255 | 273 | // Commits are the commits of the head that the target lacks, newest | |
| 256 | 274 | // first, at most MaxMergeRequestCommits. | |
| 257 | 275 | Commits []domain.Commit | |
| @@ -357,3 +375,13 @@ type InboxOverview struct { | |||
| 357 | 375 | // whatever the filter: they feed the filter itself. | |
| 358 | 376 | Repos []domain.RepoName | |
| 359 | 377 | } | |
| 378 | + | ||
| 379 | + | // History query limits. | |
| 380 | + | const ( | |
| 381 | + | // MaxMergeRequestCommits bounds the commits returned with a merge request. | |
| 382 | + | MaxMergeRequestCommits = 100 | |
| 383 | + | // MaxCompareCommits bounds a repository comparison. | |
| 384 | + | MaxCompareCommits = 500 | |
| 385 | + | // MaxPushSummaryCommits bounds the commits counted in a push summary. | |
| 386 | + | MaxPushSummaryCommits = 100 | |
| 387 | + | ) | |
M internal/ports/mocks/MergeRequestService.go +168 -0
| @@ -210,6 +210,90 @@ func (_c *MergeRequestService_Close_Call) RunAndReturn(run func(ctx context.Cont | |||
| 210 | 210 | return _c | |
| 211 | 211 | } | |
| 212 | 212 | ||
| 213 | + | // Compare provides a mock function for the type MergeRequestService | |
| 214 | + | func (_mock *MergeRequestService) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID) (ports.CompareResult, error) { | |
| 215 | + | ret := _mock.Called(ctx, p, repo, target, source) | |
| 216 | + | ||
| 217 | + | if len(ret) == 0 { | |
| 218 | + | panic("no return value specified for Compare") | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | var r0 ports.CompareResult | |
| 222 | + | var r1 error | |
| 223 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) (ports.CompareResult, error)); ok { | |
| 224 | + | return returnFunc(ctx, p, repo, target, source) | |
| 225 | + | } | |
| 226 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) ports.CompareResult); ok { | |
| 227 | + | r0 = returnFunc(ctx, p, repo, target, source) | |
| 228 | + | } else { | |
| 229 | + | r0 = ret.Get(0).(ports.CompareResult) | |
| 230 | + | } | |
| 231 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok { | |
| 232 | + | r1 = returnFunc(ctx, p, repo, target, source) | |
| 233 | + | } else { | |
| 234 | + | r1 = ret.Error(1) | |
| 235 | + | } | |
| 236 | + | return r0, r1 | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // MergeRequestService_Compare_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Compare' | |
| 240 | + | type MergeRequestService_Compare_Call struct { | |
| 241 | + | *mock.Call | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // Compare is a helper method to define mock.On call | |
| 245 | + | // - ctx context.Context | |
| 246 | + | // - p domain.Principal | |
| 247 | + | // - repo domain.RepoName | |
| 248 | + | // - target domain.ObjectID | |
| 249 | + | // - source domain.ObjectID | |
| 250 | + | func (_e *MergeRequestService_Expecter) Compare(ctx any, p any, repo any, target any, source any) *MergeRequestService_Compare_Call { | |
| 251 | + | return &MergeRequestService_Compare_Call{Call: _e.mock.On("Compare", ctx, p, repo, target, source)} | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (_c *MergeRequestService_Compare_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID)) *MergeRequestService_Compare_Call { | |
| 255 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 256 | + | var arg0 context.Context | |
| 257 | + | if args[0] != nil { | |
| 258 | + | arg0 = args[0].(context.Context) | |
| 259 | + | } | |
| 260 | + | var arg1 domain.Principal | |
| 261 | + | if args[1] != nil { | |
| 262 | + | arg1 = args[1].(domain.Principal) | |
| 263 | + | } | |
| 264 | + | var arg2 domain.RepoName | |
| 265 | + | if args[2] != nil { | |
| 266 | + | arg2 = args[2].(domain.RepoName) | |
| 267 | + | } | |
| 268 | + | var arg3 domain.ObjectID | |
| 269 | + | if args[3] != nil { | |
| 270 | + | arg3 = args[3].(domain.ObjectID) | |
| 271 | + | } | |
| 272 | + | var arg4 domain.ObjectID | |
| 273 | + | if args[4] != nil { | |
| 274 | + | arg4 = args[4].(domain.ObjectID) | |
| 275 | + | } | |
| 276 | + | run( | |
| 277 | + | arg0, | |
| 278 | + | arg1, | |
| 279 | + | arg2, | |
| 280 | + | arg3, | |
| 281 | + | arg4, | |
| 282 | + | ) | |
| 283 | + | }) | |
| 284 | + | return _c | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func (_c *MergeRequestService_Compare_Call) Return(compareResult ports.CompareResult, err error) *MergeRequestService_Compare_Call { | |
| 288 | + | _c.Call.Return(compareResult, err) | |
| 289 | + | return _c | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func (_c *MergeRequestService_Compare_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, target domain.ObjectID, source domain.ObjectID) (ports.CompareResult, error)) *MergeRequestService_Compare_Call { | |
| 293 | + | _c.Call.Return(run) | |
| 294 | + | return _c | |
| 295 | + | } | |
| 296 | + | ||
| 213 | 297 | // CountOpen provides a mock function for the type MergeRequestService | |
| 214 | 298 | func (_mock *MergeRequestService) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) { | |
| 215 | 299 | ret := _mock.Called(ctx, p, name) | |
| @@ -1020,6 +1104,90 @@ func (_c *MergeRequestService_Mergeability_Call) RunAndReturn(run func(ctx conte | |||
| 1020 | 1104 | return _c | |
| 1021 | 1105 | } | |
| 1022 | 1106 | ||
| 1107 | + | // PushSummary provides a mock function for the type MergeRequestService | |
| 1108 | + | func (_mock *MergeRequestService) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID) (ports.PushSummary, error) { | |
| 1109 | + | ret := _mock.Called(ctx, p, repo, oldHead, newTip) | |
| 1110 | + | ||
| 1111 | + | if len(ret) == 0 { | |
| 1112 | + | panic("no return value specified for PushSummary") | |
| 1113 | + | } | |
| 1114 | + | ||
| 1115 | + | var r0 ports.PushSummary | |
| 1116 | + | var r1 error | |
| 1117 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) (ports.PushSummary, error)); ok { | |
| 1118 | + | return returnFunc(ctx, p, repo, oldHead, newTip) | |
| 1119 | + | } | |
| 1120 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) ports.PushSummary); ok { | |
| 1121 | + | r0 = returnFunc(ctx, p, repo, oldHead, newTip) | |
| 1122 | + | } else { | |
| 1123 | + | r0 = ret.Get(0).(ports.PushSummary) | |
| 1124 | + | } | |
| 1125 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, domain.ObjectID, domain.ObjectID) error); ok { | |
| 1126 | + | r1 = returnFunc(ctx, p, repo, oldHead, newTip) | |
| 1127 | + | } else { | |
| 1128 | + | r1 = ret.Error(1) | |
| 1129 | + | } | |
| 1130 | + | return r0, r1 | |
| 1131 | + | } | |
| 1132 | + | ||
| 1133 | + | // MergeRequestService_PushSummary_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushSummary' | |
| 1134 | + | type MergeRequestService_PushSummary_Call struct { | |
| 1135 | + | *mock.Call | |
| 1136 | + | } | |
| 1137 | + | ||
| 1138 | + | // PushSummary is a helper method to define mock.On call | |
| 1139 | + | // - ctx context.Context | |
| 1140 | + | // - p domain.Principal | |
| 1141 | + | // - repo domain.RepoName | |
| 1142 | + | // - oldHead domain.ObjectID | |
| 1143 | + | // - newTip domain.ObjectID | |
| 1144 | + | func (_e *MergeRequestService_Expecter) PushSummary(ctx any, p any, repo any, oldHead any, newTip any) *MergeRequestService_PushSummary_Call { | |
| 1145 | + | return &MergeRequestService_PushSummary_Call{Call: _e.mock.On("PushSummary", ctx, p, repo, oldHead, newTip)} | |
| 1146 | + | } | |
| 1147 | + | ||
| 1148 | + | func (_c *MergeRequestService_PushSummary_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID)) *MergeRequestService_PushSummary_Call { | |
| 1149 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1150 | + | var arg0 context.Context | |
| 1151 | + | if args[0] != nil { | |
| 1152 | + | arg0 = args[0].(context.Context) | |
| 1153 | + | } | |
| 1154 | + | var arg1 domain.Principal | |
| 1155 | + | if args[1] != nil { | |
| 1156 | + | arg1 = args[1].(domain.Principal) | |
| 1157 | + | } | |
| 1158 | + | var arg2 domain.RepoName | |
| 1159 | + | if args[2] != nil { | |
| 1160 | + | arg2 = args[2].(domain.RepoName) | |
| 1161 | + | } | |
| 1162 | + | var arg3 domain.ObjectID | |
| 1163 | + | if args[3] != nil { | |
| 1164 | + | arg3 = args[3].(domain.ObjectID) | |
| 1165 | + | } | |
| 1166 | + | var arg4 domain.ObjectID | |
| 1167 | + | if args[4] != nil { | |
| 1168 | + | arg4 = args[4].(domain.ObjectID) | |
| 1169 | + | } | |
| 1170 | + | run( | |
| 1171 | + | arg0, | |
| 1172 | + | arg1, | |
| 1173 | + | arg2, | |
| 1174 | + | arg3, | |
| 1175 | + | arg4, | |
| 1176 | + | ) | |
| 1177 | + | }) | |
| 1178 | + | return _c | |
| 1179 | + | } | |
| 1180 | + | ||
| 1181 | + | func (_c *MergeRequestService_PushSummary_Call) Return(pushSummary ports.PushSummary, err error) *MergeRequestService_PushSummary_Call { | |
| 1182 | + | _c.Call.Return(pushSummary, err) | |
| 1183 | + | return _c | |
| 1184 | + | } | |
| 1185 | + | ||
| 1186 | + | func (_c *MergeRequestService_PushSummary_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead domain.ObjectID, newTip domain.ObjectID) (ports.PushSummary, error)) *MergeRequestService_PushSummary_Call { | |
| 1187 | + | _c.Call.Return(run) | |
| 1188 | + | return _c | |
| 1189 | + | } | |
| 1190 | + | ||
| 1023 | 1191 | // ResolveComment provides a mock function for the type MergeRequestService | |
| 1024 | 1192 | func (_mock *MergeRequestService) ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, commentID int64, resolved bool) (domain.Comment, error) { | |
| 1025 | 1193 | ret := _mock.Called(ctx, p, name, number, commentID, resolved) | |
M internal/ports/mocks/OIDCKeyService.go +44 -0
| @@ -48,6 +48,50 @@ func (_m *OIDCKeyService) EXPECT() *OIDCKeyService_Expecter { | |||
| 48 | 48 | return &OIDCKeyService_Expecter{mock: &_m.Mock} | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | + | // Enabled provides a mock function for the type OIDCKeyService | |
| 52 | + | func (_mock *OIDCKeyService) Enabled() bool { | |
| 53 | + | ret := _mock.Called() | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for Enabled") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 bool | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func() bool); ok { | |
| 61 | + | r0 = returnFunc() | |
| 62 | + | } else { | |
| 63 | + | r0 = ret.Get(0).(bool) | |
| 64 | + | } | |
| 65 | + | return r0 | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // OIDCKeyService_Enabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Enabled' | |
| 69 | + | type OIDCKeyService_Enabled_Call struct { | |
| 70 | + | *mock.Call | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Enabled is a helper method to define mock.On call | |
| 74 | + | func (_e *OIDCKeyService_Expecter) Enabled() *OIDCKeyService_Enabled_Call { | |
| 75 | + | return &OIDCKeyService_Enabled_Call{Call: _e.mock.On("Enabled")} | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (_c *OIDCKeyService_Enabled_Call) Run(run func()) *OIDCKeyService_Enabled_Call { | |
| 79 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 80 | + | run() | |
| 81 | + | }) | |
| 82 | + | return _c | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func (_c *OIDCKeyService_Enabled_Call) Return(b bool) *OIDCKeyService_Enabled_Call { | |
| 86 | + | _c.Call.Return(b) | |
| 87 | + | return _c | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (_c *OIDCKeyService_Enabled_Call) RunAndReturn(run func() bool) *OIDCKeyService_Enabled_Call { | |
| 91 | + | _c.Call.Return(run) | |
| 92 | + | return _c | |
| 93 | + | } | |
| 94 | + | ||
| 51 | 95 | // RotateKey provides a mock function for the type OIDCKeyService | |
| 52 | 96 | func (_mock *OIDCKeyService) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) { | |
| 53 | 97 | ret := _mock.Called(ctx, p) | |
A internal/ports/mocks/PushTreeReader.go +223 -0
| @@ -0,0 +1,223 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewPushTreeReader creates a new instance of PushTreeReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewPushTreeReader(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *PushTreeReader { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &PushTreeReader{} | |
| 26 | + | mock.Mock.Test(t) | |
| 27 | + | ||
| 28 | + | t.Cleanup(func() { | |
| 29 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | + | helper.Helper() | |
| 31 | + | } | |
| 32 | + | mock.AssertExpectations(t) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | return mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // PushTreeReader is an autogenerated mock type for the PushTreeReader type | |
| 39 | + | type PushTreeReader struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type PushTreeReader_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *PushTreeReader) EXPECT() *PushTreeReader_Expecter { | |
| 48 | + | return &PushTreeReader_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // FirstDifferentEntry provides a mock function for the type PushTreeReader | |
| 52 | + | func (_mock *PushTreeReader) FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID) (string, error) { | |
| 53 | + | ret := _mock.Called(ctx, repo, objects, old, new) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for FirstDifferentEntry") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 string | |
| 60 | + | var r1 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) (string, error)); ok { | |
| 62 | + | return returnFunc(ctx, repo, objects, old, new) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) string); ok { | |
| 65 | + | r0 = returnFunc(ctx, repo, objects, old, new) | |
| 66 | + | } else { | |
| 67 | + | r0 = ret.Get(0).(string) | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) error); ok { | |
| 70 | + | r1 = returnFunc(ctx, repo, objects, old, new) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // PushTreeReader_FirstDifferentEntry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FirstDifferentEntry' | |
| 78 | + | type PushTreeReader_FirstDifferentEntry_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // FirstDifferentEntry is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | // - repo domain.RepoName | |
| 85 | + | // - objects ports.PushObjects | |
| 86 | + | // - old domain.ObjectID | |
| 87 | + | // - new domain.ObjectID | |
| 88 | + | func (_e *PushTreeReader_Expecter) FirstDifferentEntry(ctx any, repo any, objects any, old any, new any) *PushTreeReader_FirstDifferentEntry_Call { | |
| 89 | + | return &PushTreeReader_FirstDifferentEntry_Call{Call: _e.mock.On("FirstDifferentEntry", ctx, repo, objects, old, new)} | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (_c *PushTreeReader_FirstDifferentEntry_Call) Run(run func(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID)) *PushTreeReader_FirstDifferentEntry_Call { | |
| 93 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 94 | + | var arg0 context.Context | |
| 95 | + | if args[0] != nil { | |
| 96 | + | arg0 = args[0].(context.Context) | |
| 97 | + | } | |
| 98 | + | var arg1 domain.RepoName | |
| 99 | + | if args[1] != nil { | |
| 100 | + | arg1 = args[1].(domain.RepoName) | |
| 101 | + | } | |
| 102 | + | var arg2 ports.PushObjects | |
| 103 | + | if args[2] != nil { | |
| 104 | + | arg2 = args[2].(ports.PushObjects) | |
| 105 | + | } | |
| 106 | + | var arg3 domain.ObjectID | |
| 107 | + | if args[3] != nil { | |
| 108 | + | arg3 = args[3].(domain.ObjectID) | |
| 109 | + | } | |
| 110 | + | var arg4 domain.ObjectID | |
| 111 | + | if args[4] != nil { | |
| 112 | + | arg4 = args[4].(domain.ObjectID) | |
| 113 | + | } | |
| 114 | + | run( | |
| 115 | + | arg0, | |
| 116 | + | arg1, | |
| 117 | + | arg2, | |
| 118 | + | arg3, | |
| 119 | + | arg4, | |
| 120 | + | ) | |
| 121 | + | }) | |
| 122 | + | return _c | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func (_c *PushTreeReader_FirstDifferentEntry_Call) Return(name string, err error) *PushTreeReader_FirstDifferentEntry_Call { | |
| 126 | + | _c.Call.Return(name, err) | |
| 127 | + | return _c | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (_c *PushTreeReader_FirstDifferentEntry_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID) (string, error)) *PushTreeReader_FirstDifferentEntry_Call { | |
| 131 | + | _c.Call.Return(run) | |
| 132 | + | return _c | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // PushSubtrees provides a mock function for the type PushTreeReader | |
| 136 | + | func (_mock *PushTreeReader) PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) { | |
| 137 | + | ret := _mock.Called(ctx, repo, updates, objects, path, bounds) | |
| 138 | + | ||
| 139 | + | if len(ret) == 0 { | |
| 140 | + | panic("no return value specified for PushSubtrees") | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | var r0 domain.PushSubtrees | |
| 144 | + | var r1 error | |
| 145 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) (domain.PushSubtrees, error)); ok { | |
| 146 | + | return returnFunc(ctx, repo, updates, objects, path, bounds) | |
| 147 | + | } | |
| 148 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) domain.PushSubtrees); ok { | |
| 149 | + | r0 = returnFunc(ctx, repo, updates, objects, path, bounds) | |
| 150 | + | } else { | |
| 151 | + | r0 = ret.Get(0).(domain.PushSubtrees) | |
| 152 | + | } | |
| 153 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) error); ok { | |
| 154 | + | r1 = returnFunc(ctx, repo, updates, objects, path, bounds) | |
| 155 | + | } else { | |
| 156 | + | r1 = ret.Error(1) | |
| 157 | + | } | |
| 158 | + | return r0, r1 | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // PushTreeReader_PushSubtrees_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushSubtrees' | |
| 162 | + | type PushTreeReader_PushSubtrees_Call struct { | |
| 163 | + | *mock.Call | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // PushSubtrees is a helper method to define mock.On call | |
| 167 | + | // - ctx context.Context | |
| 168 | + | // - repo domain.RepoName | |
| 169 | + | // - updates []domain.RefUpdate | |
| 170 | + | // - objects ports.PushObjects | |
| 171 | + | // - path string | |
| 172 | + | // - bounds domain.PushWalkBounds | |
| 173 | + | func (_e *PushTreeReader_Expecter) PushSubtrees(ctx any, repo any, updates any, objects any, path any, bounds any) *PushTreeReader_PushSubtrees_Call { | |
| 174 | + | return &PushTreeReader_PushSubtrees_Call{Call: _e.mock.On("PushSubtrees", ctx, repo, updates, objects, path, bounds)} | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (_c *PushTreeReader_PushSubtrees_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds)) *PushTreeReader_PushSubtrees_Call { | |
| 178 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 179 | + | var arg0 context.Context | |
| 180 | + | if args[0] != nil { | |
| 181 | + | arg0 = args[0].(context.Context) | |
| 182 | + | } | |
| 183 | + | var arg1 domain.RepoName | |
| 184 | + | if args[1] != nil { | |
| 185 | + | arg1 = args[1].(domain.RepoName) | |
| 186 | + | } | |
| 187 | + | var arg2 []domain.RefUpdate | |
| 188 | + | if args[2] != nil { | |
| 189 | + | arg2 = args[2].([]domain.RefUpdate) | |
| 190 | + | } | |
| 191 | + | var arg3 ports.PushObjects | |
| 192 | + | if args[3] != nil { | |
| 193 | + | arg3 = args[3].(ports.PushObjects) | |
| 194 | + | } | |
| 195 | + | var arg4 string | |
| 196 | + | if args[4] != nil { | |
| 197 | + | arg4 = args[4].(string) | |
| 198 | + | } | |
| 199 | + | var arg5 domain.PushWalkBounds | |
| 200 | + | if args[5] != nil { | |
| 201 | + | arg5 = args[5].(domain.PushWalkBounds) | |
| 202 | + | } | |
| 203 | + | run( | |
| 204 | + | arg0, | |
| 205 | + | arg1, | |
| 206 | + | arg2, | |
| 207 | + | arg3, | |
| 208 | + | arg4, | |
| 209 | + | arg5, | |
| 210 | + | ) | |
| 211 | + | }) | |
| 212 | + | return _c | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func (_c *PushTreeReader_PushSubtrees_Call) Return(pushSubtrees domain.PushSubtrees, err error) *PushTreeReader_PushSubtrees_Call { | |
| 216 | + | _c.Call.Return(pushSubtrees, err) | |
| 217 | + | return _c | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func (_c *PushTreeReader_PushSubtrees_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error)) *PushTreeReader_PushSubtrees_Call { | |
| 221 | + | _c.Call.Return(run) | |
| 222 | + | return _c | |
| 223 | + | } | |
D internal/ports/mocks/WorkflowGuard.go +0 -127
| @@ -1,127 +0,0 @@ | |||
| 1 | - | // Code generated by mockery; DO NOT EDIT. | |
| 2 | - | // github.com/vektra/mockery | |
| 3 | - | // template: testify | |
| 4 | - | ||
| 5 | - | package mocks | |
| 6 | - | ||
| 7 | - | import ( | |
| 8 | - | "context" | |
| 9 | - | ||
| 10 | - | "bnema.dev/ermite/internal/domain" | |
| 11 | - | "bnema.dev/ermite/internal/ports" | |
| 12 | - | mock "github.com/stretchr/testify/mock" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | // NewWorkflowGuard creates a new instance of WorkflowGuard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | - | // The first argument is typically a *testing.T value. | |
| 17 | - | func NewWorkflowGuard(t interface { | |
| 18 | - | mock.TestingT | |
| 19 | - | Cleanup(func()) | |
| 20 | - | }) *WorkflowGuard { | |
| 21 | - | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | - | helper.Helper() | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | mock := &WorkflowGuard{} | |
| 26 | - | mock.Mock.Test(t) | |
| 27 | - | ||
| 28 | - | t.Cleanup(func() { | |
| 29 | - | if helper, ok := t.(interface{ Helper() }); ok { | |
| 30 | - | helper.Helper() | |
| 31 | - | } | |
| 32 | - | mock.AssertExpectations(t) | |
| 33 | - | }) | |
| 34 | - | ||
| 35 | - | return mock | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | // WorkflowGuard is an autogenerated mock type for the WorkflowGuard type | |
| 39 | - | type WorkflowGuard struct { | |
| 40 | - | mock.Mock | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | type WorkflowGuard_Expecter struct { | |
| 44 | - | mock *mock.Mock | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | func (_m *WorkflowGuard) EXPECT() *WorkflowGuard_Expecter { | |
| 48 | - | return &WorkflowGuard_Expecter{mock: &_m.Mock} | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | // ChangedWorkflow provides a mock function for the type WorkflowGuard | |
| 52 | - | func (_mock *WorkflowGuard) ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error) { | |
| 53 | - | ret := _mock.Called(ctx, repo, updates, objects) | |
| 54 | - | ||
| 55 | - | if len(ret) == 0 { | |
| 56 | - | panic("no return value specified for ChangedWorkflow") | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | var r0 string | |
| 60 | - | var r1 error | |
| 61 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) (string, error)); ok { | |
| 62 | - | return returnFunc(ctx, repo, updates, objects) | |
| 63 | - | } | |
| 64 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) string); ok { | |
| 65 | - | r0 = returnFunc(ctx, repo, updates, objects) | |
| 66 | - | } else { | |
| 67 | - | r0 = ret.Get(0).(string) | |
| 68 | - | } | |
| 69 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) error); ok { | |
| 70 | - | r1 = returnFunc(ctx, repo, updates, objects) | |
| 71 | - | } else { | |
| 72 | - | r1 = ret.Error(1) | |
| 73 | - | } | |
| 74 | - | return r0, r1 | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | // WorkflowGuard_ChangedWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChangedWorkflow' | |
| 78 | - | type WorkflowGuard_ChangedWorkflow_Call struct { | |
| 79 | - | *mock.Call | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | // ChangedWorkflow is a helper method to define mock.On call | |
| 83 | - | // - ctx context.Context | |
| 84 | - | // - repo domain.RepoName | |
| 85 | - | // - updates []domain.RefUpdate | |
| 86 | - | // - objects ports.PushObjects | |
| 87 | - | func (_e *WorkflowGuard_Expecter) ChangedWorkflow(ctx any, repo any, updates any, objects any) *WorkflowGuard_ChangedWorkflow_Call { | |
| 88 | - | return &WorkflowGuard_ChangedWorkflow_Call{Call: _e.mock.On("ChangedWorkflow", ctx, repo, updates, objects)} | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | func (_c *WorkflowGuard_ChangedWorkflow_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects)) *WorkflowGuard_ChangedWorkflow_Call { | |
| 92 | - | _c.Call.Run(func(args mock.Arguments) { | |
| 93 | - | var arg0 context.Context | |
| 94 | - | if args[0] != nil { | |
| 95 | - | arg0 = args[0].(context.Context) | |
| 96 | - | } | |
| 97 | - | var arg1 domain.RepoName | |
| 98 | - | if args[1] != nil { | |
| 99 | - | arg1 = args[1].(domain.RepoName) | |
| 100 | - | } | |
| 101 | - | var arg2 []domain.RefUpdate | |
| 102 | - | if args[2] != nil { | |
| 103 | - | arg2 = args[2].([]domain.RefUpdate) | |
| 104 | - | } | |
| 105 | - | var arg3 ports.PushObjects | |
| 106 | - | if args[3] != nil { | |
| 107 | - | arg3 = args[3].(ports.PushObjects) | |
| 108 | - | } | |
| 109 | - | run( | |
| 110 | - | arg0, | |
| 111 | - | arg1, | |
| 112 | - | arg2, | |
| 113 | - | arg3, | |
| 114 | - | ) | |
| 115 | - | }) | |
| 116 | - | return _c | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | func (_c *WorkflowGuard_ChangedWorkflow_Call) Return(path string, err error) *WorkflowGuard_ChangedWorkflow_Call { | |
| 120 | - | _c.Call.Return(path, err) | |
| 121 | - | return _c | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | func (_c *WorkflowGuard_ChangedWorkflow_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error)) *WorkflowGuard_ChangedWorkflow_Call { | |
| 125 | - | _c.Call.Return(run) | |
| 126 | - | return _c | |
| 127 | - | } | |
M internal/ports/oidc.go +2 -0
| @@ -53,6 +53,8 @@ type OIDCRotation struct { | |||
| 53 | 53 | // OIDCKeyService manages the signing key of the ID tokens. *usecase.OIDC | |
| 54 | 54 | // implements it. | |
| 55 | 55 | type OIDCKeyService interface { | |
| 56 | + | // Enabled reports whether the server issues ID tokens. | |
| 57 | + | Enabled() bool | |
| 56 | 58 | // RotateKey replaces the signing key by a new one. It needs owner rights. | |
| 57 | 59 | RotateKey(ctx context.Context, p domain.Principal) (OIDCRotation, error) | |
| 58 | 60 | } | |
M internal/ports/services.go +4 -0
| @@ -109,6 +109,10 @@ type EventService interface { | |||
| 109 | 109 | // MergeRequestService is the merge request use cases. *usecase.MergeRequests | |
| 110 | 110 | // implements it. | |
| 111 | 111 | type MergeRequestService interface { | |
| 112 | + | // Compare queries repository history; it stays here because this service owns the merge-git port. | |
| 113 | + | Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (CompareResult, error) | |
| 114 | + | // PushSummary queries repository history; it stays here because this service owns the merge-git port. | |
| 115 | + | PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (PushSummary, error) | |
| 112 | 116 | Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error) | |
| 113 | 117 | List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error) | |
| 114 | 118 | Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error) | |
M internal/usecase/ci.go +1 -1
| @@ -106,7 +106,7 @@ type CIConfig struct { | |||
| 106 | 106 | // read when an action of another repository is resolved for the run. Nil | |
| 107 | 107 | // reads nothing for a token (fail closed). | |
| 108 | 108 | Tokens ports.TokenStore | |
| 109 | - | // OIDC signs the ID tokens of the jobs (CI-4); nil issues none: jobs get no | |
| 109 | + | // OIDC signs the ID tokens of the jobs (CI-4); nil or disabled issues none: jobs get no | |
| 110 | 110 | // token endpoint, and the provider is not served. | |
| 111 | 111 | OIDC *OIDC | |
| 112 | 112 | // Settings resolves the variables and secrets of a job (CI-3). Nil gives | |
A internal/usecase/ci_creation_policy_test.go +48 -0
| @@ -0,0 +1,48 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestCreateRunWorkflowPolicy(t *testing.T) { | |
| 18 | + | repo, err := domain.ParseRepoName("project") | |
| 19 | + | require.NoError(t, err) | |
| 20 | + | failure := errors.New("state unavailable") | |
| 21 | + | for _, tc := range []struct { | |
| 22 | + | name string | |
| 23 | + | off []string | |
| 24 | + | stateErr, want error | |
| 25 | + | }{ | |
| 26 | + | {name: "enabled"}, | |
| 27 | + | {name: "other disabled", off: []string{"other.yml"}}, | |
| 28 | + | {name: "disabled", off: []string{"ci.yml"}, want: domain.ErrWorkflowDisabled}, | |
| 29 | + | {name: "state failure", stateErr: failure, want: failure}, | |
| 30 | + | } { | |
| 31 | + | t.Run(tc.name, func(t *testing.T) { | |
| 32 | + | state := mocks.NewCIWorkflowStore(t) | |
| 33 | + | store := mocks.NewCIStore(t) | |
| 34 | + | state.EXPECT().DisabledWorkflows(context.Background(), repo).Return(tc.off, tc.stateErr).Once() | |
| 35 | + | c := &CI{cfg: CIConfig{Store: store, WorkflowState: state}} | |
| 36 | + | build := func(id, number int64) ports.CIRunDraft { return ports.CIRunDraft{} } | |
| 37 | + | if tc.want == nil { | |
| 38 | + | store.EXPECT().CreateRun(context.Background(), repo, "ci.yml", mock.Anything).Return(ports.CIRunDraft{}, nil).Once() | |
| 39 | + | } | |
| 40 | + | _, err := c.createRun(context.Background(), pushFacts{}, repo, "ci.yml", build) | |
| 41 | + | if tc.want == nil { | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | } else { | |
| 44 | + | require.ErrorIs(t, err, tc.want) | |
| 45 | + | } | |
| 46 | + | }) | |
| 47 | + | } | |
| 48 | + | } | |
M internal/usecase/ci_oidc.go +3 -9
| @@ -31,7 +31,7 @@ var _ ports.OIDCService = (*CI)(nil) | |||
| 31 | 31 | // server issues none. j must be assigned (its AssignedAt is set). | |
| 32 | 32 | func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string { | |
| 33 | 33 | c := rs.c | |
| 34 | - | if c.cfg.OIDC == nil || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) { | |
| 34 | + | if !c.cfg.OIDC.Enabled() || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) { | |
| 35 | 35 | return "" | |
| 36 | 36 | } | |
| 37 | 37 | now := c.now() | |
| @@ -48,21 +48,15 @@ func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string { | |||
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | 50 | // OIDCEnabled reports whether the server issues ID tokens. | |
| 51 | - | func (c *CI) OIDCEnabled() bool { return c.cfg.OIDC != nil } | |
| 51 | + | func (c *CI) OIDCEnabled() bool { return c.cfg.OIDC.Enabled() } | |
| 52 | 52 | ||
| 53 | 53 | // OIDCDiscovery returns the OpenID Provider configuration. | |
| 54 | 54 | func (c *CI) OIDCDiscovery() ([]byte, error) { | |
| 55 | - | if c.cfg.OIDC == nil { | |
| 56 | - | return nil, domain.ErrNotFound | |
| 57 | - | } | |
| 58 | 55 | return c.cfg.OIDC.Discovery() | |
| 59 | 56 | } | |
| 60 | 57 | ||
| 61 | 58 | // OIDCKeys returns the JWKS. | |
| 62 | 59 | func (c *CI) OIDCKeys() ([]byte, error) { | |
| 63 | - | if c.cfg.OIDC == nil { | |
| 64 | - | return nil, domain.ErrNotFound | |
| 65 | - | } | |
| 66 | 60 | return c.cfg.OIDC.JWKS() | |
| 67 | 61 | } | |
| 68 | 62 | ||
| @@ -73,7 +67,7 @@ func (c *CI) OIDCKeys() ([]byte, error) { | |||
| 73 | 67 | // "https://github.com/<owner>". | |
| 74 | 68 | func (c *CI) IDToken(ctx context.Context, secret, audience string) (string, error) { | |
| 75 | 69 | o := c.cfg.OIDC | |
| 76 | - | if o == nil { | |
| 70 | + | if !o.Enabled() { | |
| 77 | 71 | return "", domain.ErrNotFound | |
| 78 | 72 | } | |
| 79 | 73 | if len(audience) > domain.MaxOIDCAudienceLen { | |
M internal/usecase/ci_pull.go +9 -1
| @@ -681,11 +681,19 @@ func (c *CI) whileOpen(ctx context.Context, repo domain.RepoName, number int64, | |||
| 681 | 681 | // createRun stores a run. The run of an open merge request is stored under the | |
| 682 | 682 | // gate, so that one that is closed meanwhile gets none (errPullClosed). | |
| 683 | 683 | func (c *CI) createRun(ctx context.Context, f pushFacts, repo domain.RepoName, wfPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) { | |
| 684 | + | off, err := c.workflowDisabled(ctx, repo, wfPath) | |
| 685 | + | if err != nil { | |
| 686 | + | return ports.CIRunDraft{}, err | |
| 687 | + | } | |
| 688 | + | if off { | |
| 689 | + | return ports.CIRunDraft{}, domain.ErrWorkflowDisabled | |
| 690 | + | } | |
| 691 | + | ||
| 684 | 692 | if f.pull == nil || f.pull.action == domain.PullClosed { | |
| 685 | 693 | return c.cfg.Store.CreateRun(ctx, repo, wfPath, build) | |
| 686 | 694 | } | |
| 687 | 695 | var d ports.CIRunDraft | |
| 688 | - | err := c.whileOpen(ctx, repo, f.pull.mr.Number, func() error { | |
| 696 | + | err = c.whileOpen(ctx, repo, f.pull.mr.Number, func() error { | |
| 689 | 697 | var err error | |
| 690 | 698 | d, err = c.cfg.Store.CreateRun(ctx, repo, wfPath, build) | |
| 691 | 699 | return err | |
M internal/usecase/ci_runner.go +1 -1
| @@ -281,7 +281,7 @@ func (c *CI) runnerJob(rs *runState, j domain.CIJob, sec jobSecrets, settings po | |||
| 281 | 281 | Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted), | |
| 282 | 282 | Cache: c.cacheScope(rs.run, rs.src.GithubJSON), | |
| 283 | 283 | } | |
| 284 | - | if sec.idToken != "" && c.cfg.OIDC != nil { | |
| 284 | + | if sec.idToken != "" && c.cfg.OIDC.Enabled() { | |
| 285 | 285 | out.IDTokenURL, out.IDTokenRequestToken = c.cfg.OIDC.TokenURL(), sec.idToken | |
| 286 | 286 | } | |
| 287 | 287 | return out | |
M internal/usecase/events.go +2 -2
| @@ -573,7 +573,7 @@ func (e *Events) deliverOwnerOnly(ctx context.Context, w *watch, name domain.Rep | |||
| 573 | 573 | case err != nil: | |
| 574 | 574 | return fmt.Errorf("check access to %q: %w", name, err) | |
| 575 | 575 | } | |
| 576 | - | if repo.Settings.Issues.OrDefault() == domain.IssuesOff { | |
| 576 | + | if !repo.Settings.IssuesEnabled() { | |
| 577 | 577 | return nil | |
| 578 | 578 | } | |
| 579 | 579 | return send(out) | |
| @@ -613,7 +613,7 @@ func (e *Events) deliverRepo(ctx context.Context, w *watch, name domain.RepoName | |||
| 613 | 613 | if !canRead(w.p, repo) { | |
| 614 | 614 | return nil | |
| 615 | 615 | } | |
| 616 | - | if isIssueEvent(be.ev) && repo.Settings.Issues.OrDefault() == domain.IssuesOff { | |
| 616 | + | if isIssueEvent(be.ev) && !repo.Settings.IssuesEnabled() { | |
| 617 | 617 | return nil // issues were turned off since: a replay must not show them | |
| 618 | 618 | } | |
| 619 | 619 | if withRepo { | |
M internal/usecase/inbox.go +1 -1
| @@ -74,7 +74,7 @@ func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain | |||
| 74 | 74 | return fmt.Errorf("read repository %q: %w", name, err) | |
| 75 | 75 | default: | |
| 76 | 76 | exists[name] = true | |
| 77 | - | issuesOn[name] = repo.Settings.Issues.OrDefault() != domain.IssuesOff | |
| 77 | + | issuesOn[name] = repo.Settings.IssuesEnabled() | |
| 78 | 78 | } | |
| 79 | 79 | return nil | |
| 80 | 80 | } | |
M internal/usecase/issue_moderation.go +1 -1
| @@ -71,7 +71,7 @@ func (m *IssueModeration) Approve(ctx context.Context, p domain.Principal, repo | |||
| 71 | 71 | if err != nil { | |
| 72 | 72 | return domain.Issue{}, err | |
| 73 | 73 | } | |
| 74 | - | if r.Settings.Issues.OrDefault() == domain.IssuesOff { | |
| 74 | + | if !r.Settings.IssuesEnabled() { | |
| 75 | 75 | return domain.Issue{}, domain.ErrNotFound | |
| 76 | 76 | } | |
| 77 | 77 | now := m.clock.Now().UTC() | |
M internal/usecase/issue_submissions.go +1 -1
| @@ -162,7 +162,7 @@ func (s *IssueSubmissions) openRepo(ctx context.Context, repo domain.RepoName) e | |||
| 162 | 162 | } | |
| 163 | 163 | return err | |
| 164 | 164 | } | |
| 165 | - | if !canRead(domain.Anonymous(), r) || r.Settings.Issues.OrDefault() != domain.IssuesPublic { | |
| 165 | + | if !canRead(domain.Anonymous(), r) || !r.Settings.AcceptsAnonymousIssues() { | |
| 166 | 166 | return domain.ErrNotFound | |
| 167 | 167 | } | |
| 168 | 168 | return nil | |
M internal/usecase/issues.go +1 -1
| @@ -59,7 +59,7 @@ func (i *Issues) enabled(ctx context.Context, name domain.RepoName) (domain.Repo | |||
| 59 | 59 | if err != nil { | |
| 60 | 60 | return domain.Repo{}, err | |
| 61 | 61 | } | |
| 62 | - | if repo.Settings.Issues.OrDefault() == domain.IssuesOff { | |
| 62 | + | if !repo.Settings.IssuesEnabled() { | |
| 63 | 63 | return domain.Repo{}, domain.ErrNotFound | |
| 64 | 64 | } | |
| 65 | 65 | return repo, nil | |
M internal/usecase/mergerequests.go +57 -8
| @@ -21,7 +21,7 @@ import ( | |||
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 23 | // MaxMergeRequestCommits is the most commits GetMergeRequest returns. | |
| 24 | - | const MaxMergeRequestCommits = 100 | |
| 24 | + | const MaxMergeRequestCommits = ports.MaxMergeRequestCommits | |
| 25 | 25 | ||
| 26 | 26 | // maxWriteRetries bounds the retries of a write that loses a race with another | |
| 27 | 27 | // write of the same merge request. Each lost race means another writer won, so | |
| @@ -546,6 +546,7 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||
| 546 | 546 | if d.LastReadID, err = m.store.GetRead(ctx, name, number); err != nil { | |
| 547 | 547 | return ports.MergeRequestDetail{}, err | |
| 548 | 548 | } | |
| 549 | + | d.HeadUnread = !mr.HeadID.IsZero() && mr.HeadID != d.LastReadID | |
| 549 | 550 | ||
| 550 | 551 | exclude, base, err := m.rangeOf(ctx, mr) | |
| 551 | 552 | if err != nil { | |
| @@ -574,6 +575,14 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||
| 574 | 575 | return ports.MergeRequestDetail{}, err | |
| 575 | 576 | } | |
| 576 | 577 | verifyCommits(ctx, m.verifier, name, d.Commits) | |
| 578 | + | if !d.LastReadID.IsZero() { | |
| 579 | + | for i, c := range d.Commits { | |
| 580 | + | if c.ID == d.LastReadID { | |
| 581 | + | d.NewCommits = i | |
| 582 | + | break | |
| 583 | + | } | |
| 584 | + | } | |
| 585 | + | } | |
| 577 | 586 | } | |
| 578 | 587 | if opts.SinceLastRead && !d.LastReadID.IsZero() { | |
| 579 | 588 | switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); { | |
| @@ -993,8 +1002,7 @@ func (m *MergeRequests) checkMode(ctx context.Context, mr domain.MergeRequest, m | |||
| 993 | 1002 | } | |
| 994 | 1003 | ||
| 995 | 1004 | func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (domain.MergeRequest, error) { | |
| 996 | - | repo, err := m.writable(ctx, p, name, domain.ScopeMRMerge) | |
| 997 | - | if err != nil { | |
| 1005 | + | if _, err := m.writable(ctx, p, name, domain.ScopeMRMerge); err != nil { | |
| 998 | 1006 | return domain.MergeRequest{}, err | |
| 999 | 1007 | } | |
| 1000 | 1008 | mode := params.Mode | |
| @@ -1039,17 +1047,14 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma | |||
| 1039 | 1047 | return domain.MergeRequest{}, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number) | |
| 1040 | 1048 | } | |
| 1041 | 1049 | // The settings are read again under the lock: a change applies at once. | |
| 1042 | - | repo, err = m.meta.Get(ctx, name) | |
| 1050 | + | repo, err := m.meta.Get(ctx, name) | |
| 1043 | 1051 | if err != nil { | |
| 1044 | 1052 | if errors.Is(err, domain.ErrNotFound) { | |
| 1045 | 1053 | return domain.MergeRequest{}, err // deleted meanwhile | |
| 1046 | 1054 | } | |
| 1047 | 1055 | return domain.MergeRequest{}, fmt.Errorf("get repository %q: %w", name, err) | |
| 1048 | 1056 | } | |
| 1049 | - | settings := repo.Settings | |
| 1050 | - | if settings.IsZero() { | |
| 1051 | - | settings = domain.DefaultRepoSettings() | |
| 1052 | - | } | |
| 1057 | + | settings := repo.EffectiveSettings() | |
| 1053 | 1058 | if !settings.Allows(mode) { | |
| 1054 | 1059 | return domain.MergeRequest{}, fmt.Errorf("%w: the %s merge mode is not allowed in %s", domain.ErrPreconditionFailed, mode, name) | |
| 1055 | 1060 | } | |
| @@ -1438,3 +1443,47 @@ func (m *MergeRequests) replyRoot(ctx context.Context, name domain.RepoName, num | |||
| 1438 | 1443 | } | |
| 1439 | 1444 | return root, nil | |
| 1440 | 1445 | } | |
| 1446 | + | ||
| 1447 | + | // Compare lists at most ports.MaxCompareCommits source commits absent from target. Unlike bounded | |
| 1448 | + | // history intersection, ancestry remains accurate even for deep histories. | |
| 1449 | + | func (m *MergeRequests) Compare(ctx context.Context, p domain.Principal, repo domain.RepoName, target, source domain.ObjectID) (ports.CompareResult, error) { | |
| 1450 | + | if _, err := m.readable(ctx, p, repo); err != nil { | |
| 1451 | + | return ports.CompareResult{}, err | |
| 1452 | + | } | |
| 1453 | + | if target == source { | |
| 1454 | + | return ports.CompareResult{Exact: true}, nil | |
| 1455 | + | } | |
| 1456 | + | ancestor, err := m.git.IsAncestor(ctx, repo, target, source) | |
| 1457 | + | if err != nil { | |
| 1458 | + | return ports.CompareResult{}, err | |
| 1459 | + | } | |
| 1460 | + | commits, more, err := m.git.ListRange(ctx, repo, target, source, ports.MaxCompareCommits) | |
| 1461 | + | if err != nil { | |
| 1462 | + | return ports.CompareResult{}, err | |
| 1463 | + | } | |
| 1464 | + | verifyCommits(ctx, m.verifier, repo, commits) | |
| 1465 | + | return ports.CompareResult{Ahead: commits, Behind: !ancestor, Exact: !more}, nil | |
| 1466 | + | } | |
| 1467 | + | ||
| 1468 | + | // PushSummary detects rewrites by ancestry, not log ordering, and counts at | |
| 1469 | + | // most ports.MaxPushSummaryCommits new commits. More distinguishes a longer range from a rewrite. | |
| 1470 | + | func (m *MergeRequests) PushSummary(ctx context.Context, p domain.Principal, repo domain.RepoName, oldHead, newTip domain.ObjectID) (ports.PushSummary, error) { | |
| 1471 | + | if _, err := m.readable(ctx, p, repo); err != nil { | |
| 1472 | + | return ports.PushSummary{}, err | |
| 1473 | + | } | |
| 1474 | + | if oldHead == newTip { | |
| 1475 | + | return ports.PushSummary{}, nil | |
| 1476 | + | } | |
| 1477 | + | ancestor, err := m.git.IsAncestor(ctx, repo, oldHead, newTip) | |
| 1478 | + | if err != nil { | |
| 1479 | + | return ports.PushSummary{}, err | |
| 1480 | + | } | |
| 1481 | + | if !ancestor { | |
| 1482 | + | return ports.PushSummary{Rewritten: true}, nil | |
| 1483 | + | } | |
| 1484 | + | commits, more, err := m.git.ListRange(ctx, repo, oldHead, newTip, ports.MaxPushSummaryCommits) | |
| 1485 | + | if err != nil { | |
| 1486 | + | return ports.PushSummary{}, err | |
| 1487 | + | } | |
| 1488 | + | return ports.PushSummary{Count: len(commits), More: more}, nil | |
| 1489 | + | } | |
M internal/usecase/mergerequests_test.go +144 -0
| @@ -233,3 +233,147 @@ func TestAddNoteValidation(t *testing.T) { | |||
| 233 | 233 | }) | |
| 234 | 234 | } | |
| 235 | 235 | } | |
| 236 | + | ||
| 237 | + | func TestCompare(t *testing.T) { | |
| 238 | + | for _, tc := range []struct { | |
| 239 | + | name string | |
| 240 | + | ancestor, more, zero bool | |
| 241 | + | ancestorErr, rangeErr error | |
| 242 | + | }{ | |
| 243 | + | {name: "linear", ancestor: true}, {name: "bounded", ancestor: true, more: true}, | |
| 244 | + | {name: "unrelated histories"}, {name: "zero target forwards ancestry error", zero: true, ancestorErr: domain.ErrInvalid}, | |
| 245 | + | {name: "ancestor error", ancestorErr: domain.ErrInvalid}, | |
| 246 | + | {name: "range error", ancestor: true, rangeErr: domain.ErrInvalid}, | |
| 247 | + | } { | |
| 248 | + | t.Run(tc.name, func(t *testing.T) { | |
| 249 | + | u := newMRUnit(t) | |
| 250 | + | old, tip := someID(t), oidFrom(t, "2222222222222222222222222222222222222222") | |
| 251 | + | if tc.zero { | |
| 252 | + | old = domain.ObjectID{} | |
| 253 | + | } | |
| 254 | + | u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil) | |
| 255 | + | u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, old, tip).Return(tc.ancestor, tc.ancestorErr) | |
| 256 | + | commits := []domain.Commit{{ID: tip}} | |
| 257 | + | if tc.ancestorErr == nil { | |
| 258 | + | u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, ports.MaxCompareCommits).Return(commits, tc.more, tc.rangeErr) | |
| 259 | + | } | |
| 260 | + | got, err := u.mrs.Compare(context.Background(), domain.Root(), u.repo.Name, old, tip) | |
| 261 | + | if tc.ancestorErr != nil { | |
| 262 | + | require.ErrorIs(t, err, tc.ancestorErr) | |
| 263 | + | return | |
| 264 | + | } | |
| 265 | + | if tc.rangeErr != nil { | |
| 266 | + | require.ErrorIs(t, err, tc.rangeErr) | |
| 267 | + | return | |
| 268 | + | } | |
| 269 | + | require.NoError(t, err) | |
| 270 | + | assert.Equal(t, ports.CompareResult{Ahead: commits, Behind: !tc.ancestor, Exact: !tc.more}, got) | |
| 271 | + | }) | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func TestPushSummary(t *testing.T) { | |
| 276 | + | for _, tc := range []struct { | |
| 277 | + | name string | |
| 278 | + | ancestor, more bool | |
| 279 | + | ancestorErr, rangeErr error | |
| 280 | + | }{ | |
| 281 | + | {name: "linear", ancestor: true}, {name: "bounded", ancestor: true, more: true}, | |
| 282 | + | {name: "unrelated histories"}, {name: "ancestor error", ancestorErr: domain.ErrInvalid}, | |
| 283 | + | {name: "range error", ancestor: true, rangeErr: domain.ErrInvalid}, | |
| 284 | + | } { | |
| 285 | + | t.Run(tc.name, func(t *testing.T) { | |
| 286 | + | u := newMRUnit(t) | |
| 287 | + | old, tip := someID(t), oidFrom(t, "2222222222222222222222222222222222222222") | |
| 288 | + | u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil) | |
| 289 | + | u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, old, tip).Return(tc.ancestor, tc.ancestorErr) | |
| 290 | + | if tc.ancestor && tc.ancestorErr == nil { | |
| 291 | + | u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, ports.MaxPushSummaryCommits).Return([]domain.Commit{{ID: tip}}, tc.more, tc.rangeErr) | |
| 292 | + | } | |
| 293 | + | got, err := u.mrs.PushSummary(context.Background(), domain.Root(), u.repo.Name, old, tip) | |
| 294 | + | if tc.ancestorErr != nil { | |
| 295 | + | require.ErrorIs(t, err, tc.ancestorErr) | |
| 296 | + | return | |
| 297 | + | } | |
| 298 | + | if tc.rangeErr != nil { | |
| 299 | + | require.ErrorIs(t, err, tc.rangeErr) | |
| 300 | + | return | |
| 301 | + | } | |
| 302 | + | require.NoError(t, err) | |
| 303 | + | want := ports.PushSummary{Rewritten: !tc.ancestor} | |
| 304 | + | if tc.ancestor { | |
| 305 | + | want.Count, want.More = 1, tc.more | |
| 306 | + | } | |
| 307 | + | assert.Equal(t, want, got) | |
| 308 | + | }) | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func TestReadTrackingSharedPosition(t *testing.T) { | |
| 313 | + | for _, tc := range []struct { | |
| 314 | + | name string | |
| 315 | + | principal domain.Principal | |
| 316 | + | readIndex int | |
| 317 | + | }{ | |
| 318 | + | {"owner unread", domain.Owner(), 1}, {"owner already read", domain.Owner(), 0}, | |
| 319 | + | {"owner rewritten", domain.Owner(), -1}, {"anonymous", domain.Anonymous(), 1}, {"root token", domain.Root(), 1}, | |
| 320 | + | } { | |
| 321 | + | t.Run(tc.name, func(t *testing.T) { | |
| 322 | + | u := newMRUnit(t) | |
| 323 | + | u.repo.Visibility = domain.VisibilityPublic | |
| 324 | + | head := someID(t) | |
| 325 | + | old := oidFrom(t, "2222222222222222222222222222222222222222") | |
| 326 | + | read := old | |
| 327 | + | if tc.readIndex == 0 { | |
| 328 | + | read = head | |
| 329 | + | } | |
| 330 | + | mr := domain.MergeRequest{Repo: u.repo.Name, Number: 1, State: domain.MRStateClosed, HeadID: head} | |
| 331 | + | u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil) | |
| 332 | + | u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(mr, nil) | |
| 333 | + | u.store.EXPECT().ListComments(mock.Anything, u.repo.Name, int64(1)).Return(nil, nil) | |
| 334 | + | u.store.EXPECT().GetRead(mock.Anything, u.repo.Name, int64(1)).Return(read, nil) | |
| 335 | + | u.git.EXPECT().HasCommit(mock.Anything, u.repo.Name, head).Return(true, nil) | |
| 336 | + | commits := []domain.Commit{{ID: head}} | |
| 337 | + | if tc.readIndex >= 0 { | |
| 338 | + | commits = append(commits, domain.Commit{ID: old}) | |
| 339 | + | } | |
| 340 | + | u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, domain.ObjectID{}, head, usecase.MaxMergeRequestCommits).Return(commits, false, nil) | |
| 341 | + | detail, err := u.mrs.Get(context.Background(), tc.principal, u.repo.Name, 1, ports.GetMergeRequestOptions{}) | |
| 342 | + | require.NoError(t, err) | |
| 343 | + | expected := 0 | |
| 344 | + | assert.Equal(t, read, detail.LastReadID) | |
| 345 | + | assert.Equal(t, read != head, detail.HeadUnread) | |
| 346 | + | if tc.readIndex > 0 { | |
| 347 | + | expected = tc.readIndex | |
| 348 | + | } | |
| 349 | + | assert.Equal(t, expected, detail.NewCommits) | |
| 350 | + | }) | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func TestHistoryAuthorizationAndEqualTips(t *testing.T) { | |
| 355 | + | for _, allowed := range []bool{false, true} { | |
| 356 | + | t.Run(map[bool]string{false: "private", true: "equal"}[allowed], func(t *testing.T) { | |
| 357 | + | u := newMRUnit(t) | |
| 358 | + | if allowed { | |
| 359 | + | u.repo.Visibility = domain.VisibilityPublic | |
| 360 | + | } | |
| 361 | + | u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Twice() | |
| 362 | + | id := someID(t) | |
| 363 | + | comparison, err := u.mrs.Compare(context.Background(), domain.Anonymous(), u.repo.Name, id, id) | |
| 364 | + | if allowed { | |
| 365 | + | require.NoError(t, err) | |
| 366 | + | assert.True(t, comparison.Exact) | |
| 367 | + | } else { | |
| 368 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 369 | + | } | |
| 370 | + | summary, err := u.mrs.PushSummary(context.Background(), domain.Anonymous(), u.repo.Name, id, id) | |
| 371 | + | if allowed { | |
| 372 | + | require.NoError(t, err) | |
| 373 | + | assert.Zero(t, summary) | |
| 374 | + | } else { | |
| 375 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 376 | + | } | |
| 377 | + | }) | |
| 378 | + | } | |
| 379 | + | } | |
M internal/usecase/oidc.go +27 -3
| @@ -74,8 +74,8 @@ type oidcKey struct { | |||
| 74 | 74 | var _ ports.OIDCKeyService = (*OIDC)(nil) | |
| 75 | 75 | ||
| 76 | 76 | // NewOIDC loads the signing keys, generates the first one when there is none, | |
| 77 | - | // and forgets the retired keys whose publication is over. It returns nil | |
| 78 | - | // without error when cfg.Sealer is nil: the server issues no ID tokens. A | |
| 77 | + | // and forgets the retired keys whose publication is over. With cfg.Sealer nil | |
| 78 | + | // it returns a disabled service: no ID tokens are issued. A | |
| 79 | 79 | // stored key that does not open with the secrets key is an error: the server | |
| 80 | 80 | // must not start (the secrets key check already refuses another key, so this | |
| 81 | 81 | // is a damaged record). | |
| @@ -83,7 +83,7 @@ func NewOIDC(ctx context.Context, cfg OIDCConfig) (*OIDC, error) { | |||
| 83 | 83 | log := zerowrap.FromCtx(ctx) | |
| 84 | 84 | if cfg.Sealer == nil { | |
| 85 | 85 | log.Info().Msg("ERMITE_SECRETS_KEY is not set: CI jobs get no OIDC ID tokens") | |
| 86 | - | return nil, nil | |
| 86 | + | return &OIDC{}, nil | |
| 87 | 87 | } | |
| 88 | 88 | if cfg.NewKey == nil { | |
| 89 | 89 | cfg.NewKey = func() (*rsa.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, OIDCKeyBits) } | |
| @@ -180,6 +180,13 @@ func (o *OIDC) signing() (oidcKey, bool) { | |||
| 180 | 180 | // and stays in the JWKS for domain.OIDCKeyRetention. Retired keys whose | |
| 181 | 181 | // publication is over are deleted with it. Only the owner may rotate. | |
| 182 | 182 | func (o *OIDC) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) { | |
| 183 | + | if !o.Enabled() { | |
| 184 | + | if !p.HasOwnerRights() { | |
| 185 | + | return ports.OIDCRotation{}, domain.ErrForbidden | |
| 186 | + | } | |
| 187 | + | return ports.OIDCRotation{}, oidcDisabledError{} | |
| 188 | + | } | |
| 189 | + | ||
| 183 | 190 | switch { | |
| 184 | 191 | case p.IsAnonymous(): | |
| 185 | 192 | return ports.OIDCRotation{}, domain.ErrUnauthenticated | |
| @@ -234,6 +241,9 @@ func publicJWK(kid string, pub *rsa.PublicKey) jwk { | |||
| 234 | 241 | ||
| 235 | 242 | // JWKS returns the JSON of the published keys. | |
| 236 | 243 | func (o *OIDC) JWKS() ([]byte, error) { | |
| 244 | + | if !o.Enabled() { | |
| 245 | + | return nil, domain.ErrNotFound | |
| 246 | + | } | |
| 237 | 247 | now := o.cfg.Clock.Now().UTC() | |
| 238 | 248 | o.mu.RLock() | |
| 239 | 249 | defer o.mu.RUnlock() | |
| @@ -258,6 +268,9 @@ var oidcClaims = []string{ | |||
| 258 | 268 | // Discovery returns the JSON of the OpenID Provider configuration, in the | |
| 259 | 269 | // shape of GitHub's. | |
| 260 | 270 | func (o *OIDC) Discovery() ([]byte, error) { | |
| 271 | + | if !o.Enabled() { | |
| 272 | + | return nil, domain.ErrNotFound | |
| 273 | + | } | |
| 261 | 274 | return json.Marshal(ordered{ | |
| 262 | 275 | {"issuer", o.issuer}, | |
| 263 | 276 | {"jwks_uri", o.issuer + "/.well-known/jwks"}, | |
| @@ -303,3 +316,14 @@ func thumbprint(pub *rsa.PublicKey) string { | |||
| 303 | 316 | } | |
| 304 | 317 | ||
| 305 | 318 | func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } | |
| 319 | + | ||
| 320 | + | // Enabled reports whether the provider issues ID tokens. A nil provider is disabled. | |
| 321 | + | func (o *OIDC) Enabled() bool { return o != nil && o.cfg.Sealer != nil } | |
| 322 | + | ||
| 323 | + | // oidcDisabledError preserves the public disabled-provider message and precondition code. | |
| 324 | + | type oidcDisabledError struct{} | |
| 325 | + | ||
| 326 | + | func (oidcDisabledError) Error() string { | |
| 327 | + | return "the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY" | |
| 328 | + | } | |
| 329 | + | func (oidcDisabledError) Is(target error) bool { return target == domain.ErrPreconditionFailed } | |
M internal/usecase/oidc_test.go +44 -1
| @@ -123,7 +123,8 @@ func TestNoSecretsKeyNoOIDC(t *testing.T) { | |||
| 123 | 123 | f := newOIDCFixture(t) | |
| 124 | 124 | o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{Store: f.store, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test"}) | |
| 125 | 125 | require.NoError(t, err) | |
| 126 | - | assert.Nil(t, o) | |
| 126 | + | require.NotNil(t, o) | |
| 127 | + | assert.False(t, o.Enabled()) | |
| 127 | 128 | keys, err := f.store.ListOIDCKeys(context.Background()) | |
| 128 | 129 | require.NoError(t, err) | |
| 129 | 130 | assert.Empty(t, keys, "no key is generated") | |
| @@ -338,3 +339,45 @@ func TestIDTokenClaimsPerEvent(t *testing.T) { | |||
| 338 | 339 | }) | |
| 339 | 340 | } | |
| 340 | 341 | } | |
| 342 | + | ||
| 343 | + | func TestDisabledOIDCRotationAuthorization(t *testing.T) { | |
| 344 | + | o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{}) | |
| 345 | + | require.NoError(t, err) | |
| 346 | + | for _, tc := range []struct { | |
| 347 | + | name string | |
| 348 | + | p domain.Principal | |
| 349 | + | want error | |
| 350 | + | }{ | |
| 351 | + | {"anonymous", domain.Anonymous(), domain.ErrForbidden}, | |
| 352 | + | {"limited", domain.TokenPrincipal(domain.Token{ID: "limited"}), domain.ErrForbidden}, | |
| 353 | + | {"root", domain.Root(), domain.ErrPreconditionFailed}, | |
| 354 | + | {"owner", domain.Owner(), domain.ErrPreconditionFailed}, | |
| 355 | + | } { | |
| 356 | + | t.Run(tc.name, func(t *testing.T) { | |
| 357 | + | _, err := o.RotateKey(context.Background(), tc.p) | |
| 358 | + | require.ErrorIs(t, err, tc.want) | |
| 359 | + | if tc.want == domain.ErrPreconditionFailed { | |
| 360 | + | assert.EqualError(t, err, "the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY") | |
| 361 | + | } | |
| 362 | + | }) | |
| 363 | + | } | |
| 364 | + | _, err = o.Discovery() | |
| 365 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 366 | + | _, err = o.JWKS() | |
| 367 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | func TestDisabledOIDCPublicEndpoints(t *testing.T) { | |
| 371 | + | for _, provider := range []*usecase.OIDC{nil, {}} { | |
| 372 | + | _, err := provider.Discovery() | |
| 373 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 374 | + | _, err = provider.JWKS() | |
| 375 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 376 | + | ci := usecase.NewCI(context.Background(), usecase.CIConfig{Owner: "owner", Clock: fixedClock{}, OIDC: provider}) | |
| 377 | + | _, err = ci.OIDCDiscovery() | |
| 378 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 379 | + | _, err = ci.OIDCKeys() | |
| 380 | + | require.ErrorIs(t, err, domain.ErrNotFound) | |
| 381 | + | ci.Close(context.Background()) | |
| 382 | + | } | |
| 383 | + | } | |
M internal/usecase/pushes.go +30 -14
| @@ -26,7 +26,7 @@ type Pushes struct { | |||
| 26 | 26 | auth ports.Revalidator | |
| 27 | 27 | fresh ports.RepoFreshener // nil: the reader needs no telling | |
| 28 | 28 | ci ports.WorkflowTrigger // nil: no CI | |
| 29 | - | wf ports.WorkflowGuard // nil: pushes by job tokens are refused | |
| 29 | + | trees ports.PushTreeReader // nil: pushes by job tokens are refused | |
| 30 | 30 | tips ports.CIRefTipStore // nil: who pushes a ref is not recorded | |
| 31 | 31 | mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked | |
| 32 | 32 | } | |
| @@ -73,11 +73,11 @@ func (u *Pushes) WithRefTips(t ports.CIRefTipStore) *Pushes { | |||
| 73 | 73 | return u | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | - | // WithWorkflowGuard lets Check tell whether a push changes workflow files, which | |
| 76 | + | // WithPushTreeReader lets Check tell whether a push changes workflow files, which | |
| 77 | 77 | // a push by a job token may not do. Without it a push by a job token is | |
| 78 | 78 | // refused, as it cannot be judged. Call it before the first push; it returns u. | |
| 79 | - | func (u *Pushes) WithWorkflowGuard(g ports.WorkflowGuard) *Pushes { | |
| 80 | - | u.wf = g | |
| 79 | + | func (u *Pushes) WithPushTreeReader(g ports.PushTreeReader) *Pushes { | |
| 80 | + | u.trees = g | |
| 81 | 81 | return u | |
| 82 | 82 | } | |
| 83 | 83 | ||
| @@ -199,20 +199,36 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 199 | 199 | const workflowRefusal = "refusing to allow a GitHub App to create or update workflow `%s` without `workflows` permission" | |
| 200 | 200 | ||
| 201 | 201 | func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error { | |
| 202 | - | if u.wf == nil { | |
| 202 | + | if u.trees == nil { | |
| 203 | 203 | return fmt.Errorf("%w: the token of a job may not push here", domain.ErrRejected) | |
| 204 | 204 | } | |
| 205 | - | path, err := u.wf.ChangedWorkflow(ctx, name, updates, objects) | |
| 206 | - | switch { | |
| 207 | - | case errors.Is(err, domain.ErrInvalid): | |
| 208 | - | // A push the guard cannot judge within its bounds: refused, with the reason. | |
| 205 | + | t, err := u.trees.PushSubtrees(ctx, name, updates, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 206 | + | if err != nil { | |
| 207 | + | return workflowReadError(name, err) | |
| 208 | + | } | |
| 209 | + | c, found, err := domain.WorkflowChangeOf(updates, t) | |
| 210 | + | if err != nil { | |
| 209 | 211 | return fmt.Errorf("%w: %v", domain.ErrRejected, err) | |
| 210 | - | case err != nil: | |
| 211 | - | return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err) | |
| 212 | - | case path != "": | |
| 213 | - | return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path) | |
| 214 | 212 | } | |
| 215 | - | return nil | |
| 213 | + | if !found { | |
| 214 | + | return nil | |
| 215 | + | } | |
| 216 | + | entry, err := u.trees.FirstDifferentEntry(ctx, name, objects, c.Old, c.New) | |
| 217 | + | if err != nil { | |
| 218 | + | return workflowReadError(name, err) | |
| 219 | + | } | |
| 220 | + | path := domain.WorkflowDir | |
| 221 | + | if entry != "" { | |
| 222 | + | path += "/" + entry | |
| 223 | + | } | |
| 224 | + | return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func workflowReadError(name domain.RepoName, err error) error { | |
| 228 | + | if errors.Is(err, domain.ErrInvalid) { | |
| 229 | + | return fmt.Errorf("%w: %v", domain.ErrRejected, err) | |
| 230 | + | } | |
| 231 | + | return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err) | |
| 216 | 232 | } | |
| 217 | 233 | ||
| 218 | 234 | // Record is the post-receive step of an accepted push: it sets pushed_at and active_at, | |
M internal/usecase/pushes_test.go +79 -17
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | 8 | "fmt" | |
| 9 | + | "strings" | |
| 9 | 10 | "testing" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| @@ -440,16 +441,41 @@ func (f *pushFixture) check(ctx context.Context, p domain.Principal, n domain.Re | |||
| 440 | 441 | return f.pushes.Check(ctx, p, n, ups, ports.PushObjects{}) | |
| 441 | 442 | } | |
| 442 | 443 | ||
| 443 | - | type fakeGuard struct { | |
| 444 | - | path string | |
| 445 | - | err error | |
| 446 | - | got []domain.RefUpdate | |
| 447 | - | objs ports.PushObjects | |
| 444 | + | type fakePushTrees struct { | |
| 445 | + | path string | |
| 446 | + | err error | |
| 447 | + | diffErr error | |
| 448 | + | got []domain.RefUpdate | |
| 449 | + | objs ports.PushObjects | |
| 450 | + | bounds domain.PushWalkBounds | |
| 451 | + | subtreePath string | |
| 452 | + | calls int | |
| 453 | + | old, new domain.ObjectID | |
| 454 | + | data *domain.PushSubtrees | |
| 455 | + | change bool | |
| 448 | 456 | } | |
| 449 | 457 | ||
| 450 | - | func (g *fakeGuard) ChangedWorkflow(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects) (string, error) { | |
| 451 | - | g.got, g.objs = ups, o | |
| 452 | - | return g.path, g.err | |
| 458 | + | func (g *fakePushTrees) PushSubtrees(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) { | |
| 459 | + | g.got, g.objs, g.subtreePath, g.bounds = ups, o, path, bounds | |
| 460 | + | if g.data != nil { | |
| 461 | + | return *g.data, g.err | |
| 462 | + | } | |
| 463 | + | t := domain.PushSubtrees{Ends: map[string]domain.PushObjectType{}, Subtrees: map[string]domain.Subtree{}} | |
| 464 | + | for _, up := range ups { | |
| 465 | + | t.Ends[up.Old] = domain.PushObjectCommit | |
| 466 | + | t.Ends[up.New] = domain.PushObjectCommit | |
| 467 | + | if g.path != "" || g.change { | |
| 468 | + | id, _ := domain.ParseObjectID(up.New) | |
| 469 | + | t.Subtrees[up.New] = domain.Subtree{Kind: domain.EntryDirectory, ID: id} | |
| 470 | + | } | |
| 471 | + | } | |
| 472 | + | return t, g.err | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | func (g *fakePushTrees) FirstDifferentEntry(_ context.Context, _ domain.RepoName, _ ports.PushObjects, old, new domain.ObjectID) (string, error) { | |
| 476 | + | g.calls++ | |
| 477 | + | g.old, g.new = old, new | |
| 478 | + | return strings.TrimPrefix(g.path, domain.WorkflowDir+"/"), g.diffErr | |
| 453 | 479 | } | |
| 454 | 480 | ||
| 455 | 481 | // A push by the token of a job is judged on the workflows it changes, and by it | |
| @@ -464,10 +490,10 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) { | |||
| 464 | 490 | job := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j1", Repo: n, ExpiresAt: exp, Permissions: map[string]string{"contents": "write"}}) | |
| 465 | 491 | readOnlyJob := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j2", Repo: n, ExpiresAt: exp}) | |
| 466 | 492 | ||
| 467 | - | build := func(t *testing.T, g *fakeGuard) *pushFixture { | |
| 493 | + | build := func(t *testing.T, g *fakePushTrees) *pushFixture { | |
| 468 | 494 | f := newPushFixture(t) | |
| 469 | 495 | if g != nil { | |
| 470 | - | f.pushes.WithWorkflowGuard(g) | |
| 496 | + | f.pushes.WithPushTreeReader(g) | |
| 471 | 497 | } | |
| 472 | 498 | f.cirepo.EXPECT().GetRepoToken(ctx, mock.Anything).Return(domain.CIRepoToken{ID: "j1", ExpiresAt: exp}, nil).Maybe() | |
| 473 | 499 | return f | |
| @@ -475,29 +501,65 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) { | |||
| 475 | 501 | ||
| 476 | 502 | t.Run("no workflow touched", func(t *testing.T) { | |
| 477 | 503 | t.Parallel() | |
| 478 | - | g := &fakeGuard{} | |
| 504 | + | g := &fakePushTrees{} | |
| 479 | 505 | f := build(t, g) | |
| 480 | 506 | require.NoError(t, f.pushes.Check(ctx, job, n, ups, objs)) | |
| 481 | 507 | assert.Equal(t, ups, g.got, "every ref is judged") | |
| 482 | 508 | assert.Equal(t, objs, g.objs, "from the objects of the push in quarantine") | |
| 509 | + | assert.Equal(t, domain.WorkflowDir, g.subtreePath) | |
| 510 | + | assert.Equal(t, domain.DefaultPushWalkBounds(), g.bounds) | |
| 511 | + | assert.Zero(t, g.calls) | |
| 483 | 512 | }) | |
| 484 | 513 | t.Run("a workflow touched refuses the whole push with GitHub's text", func(t *testing.T) { | |
| 485 | 514 | t.Parallel() | |
| 486 | - | f := build(t, &fakeGuard{path: ".github/workflows/ci.yml"}) | |
| 515 | + | f := build(t, &fakePushTrees{path: ".github/workflows/ci.yml"}) | |
| 487 | 516 | err := f.pushes.Check(ctx, job, n, ups, objs) | |
| 488 | 517 | require.ErrorIs(t, err, domain.ErrRejected) | |
| 489 | 518 | assert.Contains(t, err.Error(), "refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission") | |
| 490 | 519 | }) | |
| 520 | + | t.Run("empty differing entry reports the directory and uses the domain pair once", func(t *testing.T) { | |
| 521 | + | g := &fakePushTrees{change: true} | |
| 522 | + | f := build(t, g) | |
| 523 | + | err := f.pushes.Check(ctx, job, n, ups, objs) | |
| 524 | + | require.ErrorIs(t, err, domain.ErrRejected) | |
| 525 | + | assert.Contains(t, err.Error(), "workflow `.github/workflows`") | |
| 526 | + | assert.Equal(t, 1, g.calls) | |
| 527 | + | assert.True(t, g.old.IsZero()) | |
| 528 | + | assert.Equal(t, oidB, g.new.String()) | |
| 529 | + | }) | |
| 530 | + | t.Run("diff invalid is rejected with reason", func(t *testing.T) { | |
| 531 | + | g := &fakePushTrees{change: true, diffErr: fmt.Errorf("%w: path too long", domain.ErrInvalid)} | |
| 532 | + | f := build(t, g) | |
| 533 | + | err := f.pushes.Check(ctx, job, n, ups, objs) | |
| 534 | + | require.ErrorIs(t, err, domain.ErrRejected) | |
| 535 | + | assert.Contains(t, err.Error(), "path too long") | |
| 536 | + | assert.Equal(t, 1, g.calls) | |
| 537 | + | }) | |
| 538 | + | t.Run("diff I/O failure retains its class", func(t *testing.T) { | |
| 539 | + | boom := errors.New("diff broke") | |
| 540 | + | g := &fakePushTrees{change: true, diffErr: boom} | |
| 541 | + | f := build(t, g) | |
| 542 | + | err := f.pushes.Check(ctx, job, n, ups, objs) | |
| 543 | + | require.ErrorIs(t, err, boom) | |
| 544 | + | assert.NotErrorIs(t, err, domain.ErrRejected) | |
| 545 | + | }) | |
| 546 | + | t.Run("missing endpoint is rejected before diff", func(t *testing.T) { | |
| 547 | + | g := &fakePushTrees{data: &domain.PushSubtrees{}} | |
| 548 | + | f := build(t, g) | |
| 549 | + | err := f.pushes.Check(ctx, job, n, ups, objs) | |
| 550 | + | require.ErrorIs(t, err, domain.ErrRejected) | |
| 551 | + | assert.Zero(t, g.calls) | |
| 552 | + | }) | |
| 491 | 553 | t.Run("a failure to tell refuses it, not as a policy", func(t *testing.T) { | |
| 492 | 554 | t.Parallel() | |
| 493 | - | f := build(t, &fakeGuard{err: errors.New("git broke")}) | |
| 555 | + | f := build(t, &fakePushTrees{err: errors.New("git broke")}) | |
| 494 | 556 | err := f.pushes.Check(ctx, job, n, ups, objs) | |
| 495 | 557 | require.Error(t, err) | |
| 496 | 558 | assert.NotErrorIs(t, err, domain.ErrRejected) | |
| 497 | 559 | }) | |
| 498 | 560 | t.Run("a push beyond the guard's bounds is refused as a policy, with the reason", func(t *testing.T) { | |
| 499 | 561 | t.Parallel() | |
| 500 | - | f := build(t, &fakeGuard{err: fmt.Errorf("%w: the push brings more than 10000 commits", domain.ErrInvalid)}) | |
| 562 | + | f := build(t, &fakePushTrees{err: fmt.Errorf("%w: the push brings more than 10000 commits", domain.ErrInvalid)}) | |
| 501 | 563 | err := f.pushes.Check(ctx, job, n, ups, objs) | |
| 502 | 564 | require.ErrorIs(t, err, domain.ErrRejected) | |
| 503 | 565 | assert.Contains(t, err.Error(), "more than 10000 commits") | |
| @@ -509,19 +571,19 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) { | |||
| 509 | 571 | }) | |
| 510 | 572 | t.Run("a job token without contents: write cannot push at all", func(t *testing.T) { | |
| 511 | 573 | t.Parallel() | |
| 512 | - | f := build(t, &fakeGuard{}) | |
| 574 | + | f := build(t, &fakePushTrees{}) | |
| 513 | 575 | assert.ErrorIs(t, f.pushes.Check(ctx, readOnlyJob, n, ups, objs), domain.ErrRejected) | |
| 514 | 576 | }) | |
| 515 | 577 | t.Run("the owner is not asked", func(t *testing.T) { | |
| 516 | 578 | t.Parallel() | |
| 517 | - | g := &fakeGuard{path: ".github/workflows/ci.yml"} | |
| 579 | + | g := &fakePushTrees{path: ".github/workflows/ci.yml"} | |
| 518 | 580 | f := build(t, g) | |
| 519 | 581 | require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups, objs)) | |
| 520 | 582 | assert.Nil(t, g.got) | |
| 521 | 583 | }) | |
| 522 | 584 | t.Run("a job token never creates a repository on push", func(t *testing.T) { | |
| 523 | 585 | t.Parallel() | |
| 524 | - | f := build(t, &fakeGuard{}) | |
| 586 | + | f := build(t, &fakePushTrees{}) | |
| 525 | 587 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |
| 526 | 588 | assert.ErrorIs(t, f.pushes.Open(ctx, job, n, true), domain.ErrNotFound) | |
| 527 | 589 | }) | |
M internal/usecase/repos_write.go +1 -3
| @@ -165,9 +165,7 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por | |||
| 165 | 165 | if params.Visibility != nil { | |
| 166 | 166 | repo.Visibility = *params.Visibility | |
| 167 | 167 | } | |
| 168 | - | if repo.Settings.IsZero() { | |
| 169 | - | repo.Settings = domain.DefaultRepoSettings() | |
| 170 | - | } | |
| 168 | + | repo.Settings = repo.EffectiveSettings() | |
| 171 | 169 | if params.AllowedMergeModes != nil { | |
| 172 | 170 | repo.Settings.AllowedMergeModes = *params.AllowedMergeModes | |
| 173 | 171 | } | |