| 1 | // SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | |
| 3 | package sigverify |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "time" |
| 8 | |
| 9 | reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc" |
| 10 | ) |
| 11 | |
| 12 | // View is a render-friendly projection of a commit_verification_cache |
| 13 | // row. It carries everything the HTML badge partial and the REST |
| 14 | // `verification` object need; both consumers translate to their own |
| 15 | // surface shapes from here. |
| 16 | // |
| 17 | // "No cache row" maps to View{Verified: false, Reason: ReasonUnsigned} |
| 18 | // — gh's documented behavior for commits we haven't verified yet. |
| 19 | // |
| 20 | // "Cache row exists but invalidated_at != null" also maps to the |
| 21 | // unsigned-shaped default. Rationale: a stale row means the signing |
| 22 | // key was revoked between verification and now; pretending it's |
| 23 | // verified would be wrong. Treating it as "needs re-verify, render |
| 24 | // as unsigned for now" lets the backfill worker catch up |
| 25 | // asynchronously without the render path doing crypto work inline. |
| 26 | type View struct { |
| 27 | Verified bool |
| 28 | Reason Reason |
| 29 | Signature string |
| 30 | Payload []byte |
| 31 | VerifiedAt *time.Time |
| 32 | SignerUserID int64 |
| 33 | SignerSubkeyID int64 |
| 34 | } |
| 35 | |
| 36 | // UnsignedView is the canonical "no cache row" / "row invalidated" |
| 37 | // shape. Mirrors gh's "unsigned" rendering. |
| 38 | func UnsignedView() View { |
| 39 | return View{Verified: false, Reason: ReasonUnsigned} |
| 40 | } |
| 41 | |
| 42 | // ViewFromRow translates a sqlc cache row into the View shape. |
| 43 | // Honors the invalidated_at flag by returning UnsignedView for |
| 44 | // stale rows. |
| 45 | func ViewFromRow(row reposdb.CommitVerificationCache) View { |
| 46 | if row.InvalidatedAt.Valid { |
| 47 | return UnsignedView() |
| 48 | } |
| 49 | view := View{ |
| 50 | Verified: row.Verified, |
| 51 | Reason: Reason(row.Reason), |
| 52 | Payload: row.Payload, |
| 53 | } |
| 54 | if row.SignatureArmored.Valid { |
| 55 | view.Signature = row.SignatureArmored.String |
| 56 | } |
| 57 | if row.VerifiedAt.Valid { |
| 58 | t := row.VerifiedAt.Time |
| 59 | view.VerifiedAt = &t |
| 60 | } |
| 61 | if row.SignerUserID.Valid { |
| 62 | view.SignerUserID = row.SignerUserID.Int64 |
| 63 | } |
| 64 | if row.SignerSubkeyID.Valid { |
| 65 | view.SignerSubkeyID = row.SignerSubkeyID.Int64 |
| 66 | } |
| 67 | return view |
| 68 | } |
| 69 | |
| 70 | // LoadViewsForOIDs batch-reads verification rows for a set of commit |
| 71 | // OIDs and returns a map keyed by OID. OIDs without a cache row are |
| 72 | // absent from the map — the renderer treats absence as |
| 73 | // UnsignedView() via the LookupView helper. |
| 74 | // |
| 75 | // Used by the commit-list render path (both HTML and REST). |
| 76 | func LoadViewsForOIDs(ctx context.Context, db reposdb.DBTX, repoID int64, oids []string) (map[string]View, error) { |
| 77 | if len(oids) == 0 { |
| 78 | return map[string]View{}, nil |
| 79 | } |
| 80 | q := reposdb.New() |
| 81 | rows, err := q.GetCommitVerificationsForOIDs(ctx, db, reposdb.GetCommitVerificationsForOIDsParams{ |
| 82 | RepoID: repoID, |
| 83 | Oids: oids, |
| 84 | }) |
| 85 | if err != nil { |
| 86 | return nil, err |
| 87 | } |
| 88 | out := make(map[string]View, len(rows)) |
| 89 | for _, row := range rows { |
| 90 | out[row.CommitOid] = ViewFromRow(row) |
| 91 | } |
| 92 | return out, nil |
| 93 | } |
| 94 | |
| 95 | // LoadView reads a single commit's verification row. Returns |
| 96 | // UnsignedView() when no row exists or the row is invalidated. |
| 97 | func LoadView(ctx context.Context, db reposdb.DBTX, repoID int64, oid string) (View, error) { |
| 98 | q := reposdb.New() |
| 99 | row, err := q.GetCommitVerification(ctx, db, reposdb.GetCommitVerificationParams{ |
| 100 | RepoID: repoID, |
| 101 | CommitOid: oid, |
| 102 | }) |
| 103 | if err != nil { |
| 104 | // Distinguishing "not found" from "DB error" matters: not- |
| 105 | // found is the unsigned-default fast path; DB error should |
| 106 | // propagate so the caller can decide between fail-open |
| 107 | // (render anyway) and fail-closed (5xx). Caller handles via |
| 108 | // errors.Is(err, pgx.ErrNoRows). |
| 109 | return UnsignedView(), err |
| 110 | } |
| 111 | return ViewFromRow(row), nil |
| 112 | } |
| 113 | |
| 114 | // LookupView returns the View for the given OID from a map, falling |
| 115 | // back to UnsignedView() on miss. Template-friendly helper so the |
| 116 | // partial doesn't have to do nil-checks inline. |
| 117 | func LookupView(m map[string]View, oid string) View { |
| 118 | v, ok := m[oid] |
| 119 | if !ok { |
| 120 | return UnsignedView() |
| 121 | } |
| 122 | return v |
| 123 | } |
| 124 |