coverage~bigbes/sr-ht-spec3cb1c03dservice/read.go

Coverage
83.8% 57/68 statements
Δ
Blob
7e7a35a
1 package service
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7
8 "github.com/go-git/go-git/v5/plumbing"
9
10 "sourcecraft.dev/bigbes/sr-ht-spec/core"
11 "sourcecraft.dev/bigbes/sr-ht-spec/gitx"
12 )
13
14 // ApprovedRev is the revision string meaning "the space's approved head". It is
15 // the empty string so that a caller which simply forwards an absent ?rev= gets
16 // the approved revision by default, which is the read contract: reads default
17 // to the approved revision, because serving drafts by default would poison
18 // every downstream agent context with unreviewed text.
19 const ApprovedRev = ""
20
21 // Document is one document as it exists at a revision.
22 //
23 // Blob and Rev are hex object names rather than plumbing.Hash so that api/,
24 // mcpsrv/, graph/ and web/ can carry them without importing gitx — the layering
25 // rule is that nothing above service/ touches the git layer, and a leaked
26 // plumbing type would break it on the first struct field.
27 type Document struct {
28 // Path is the document's path in the tree.
29 Path string
30
31 // Blob is the sha of the document's blob — the render cache key. It is
32 // content-addressed, so a cache entry keyed by it can never go stale.
33 Blob string
34
35 // Rev is the commit the read resolved to. For a read at the approved head
36 // this is the value to hand back as the pinned ?rev=, and it is the same
37 // value an agent sends as If-Match.
38 Rev string
39
40 // Data is the whole document: frontmatter and body.
41 Data []byte
42 }
43
44 // ReadDocument reads one document by path, at the approved head when rev is
45 // ApprovedRev and at a pinned revision otherwise.
46 //
47 // This is the same code path for both. There is one storage tier and no
48 // checkout, so "the approved text of SPEC-0007" and "SPEC-0007 at
49 // 1f0c1d1a" differ only in which revision is resolved.
50 10 func (s *Service) ReadDocument(ctx context.Context, sp *Space, rev, path string) (Document, error) {
51 10 commit, resolved, err := s.resolveRev(ctx, sp, rev)
52 10 if err != nil {
53 3 return Document{}, err
54 3 }
55 7 doc, err := sp.Repo.ReadDocument(ctx, resolved, path)
56 7 if err != nil {
57 1 return Document{}, readErr(err, "read %s at %s in %s", path, resolved, sp.Ref)
58 1 }
59 6 return Document{
60 6 Path: doc.Path,
61 6 Blob: doc.Blob.String(),
62 6 Rev: commit.String(),
63 6 Data: doc.Data,
64 6 }, nil
65 }
66
67 // ListDocuments returns every document in a space at a revision, in tree order.
68 //
69 // Bodies are included: they come off the same tree walk, the volume is tens of
70 // documents a day, and every caller that lists documents (the indexer, the
71 // review page, the ID map a push validation builds) needs the frontmatter,
72 // which is not separable from the blob.
73 29 func (s *Service) ListDocuments(ctx context.Context, sp *Space, rev string) ([]Document, error) {
74 29 commit, resolved, err := s.resolveRev(ctx, sp, rev)
75 29 if err != nil {
76 0 return nil, err
77 0 }
78 29 docs, err := sp.Repo.ListDocuments(ctx, resolved)
79 29 if err != nil {
80 0 return nil, readErr(err, "list documents at %s in %s", resolved, sp.Ref)
81 0 }
82 29 out := make([]Document, 0, len(docs))
83 32 for _, d := range docs {
84 32 out = append(out, Document{
85 32 Path: d.Path,
86 32 Blob: d.Blob.String(),
87 32 Rev: commit.String(),
88 32 Data: d.Data,
89 32 })
90 32 }
91 29 return out, nil
92 }
93
94 // Policy reads the space's effective .spec.yml at a revision.
95 //
96 // A space with no .spec.yml gets core.DefaultPolicy: the house frontmatter
97 // contract and nothing auto-merged. That is the fail-closed direction — a space
98 // that has not said anything about review must not be quietly laundering
99 // unreviewed agent output onto the approved branch — and it is a defined
100 // default rather than a fallback, which is why an absent file is not an error
101 // but an unparseable one is.
102 //
103 // Reading it at a revision rather than from configuration is what makes policy
104 // changes reviewable like any other change, and it is why a push that edits
105 // .spec.yml is validated against the policy it is installing.
106 70 func (s *Service) Policy(ctx context.Context, sp *Space, rev string) (core.Policy, error) {
107 70 _, resolved, err := s.resolveRev(ctx, sp, rev)
108 70 if err != nil {
109 0 return core.Policy{}, err
110 0 }
111 70 data, _, err := sp.Repo.ReadBlob(ctx, resolved, core.PolicyFile)
112 70 if err != nil {
113 55 if errors.Is(err, gitx.ErrNotFound) {
114 55 return core.DefaultPolicy(), nil
115 55 }
116 0 return core.Policy{}, readErr(err, "read %s at %s in %s",
117 0 core.PolicyFile, resolved, sp.Ref)
118 }
119 15 pol, err := core.ParsePolicy(data)
120 15 if err != nil {
121 1 return core.Policy{}, fmt.Errorf("service: %s at %s in %s: %w",
122 1 core.PolicyFile, resolved, sp.Ref, err)
123 1 }
124 14 return pol, nil
125 }
126
127 // ResolveRev resolves a revision string against a space, returning the commit
128 // it names as a hex object name. ApprovedRev resolves to the approved head.
129 //
130 // Callers use it to pin: the review UI turns "the approved head right now" into
131 // an immutable ?rev= before it renders anything, so a merge landing mid-render
132 // cannot make one page describe two revisions.
133 1 func (s *Service) ResolveRev(ctx context.Context, sp *Space, rev string) (string, error) {
134 1 commit, _, err := s.resolveRev(ctx, sp, rev)
135 1 if err != nil {
136 0 return "", err
137 0 }
138 1 return commit.String(), nil
139 }
140
141 // resolveRev turns a caller's revision string into both the commit it names and
142 // the string to pass back down to gitx.
143 //
144 // Both are returned because they are not interchangeable: the hash is what a
145 // caller pins and compares, while the original string is what the read is
146 // issued against. Re-issuing reads against the resolved hash instead would be
147 // one extra object lookup per read for no gain, and would lose the branch name
148 // from error messages.
149 // ReadDocumentAtRef reads a document at an arbitrary ref, bypassing the read
150 // contract's object-name requirement.
151 //
152 // This is the review path's entry point: rendering and diffing a proposal
153 // branch genuinely needs to read one. It is deliberately a separate,
154 // awkwardly-named method rather than a flag on ReadDocument, so that serving
155 // unreviewed content is something a caller has to ask for by name and a reviewer
156 // can grep for — never something a read surface can be talked into by a crafted
157 // rev parameter.
158 //
159 // Do not call this from any surface that answers "read SPEC-0007".
160 1 func (s *Service) ReadDocumentAtRef(ctx context.Context, sp *Space, ref, path string) (Document, error) {
161 1 if sp == nil || sp.Repo == nil {
162 0 return Document{}, errors.New("service: space has no open repository")
163 0 }
164 1 resolved := ref
165 1 if resolved == ApprovedRev {
166 0 resolved = sp.Repo.ApprovedBranch()
167 0 }
168 1 commit, err := sp.Repo.ResolveRev(ctx, resolved)
169 1 if err != nil {
170 0 return Document{}, readErr(err, "resolve revision %q in %s", resolved, sp.Ref)
171 0 }
172 1 d, err := sp.Repo.ReadDocument(ctx, resolved, path)
173 1 if err != nil {
174 0 return Document{}, readErr(err, "read %s at %s in %s", path, resolved, sp.Ref)
175 0 }
176 1 return Document{Path: d.Path, Blob: d.Blob.String(), Rev: commit.String(), Data: d.Data}, nil
177 }
178
179 113 func (s *Service) resolveRev(ctx context.Context, sp *Space, rev string) (plumbing.Hash, string, error) {
180 113 if sp == nil || sp.Repo == nil {
181 0 return plumbing.ZeroHash, "", errors.New("service: space has no open repository")
182 0 }
183 113 if err := ValidateReadRev(rev); err != nil {
184 3 // Wrapped as ErrNotFound so a crafted revision cannot tell "malformed"
185 3 // from "absent" by probing, matching readErr's existing choice.
186 3 // ErrBadReadRev stays in the chain for logs and for callers that care.
187 3 return plumbing.ZeroHash, "", fmt.Errorf("%w: %w", ErrNotFound, err)
188 3 }
189 110 resolved := rev
190 110 if resolved == ApprovedRev {
191 13 resolved = sp.Repo.ApprovedBranch()
192 13 }
193 110 commit, err := sp.Repo.ResolveRev(ctx, resolved)
194 110 if err != nil {
195 1 return plumbing.ZeroHash, "", readErr(err, "resolve revision %q in %s", resolved, sp.Ref)
196 1 }
197 109 return commit, resolved, nil
198 }
199
200 // ValidateReadRev enforces the read contract: the read plane serves the approved
201 // head by default, and otherwise only an immutable object name.
202 //
203 // gitx.ResolveRev happily resolves ref names, so without this guard a caller
204 // could pass rev="proposals/42" and have the READ plane hand back unreviewed
205 // proposal content — the single failure this service exists to prevent, since
206 // that text would then flow into agent context as though it were approved.
207 // Reading a proposal branch is a deliberate act belonging to the review path,
208 // not something any read surface can be talked into.
209 //
210 // Object names are required to be full: an abbreviation that is unique today
211 // can become ambiguous later, so a pinned revision would silently stop meaning
212 // one thing.
213 123 func ValidateReadRev(rev string) error {
214 123 if rev == ApprovedRev {
215 14 return nil
216 14 }
217 109 if len(rev) != 40 {
218 9 return fmt.Errorf("%w: revision %q must be a full 40-character object name", ErrBadReadRev, rev)
219 9 }
220 3922 for _, c := range rev {
221 3922 if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
222 2 return fmt.Errorf("%w: revision %q must be a full 40-character object name", ErrBadReadRev, rev)
223 2 }
224 }
225 98 return nil
226 }
227
228 // readErr maps a gitx failure onto this package's sentinels so callers above
229 // service/ can branch on it without importing gitx. ErrNotFound and ErrBadRev
230 // both become ErrNotFound at this boundary — a crafted revision must not be
231 // able to tell "malformed" from "absent" by probing — while the original class
232 // stays in the chain for logs and for gitx-aware callers.
233 2 func readErr(err error, format string, args ...any) error {
234 2 what := fmt.Sprintf(format, args...)
235 2 switch {
236 2 case errors.Is(err, gitx.ErrNotFound), errors.Is(err, gitx.ErrBadRev):
237 2 return fmt.Errorf("%w: %s: %w", ErrNotFound, what, err)
238 0 default:
239 0 return fmt.Errorf("service: %s: %w", what, err)
240 }
241 }