| 1 |
|
package service |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"encoding/hex" |
| 6 |
|
"fmt" |
| 7 |
|
"sort" |
| 8 |
|
"strings" |
| 9 |
|
|
| 10 |
|
"github.com/go-git/go-git/v5/plumbing" |
| 11 |
|
|
| 12 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/authn" |
| 13 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/core" |
| 14 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/db" |
| 15 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/gitx" |
| 16 |
|
) |
| 17 |
|
|
| 18 |
|
// PushRequest is one proposed ref update, as the `update` hook sees it and |
| 19 |
|
// forwards it to the daemon over the localhost RPC. |
| 20 |
|
// |
| 21 |
|
// Object names are hex strings rather than plumbing.Hash so hooks/ can build |
| 22 |
|
// this straight from the hook's argv without importing gitx, and so an |
| 23 |
|
// unparseable value is a rejection rather than something that silently becomes |
| 24 |
|
// the zero hash — which the refs rule would read as "creating a branch". |
| 25 |
|
type PushRequest struct { |
| 26 |
|
// Space is the space being pushed to. |
| 27 |
|
Space core.SpaceRef |
| 28 |
|
|
| 29 |
|
// Principal is who is pushing, as authn resolved the SSH key or token the |
| 30 |
|
// forced command was invoked with. An anonymous principal is refused: there |
| 31 |
|
// is no unauthenticated write path. |
| 32 |
|
Principal authn.Principal |
| 33 |
|
|
| 34 |
|
// Ref is the full ref name, "refs/heads/main". |
| 35 |
|
Ref string |
| 36 |
|
|
| 37 |
|
// Old is the value the ref currently holds. Empty or the all-zero object |
| 38 |
|
// name means the ref is being created. |
| 39 |
|
Old string |
| 40 |
|
|
| 41 |
|
// New is the value proposed. Empty or the all-zero object name means the |
| 42 |
|
// ref is being deleted. |
| 43 |
|
New string |
| 44 |
|
|
| 45 |
|
// SkipValidation carries `--push-option=skip-validation`. |
| 46 |
|
// |
| 47 |
|
// It waives frontmatter and document-id validation and nothing else. The |
| 48 |
|
// refs rule is never skippable: the escape hatch exists so a hook bug or a |
| 49 |
|
// bad schema cannot lock the owner out of their own repository, not so that |
| 50 |
|
// an agent can reach the approved branch. |
| 51 |
|
SkipValidation bool |
| 52 |
|
} |
| 53 |
|
|
| 54 |
|
// PushProblemKind classifies one reason a push was refused, so the API and the |
| 55 |
|
// review UI can branch on it without parsing the message. |
| 56 |
|
type PushProblemKind string |
| 57 |
|
|
| 58 |
|
const ( |
| 59 |
|
// ProblemRefsRule is the refs rule refusing this principal this ref. Never |
| 60 |
|
// skippable. |
| 61 |
|
ProblemRefsRule PushProblemKind = "refs-rule" |
| 62 |
|
|
| 63 |
|
// ProblemFrontmatter is a document whose frontmatter is missing, |
| 64 |
|
// unparseable, or fails the space's schema. |
| 65 |
|
ProblemFrontmatter PushProblemKind = "frontmatter" |
| 66 |
|
|
| 67 |
|
// ProblemDuplicateID is two documents in the pushed tree carrying one id. |
| 68 |
|
ProblemDuplicateID PushProblemKind = "duplicate-id" |
| 69 |
|
|
| 70 |
|
// ProblemIDCollision is a document id already registered to another space. |
| 71 |
|
ProblemIDCollision PushProblemKind = "id-collision" |
| 72 |
|
) |
| 73 |
|
|
| 74 |
|
// PushProblem is one reason a push was refused. |
| 75 |
|
type PushProblem struct { |
| 76 |
|
// Kind is the class of failure. |
| 77 |
|
Kind PushProblemKind |
| 78 |
|
|
| 79 |
|
// Path is the offending document, empty for a problem that is not about |
| 80 |
|
// one document (the refs rule). |
| 81 |
|
Path string |
| 82 |
|
|
| 83 |
|
// Detail is one line naming what is wrong, written for the human reading |
| 84 |
|
// their terminal after a rejected `git push`. |
| 85 |
|
Detail string |
| 86 |
|
} |
| 87 |
|
|
| 88 |
|
// PushRejection is the structured refusal the `update` hook prints and exits |
| 89 |
|
// non-zero on. It wraps ErrPushRejected, so callers match the class with |
| 90 |
|
// errors.Is and type-assert only when they want the detail. |
| 91 |
|
type PushRejection struct { |
| 92 |
|
Space core.SpaceRef |
| 93 |
|
Ref string |
| 94 |
|
Problems []PushProblem |
| 95 |
|
|
| 96 |
|
// Skippable reports whether `--push-option=skip-validation` would let this |
| 97 |
|
// push through. It is false whenever any problem is a refs-rule violation, |
| 98 |
|
// and the message says so rather than suggesting a flag that will not help. |
| 99 |
|
Skippable bool |
| 100 |
|
} |
| 101 |
|
|
| 102 |
4 |
func (e *PushRejection) Is(target error) bool { return target == ErrPushRejected } |
| 103 |
|
|
| 104 |
|
// Error renders the rejection as the message a human sees on their terminal |
| 105 |
|
// after `git push`. Every line is short enough to survive git's "remote: " |
| 106 |
|
// prefix in an 80-column terminal, and the offending document is always named |
| 107 |
|
// first, because "which file" is the first thing anybody wants to know. |
| 108 |
3 |
func (e *PushRejection) Error() string { |
| 109 |
3 |
var b strings.Builder |
| 110 |
3 |
fmt.Fprintf(&b, "spec.sr.ht rejected this push.\n\n") |
| 111 |
3 |
fmt.Fprintf(&b, " space: %s\n", e.Space) |
| 112 |
3 |
fmt.Fprintf(&b, " ref: %s\n\n", e.Ref) |
| 113 |
4 |
for _, p := range e.Problems { |
| 114 |
4 |
if p.Path != "" { |
| 115 |
3 |
fmt.Fprintf(&b, " %s\n %s\n", p.Path, p.Detail) |
| 116 |
3 |
continue |
| 117 |
|
} |
| 118 |
1 |
fmt.Fprintf(&b, " %s\n", p.Detail) |
| 119 |
|
} |
| 120 |
3 |
fmt.Fprintf(&b, "\n%s. Nothing was written; the ref still points where it did.\n", |
| 121 |
3 |
plural(len(e.Problems), "problem")) |
| 122 |
3 |
if e.Skippable { |
| 123 |
2 |
b.WriteString("Re-push with --push-option=skip-validation to bypass frontmatter\n") |
| 124 |
2 |
b.WriteString("and document-id validation.\n") |
| 125 |
2 |
} else { |
| 126 |
1 |
b.WriteString("The refs rule cannot be bypassed: --push-option=skip-validation\n") |
| 127 |
1 |
b.WriteString("waives frontmatter and document-id validation only.\n") |
| 128 |
1 |
} |
| 129 |
3 |
return b.String() |
| 130 |
|
} |
| 131 |
|
|
| 132 |
3 |
func plural(n int, what string) string { |
| 133 |
3 |
if n == 1 { |
| 134 |
2 |
return fmt.Sprintf("1 %s", what) |
| 135 |
2 |
} |
| 136 |
1 |
return fmt.Sprintf("%d %ss", n, what) |
| 137 |
|
} |
| 138 |
|
|
| 139 |
|
// ValidatePush is what the `update` hook calls, per ref, before the ref moves. |
| 140 |
|
// |
| 141 |
|
// It answers in two parts, and the split is the design's: |
| 142 |
|
// |
| 143 |
|
// 1. The refs rule — may this principal move this ref? Always checked, never |
| 144 |
|
// skippable, and checked first so that a rejection for the right reason is |
| 145 |
|
// not preceded by pages of schema complaints. |
| 146 |
|
// 2. Frontmatter and document-id validation of everything this push changes. |
| 147 |
|
// Waived by SkipValidation, because a hook bug or a bad schema must never |
| 148 |
|
// be able to lock the owner out of their own repository. |
| 149 |
|
// |
| 150 |
|
// A nil return means the push may proceed. A *PushRejection means it must not, |
| 151 |
|
// and its Error() is the text to print. Any other error is an infrastructure |
| 152 |
|
// failure — Postgres down, repository unreadable — and the hook must fail |
| 153 |
|
// closed on it: a rejected push is recoverable in one command, while a silently |
| 154 |
|
// unvalidated one is a corruption discovered much later. |
| 155 |
5 |
func (s *Service) ValidatePush(ctx context.Context, req PushRequest) error { |
| 156 |
5 |
sp, err := s.OpenSpace(ctx, req.Space) |
| 157 |
5 |
if err != nil { |
| 158 |
0 |
return err |
| 159 |
0 |
} |
| 160 |
|
|
| 161 |
5 |
oldHash, err := parseObjectName("old", req.Old) |
| 162 |
5 |
if err != nil { |
| 163 |
0 |
return err |
| 164 |
0 |
} |
| 165 |
5 |
newHash, err := parseObjectName("new", req.New) |
| 166 |
5 |
if err != nil { |
| 167 |
0 |
return err |
| 168 |
0 |
} |
| 169 |
|
|
| 170 |
5 |
if problem := s.checkRefsRule(ctx, sp, req, oldHash, newHash); problem != nil { |
| 171 |
1 |
return &PushRejection{ |
| 172 |
1 |
Space: req.Space, |
| 173 |
1 |
Ref: req.Ref, |
| 174 |
1 |
Problems: []PushProblem{*problem}, |
| 175 |
1 |
Skippable: false, |
| 176 |
1 |
} |
| 177 |
1 |
} |
| 178 |
|
|
| 179 |
|
// A deletion leaves no tree to validate, and skip-validation waives |
| 180 |
|
// everything that is left. Both still went through the refs rule above. |
| 181 |
4 |
if newHash.IsZero() || req.SkipValidation { |
| 182 |
1 |
return nil |
| 183 |
1 |
} |
| 184 |
|
|
| 185 |
3 |
problems, err := s.validateContent(ctx, sp, oldHash, newHash) |
| 186 |
3 |
if err != nil { |
| 187 |
0 |
return err |
| 188 |
0 |
} |
| 189 |
3 |
if len(problems) > 0 { |
| 190 |
2 |
return &PushRejection{ |
| 191 |
2 |
Space: req.Space, |
| 192 |
2 |
Ref: req.Ref, |
| 193 |
2 |
Problems: problems, |
| 194 |
2 |
Skippable: true, |
| 195 |
2 |
} |
| 196 |
2 |
} |
| 197 |
1 |
return nil |
| 198 |
|
} |
| 199 |
|
|
| 200 |
|
// checkRefsRule applies gitx.CheckRefUpdate, computing the fast-forward fact it |
| 201 |
|
// cannot compute itself. It returns nil when the update is permitted. |
| 202 |
13 |
func (s *Service) checkRefsRule(ctx context.Context, sp *Space, req PushRequest, old, new plumbing.Hash) *PushProblem { |
| 203 |
13 |
kind, err := principalKind(req.Principal) |
| 204 |
13 |
if err != nil { |
| 205 |
0 |
return &PushProblem{Kind: ProblemRefsRule, Detail: err.Error()} |
| 206 |
0 |
} |
| 207 |
|
|
| 208 |
|
// Ancestry is only meaningful when both ends name a commit. A creation has |
| 209 |
|
// no old value and a deletion has no new one; gitx treats a creation as a |
| 210 |
|
// fast-forward and ignores the flag entirely for a deletion. |
| 211 |
13 |
fastForward := old.IsZero() |
| 212 |
13 |
if !old.IsZero() && !new.IsZero() { |
| 213 |
9 |
ff, err := sp.Repo.IsAncestor(ctx, old, new) |
| 214 |
9 |
if err != nil { |
| 215 |
0 |
// Not knowing whether this is a fast-forward is not permission to |
| 216 |
0 |
// assume it is: an unreadable object must refuse the push, not |
| 217 |
0 |
// wave through a force-update of the approved branch. |
| 218 |
0 |
return &PushProblem{ |
| 219 |
0 |
Kind: ProblemRefsRule, |
| 220 |
0 |
Detail: fmt.Sprintf("cannot determine whether %s..%s is a fast-forward: %v", old, new, err), |
| 221 |
0 |
} |
| 222 |
0 |
} |
| 223 |
9 |
fastForward = ff |
| 224 |
|
} |
| 225 |
|
|
| 226 |
13 |
err = gitx.CheckRefUpdate(kind, sp.ApprovedBranch(), gitx.RefUpdate{ |
| 227 |
13 |
Ref: req.Ref, |
| 228 |
13 |
Old: old, |
| 229 |
13 |
New: new, |
| 230 |
13 |
FastForward: fastForward, |
| 231 |
13 |
}) |
| 232 |
13 |
if err != nil { |
| 233 |
6 |
return &PushProblem{Kind: ProblemRefsRule, Detail: err.Error()} |
| 234 |
6 |
} |
| 235 |
7 |
return nil |
| 236 |
|
} |
| 237 |
|
|
| 238 |
|
// validateContent validates the frontmatter of every document this push changes |
| 239 |
|
// and checks document-id uniqueness, both within the pushed tree and against |
| 240 |
|
// the global registry. |
| 241 |
3 |
func (s *Service) validateContent(ctx context.Context, sp *Space, old, new plumbing.Hash) ([]PushProblem, error) { |
| 242 |
3 |
all, changed, err := s.changedDocuments(ctx, sp, old, new) |
| 243 |
3 |
if err != nil { |
| 244 |
0 |
return nil, err |
| 245 |
0 |
} |
| 246 |
|
|
| 247 |
|
// The schema is read at the *new* revision, so a push that edits .spec.yml |
| 248 |
|
// is validated against the policy it is installing. Validating against the |
| 249 |
|
// old one would make a schema change and the documents that satisfy it |
| 250 |
|
// impossible to land in a single push. |
| 251 |
3 |
policy, err := s.Policy(ctx, sp, new.String()) |
| 252 |
3 |
if err != nil { |
| 253 |
0 |
return nil, err |
| 254 |
0 |
} |
| 255 |
|
|
| 256 |
3 |
problems, refs := validateDocuments(all, changed, policy.Schema) |
| 257 |
3 |
|
| 258 |
3 |
collisions, err := s.store.CheckDocIDCollisions(ctx, sp.ID, refs) |
| 259 |
3 |
if err != nil { |
| 260 |
0 |
return nil, fmt.Errorf("service: check document id collisions for %s: %w", sp.Ref, err) |
| 261 |
0 |
} |
| 262 |
3 |
byID := make(map[string]string, len(refs)) |
| 263 |
3 |
for _, r := range refs { |
| 264 |
2 |
byID[r.ID.String()] = r.Path |
| 265 |
2 |
} |
| 266 |
3 |
for _, c := range collisions { |
| 267 |
1 |
owner, err := s.store.GetSpaceByID(ctx, c.Existing.SpaceID) |
| 268 |
1 |
if err != nil { |
| 269 |
0 |
return nil, fmt.Errorf("service: resolve space %d holding document id %s: %w", |
| 270 |
0 |
c.Existing.SpaceID, c.DocID, err) |
| 271 |
0 |
} |
| 272 |
1 |
problems = append(problems, PushProblem{ |
| 273 |
1 |
Kind: ProblemIDCollision, |
| 274 |
1 |
Path: byID[c.DocID.String()], |
| 275 |
1 |
Detail: fmt.Sprintf("id %s is already registered to %s at %s", |
| 276 |
1 |
c.DocID, owner.Ref, c.Existing.Path), |
| 277 |
1 |
}) |
| 278 |
|
} |
| 279 |
3 |
sort.SliceStable(problems, func(i, j int) bool { return problems[i].Path < problems[j].Path }) |
| 280 |
3 |
return problems, nil |
| 281 |
|
} |
| 282 |
|
|
| 283 |
|
// changedDocuments returns every document at the new revision, and the subset |
| 284 |
|
// of them this push changes. |
| 285 |
|
// |
| 286 |
|
// The baseline is the ref's old value, or — when the ref is being created — the |
| 287 |
|
// space's approved head. A brand-new proposal branch is cut from the approved |
| 288 |
|
// branch, so comparing it against nothing would revalidate the entire space and |
| 289 |
|
// let one document that was pushed with --push-option=skip-validation block |
| 290 |
|
// every future proposal branch. |
| 291 |
|
// |
| 292 |
|
// Only the changed subset is schema-validated, for the same reason. Malformed |
| 293 |
|
// documents already on a branch are tolerated rather than fatal: a single typo |
| 294 |
|
// must not become an outage that blocks every later push. |
| 295 |
5 |
func (s *Service) changedDocuments(ctx context.Context, sp *Space, old, new plumbing.Hash) (all, changed []Document, err error) { |
| 296 |
5 |
all, err = s.ListDocuments(ctx, sp, new.String()) |
| 297 |
5 |
if err != nil { |
| 298 |
0 |
return nil, nil, err |
| 299 |
0 |
} |
| 300 |
|
|
| 301 |
5 |
baseline := old.String() |
| 302 |
5 |
if old.IsZero() { |
| 303 |
1 |
baseline = ApprovedRev |
| 304 |
1 |
} |
| 305 |
5 |
before, err := s.ListDocuments(ctx, sp, baseline) |
| 306 |
5 |
if err != nil { |
| 307 |
0 |
return nil, nil, err |
| 308 |
0 |
} |
| 309 |
5 |
prior := make(map[string]string, len(before)) |
| 310 |
5 |
for _, d := range before { |
| 311 |
4 |
prior[d.Path] = d.Blob |
| 312 |
4 |
} |
| 313 |
|
|
| 314 |
8 |
for _, d := range all { |
| 315 |
8 |
if prior[d.Path] != d.Blob { |
| 316 |
5 |
changed = append(changed, d) |
| 317 |
5 |
} |
| 318 |
|
} |
| 319 |
5 |
return all, changed, nil |
| 320 |
|
} |
| 321 |
|
|
| 322 |
|
// validateDocuments is the whole of push validation that needs neither git nor |
| 323 |
|
// Postgres: schema conformance of the changed documents, and id uniqueness |
| 324 |
|
// within the pushed tree. It returns the problems it found and the (id, path) |
| 325 |
|
// refs of the changed documents, which is what the registry check runs against. |
| 326 |
|
// |
| 327 |
|
// A duplicate id is reported only when at least one of the documents carrying |
| 328 |
|
// it is part of this push. Two colliding documents that were both already there |
| 329 |
|
// are somebody's earlier skip-validation typo; rejecting every subsequent push |
| 330 |
|
// until they are fixed would turn a cosmetic error into a lockout, and the fix |
| 331 |
|
// itself would be unpushable. |
| 332 |
10 |
func validateDocuments(all, changed []Document, schema core.Schema) ([]PushProblem, []db.DocRef) { |
| 333 |
10 |
byID := make(map[string][]string) |
| 334 |
15 |
for _, d := range all { |
| 335 |
15 |
fm, _, err := core.ParseDocument(d.Data) |
| 336 |
15 |
if err != nil { |
| 337 |
3 |
continue // reported below if this document is part of the push |
| 338 |
|
} |
| 339 |
12 |
if id, err := core.ParseDocID(fm.ID); err == nil { |
| 340 |
11 |
byID[id.String()] = append(byID[id.String()], d.Path) |
| 341 |
11 |
} |
| 342 |
|
} |
| 343 |
|
|
| 344 |
10 |
var problems []PushProblem |
| 345 |
10 |
var refs []db.DocRef |
| 346 |
10 |
for _, d := range changed { |
| 347 |
10 |
fm, _, err := core.ParseDocument(d.Data) |
| 348 |
10 |
if err != nil { |
| 349 |
2 |
problems = append(problems, PushProblem{ |
| 350 |
2 |
Kind: ProblemFrontmatter, |
| 351 |
2 |
Path: d.Path, |
| 352 |
2 |
Detail: err.Error(), |
| 353 |
2 |
}) |
| 354 |
2 |
continue |
| 355 |
|
} |
| 356 |
8 |
if err := schema.ValidateFrontmatter(fm); err != nil { |
| 357 |
2 |
problems = append(problems, PushProblem{ |
| 358 |
2 |
Kind: ProblemFrontmatter, |
| 359 |
2 |
Path: d.Path, |
| 360 |
2 |
Detail: err.Error(), |
| 361 |
2 |
}) |
| 362 |
2 |
continue |
| 363 |
|
} |
| 364 |
6 |
id, err := core.ParseDocID(fm.ID) |
| 365 |
6 |
if err != nil { |
| 366 |
0 |
// Reachable only when the space's schema does not require `id`. |
| 367 |
0 |
// Such a document is unregistrable but not malformed, so it is not |
| 368 |
0 |
// a problem — it simply contributes nothing to the registry. |
| 369 |
0 |
continue |
| 370 |
|
} |
| 371 |
6 |
if others := without(byID[id.String()], d.Path); len(others) > 0 { |
| 372 |
1 |
problems = append(problems, PushProblem{ |
| 373 |
1 |
Kind: ProblemDuplicateID, |
| 374 |
1 |
Path: d.Path, |
| 375 |
1 |
Detail: fmt.Sprintf("id %s is also carried by %s", |
| 376 |
1 |
id, strings.Join(others, ", ")), |
| 377 |
1 |
}) |
| 378 |
1 |
} |
| 379 |
6 |
refs = append(refs, db.DocRef{ID: id, Path: d.Path}) |
| 380 |
|
} |
| 381 |
10 |
sort.SliceStable(problems, func(i, j int) bool { return problems[i].Path < problems[j].Path }) |
| 382 |
10 |
return problems, refs |
| 383 |
|
} |
| 384 |
|
|
| 385 |
|
// without returns paths with one occurrence of self removed. |
| 386 |
6 |
func without(paths []string, self string) []string { |
| 387 |
6 |
out := make([]string, 0, len(paths)) |
| 388 |
6 |
dropped := false |
| 389 |
7 |
for _, p := range paths { |
| 390 |
7 |
if p == self && !dropped { |
| 391 |
6 |
dropped = true |
| 392 |
6 |
continue |
| 393 |
|
} |
| 394 |
1 |
out = append(out, p) |
| 395 |
|
} |
| 396 |
6 |
if len(out) == 0 { |
| 397 |
5 |
return nil |
| 398 |
5 |
} |
| 399 |
1 |
return out |
| 400 |
|
} |
| 401 |
|
|
| 402 |
|
// principalKind maps a resolved identity onto the two principals the refs rule |
| 403 |
|
// knows about. An anonymous principal is refused rather than mapped to either: |
| 404 |
|
// there is no unauthenticated write path, and defaulting it to "agent" would |
| 405 |
|
// give an unidentified pusher the proposal namespace. |
| 406 |
18 |
func principalKind(p authn.Principal) (gitx.PrincipalKind, error) { |
| 407 |
18 |
switch { |
| 408 |
10 |
case p.IsOwner(): |
| 409 |
10 |
return gitx.PrincipalHuman, nil |
| 410 |
7 |
case p.IsAgent(): |
| 411 |
7 |
return gitx.PrincipalAgent, nil |
| 412 |
1 |
default: |
| 413 |
1 |
return "", fmt.Errorf("no credential identifies this push; %s may not write any ref", p) |
| 414 |
|
} |
| 415 |
|
} |
| 416 |
|
|
| 417 |
|
// zeroObjectName is git's "this ref does not exist" sentinel as the hook spells |
| 418 |
|
// it on the command line. |
| 419 |
|
const zeroObjectName = "0000000000000000000000000000000000000000" |
| 420 |
|
|
| 421 |
|
// parseObjectName converts a hook's hex argument into an object name. The empty |
| 422 |
|
// string and the all-zero name both mean "absent". |
| 423 |
|
// |
| 424 |
|
// plumbing.NewHash is deliberately not used: it maps anything unparseable to |
| 425 |
|
// the zero hash, which the refs rule would read as a branch creation or a |
| 426 |
|
// deletion. A malformed argument is a bug in whatever built the request, and it |
| 427 |
|
// fails here rather than becoming a permitted force-push. |
| 428 |
33 |
func parseObjectName(which, s string) (plumbing.Hash, error) { |
| 429 |
33 |
if s == "" || s == zeroObjectName { |
| 430 |
7 |
return plumbing.ZeroHash, nil |
| 431 |
7 |
} |
| 432 |
26 |
if len(s) != len(zeroObjectName) { |
| 433 |
2 |
return plumbing.ZeroHash, fmt.Errorf("service: %s object name %q is not %d hex digits", |
| 434 |
2 |
which, s, len(zeroObjectName)) |
| 435 |
2 |
} |
| 436 |
24 |
if _, err := hex.DecodeString(s); err != nil { |
| 437 |
1 |
return plumbing.ZeroHash, fmt.Errorf("service: %s object name %q is not hex: %w", which, s, err) |
| 438 |
1 |
} |
| 439 |
23 |
return plumbing.NewHash(s), nil |
| 440 |
|
} |