Skip to main content

max / makenotwork

24.8 KB · 683 lines History Blame Raw
1 //! JSON API for `refs/notes/*`.
2 //!
3 //! <!-- wiki: mnw-server-git-notes -->
4 //!
5 //! The point of this surface is tooling: a build script writing its result onto
6 //! the commit it built, a review bot recording a verdict. It adds no semantics.
7 //! Reads come from the repository through `crate::git::notes`, writes go through
8 //! the same `write_note` the browser form uses, and the authorization, the
9 //! namespace rules and the committer identity are `notes_write`'s, called rather
10 //! than restated.
11 //!
12 //! **Reads answer from the repository, not from the index.** The one exception
13 //! is search, which is a full-text query the tree cannot serve; it reports
14 //! whether the index has ever seen this repository so a caller can tell "no
15 //! matches" from "nothing indexed yet" (`indexed` in the response).
16 //!
17 //! **Writes authenticate by push-scoped personal access token only.** A session
18 //! cookie is refused, exactly as `receive-pack` refuses one (`raw.rs`
19 //! `authorize_push`). These routes cannot carry a CSRF token, since the caller
20 //! is a script rather than a page MNW rendered, so they are registered
21 //! CSRF-skipped; a cookie would then be an ambient credential behind a route
22 //! with no CSRF seal. `Authorization: Basic` cannot be set by a cross-site form,
23 //! so requiring it is the seal. Reads accept either, which is what the browse
24 //! path already does and costs nothing: a GET changes nothing and no CORS policy
25 //! lets another origin read the response.
26
27 use axum::{
28 Json,
29 extract::{Path, Query, State},
30 http::{HeaderMap, StatusCode},
31 response::IntoResponse,
32 };
33 use chrono::{DateTime, Utc};
34 use serde::{Deserialize, Serialize};
35 use sqlx::PgPool;
36 use utoipa::ToSchema;
37
38 use crate::{
39 auth::MaybeUserVerified,
40 config::Config,
41 db::{self, GitRepoId},
42 error::{AppError, Result},
43 git::notes::{self, GixEngine, Oid},
44 routes::git::{GitHttpPrincipal, ResolvedRepo, notes_index, notes_write, resolve_repo},
45 validation,
46 };
47
48 /// How far back the attribution walk may look, matching the commit page's
49 /// bound. It is off by default here: it costs a second history walk per note,
50 /// and a script reading notes in bulk has no use for who typed them.
51 const ATTRIBUTION_MAX_COMMITS: usize = 50;
52
53 /// Default and ceiling for `limit` on search. The ceiling exists because the
54 /// index holds whole note bodies, so a large page is a large response.
55 const SEARCH_LIMIT_DEFAULT: i64 = 50;
56 const SEARCH_LIMIT_MAX: i64 = 200;
57
58 // --- Response shapes ---
59
60 /// One notes namespace as the repository holds it.
61 #[derive(Serialize, ToSchema)]
62 pub(crate) struct NamespaceEntry {
63 /// Namespace as a person says it: `commits`, `review/security`.
64 pub name: String,
65 /// The ref it lives on, for a caller assembling a fetch refspec.
66 pub git_ref: String,
67 /// Object id the ref points at.
68 pub tip: String,
69 /// Notes in the namespace.
70 pub notes: i64,
71 }
72
73 #[derive(Serialize, ToSchema)]
74 pub(crate) struct NamespacesResponse {
75 pub data: Vec<NamespaceEntry>,
76 }
77
78 /// Who wrote the note and when, from the notes ref's own history.
79 #[derive(Serialize, ToSchema)]
80 pub(crate) struct NoteAttribution {
81 /// The notes commit that set the note to what it says now.
82 pub commit: String,
83 pub name: String,
84 pub email: String,
85 pub at: DateTime<Utc>,
86 /// False when the bounded walk ran out before finding the change, so the
87 /// commit named is as far back as it looked rather than the one responsible.
88 pub exact: bool,
89 }
90
91 #[derive(Serialize, ToSchema)]
92 pub(crate) struct NoteResponse {
93 pub namespace: String,
94 /// The annotated object. Need not be a commit: notes on blobs and trees are
95 /// legal and this returns them.
96 pub target: String,
97 /// The blob holding the content.
98 pub blob: String,
99 /// Note content. Note bodies are bytes, not text; anything that is not UTF-8
100 /// is replaced rather than rejected, since a note git accepted has to be
101 /// readable here.
102 pub content: String,
103 /// Present only when the request asked for it.
104 pub attribution: Option<NoteAttribution>,
105 }
106
107 /// What a write did.
108 #[derive(Serialize, ToSchema)]
109 pub(crate) struct WriteResponse {
110 pub namespace: String,
111 pub target: String,
112 /// `written` when the ref moved, `unchanged` when the namespace already said
113 /// exactly this. Re-putting an identical note is not an error and costs no
114 /// commit.
115 pub status: &'static str,
116 /// Somebody else annotated the same target while this write was in flight
117 /// and the two were merged, so the stored note is not byte-for-byte what was
118 /// sent. Re-read it if that matters.
119 pub merged: bool,
120 /// Where the namespace points now, absent when nothing was written.
121 pub tip: Option<String>,
122 }
123
124 /// One search hit, out of the index.
125 #[derive(Serialize, ToSchema)]
126 pub(crate) struct SearchHit {
127 pub namespace: String,
128 pub target: String,
129 pub blob: String,
130 pub content: String,
131 /// Whether the annotated object is a commit. False for a note on a blob or
132 /// a tree, where `summary` and `time` are empty.
133 pub target_is_commit: bool,
134 pub summary: String,
135 pub time: Option<DateTime<Utc>>,
136 pub updated_at: DateTime<Utc>,
137 pub updated_by: String,
138 }
139
140 #[derive(Serialize, ToSchema)]
141 pub(crate) struct SearchResponse {
142 pub data: Vec<SearchHit>,
143 /// False when the index has never seen this repository, which makes an
144 /// empty `data` mean "not searchable yet" rather than "no matches". The
145 /// repository still holds its notes and every other endpoint here returns
146 /// them; only search needs the index.
147 pub indexed: bool,
148 }
149
150 // --- Request shapes ---
151
152 #[derive(Deserialize, ToSchema)]
153 pub(crate) struct NamespaceQuery {
154 /// Defaults to `commits`, git's own default namespace.
155 pub namespace: Option<String>,
156 }
157
158 #[derive(Deserialize, ToSchema)]
159 pub(crate) struct GetNoteQuery {
160 pub namespace: Option<String>,
161 /// Ask for the attribution walk. Off by default because it costs a walk of
162 /// the notes ref per note.
163 #[serde(default)]
164 pub attribution: bool,
165 }
166
167 #[derive(Deserialize, ToSchema)]
168 pub(crate) struct PutNoteRequest {
169 pub namespace: Option<String>,
170 /// The note body. Trailing whitespace is trimmed and a newline appended, the
171 /// same shape git's own notes carry.
172 pub content: String,
173 }
174
175 #[derive(Deserialize, ToSchema)]
176 pub(crate) struct SearchQuery {
177 /// The query, in `websearch_to_tsquery` syntax: bare words, `"quoted
178 /// phrases"`, `or`, and `-excluded`.
179 pub q: String,
180 pub namespace: Option<String>,
181 /// Restrict to notes on commits, dropping notes on blobs and trees.
182 #[serde(default)]
183 pub commits_only: bool,
184 pub limit: Option<i64>,
185 }
186
187 // --- Handlers ---
188
189 /// `GET /api/git/{owner}/{repo}/notes`: the namespaces this repository carries.
190 #[utoipa::path(
191 get,
192 path = "/api/git/{owner}/{repo}/notes",
193 tag = "Git Notes",
194 params(
195 ("owner" = String, Path, description = "Repository owner's username"),
196 ("repo" = String, Path, description = "Repository name"),
197 ),
198 responses(
199 (status = 200, description = "Namespaces, with a note count each", body = NamespacesResponse),
200 (status = 404, description = "No such repository, or not visible to the caller"),
201 ),
202 )]
203 #[tracing::instrument(skip_all, name = "api::git_notes::list_namespaces")]
204 pub(crate) async fn list_namespaces(
205 State(db): State<PgPool>,
206 State(config): State<Config>,
207 MaybeUserVerified(maybe_user): MaybeUserVerified,
208 Path((owner, repo_name)): Path<(String, String)>,
209 headers: HeaderMap,
210 ) -> Result<impl IntoResponse> {
211 let resolved = read_repo(&db, &config, &owner, &repo_name, &headers, maybe_user).await?;
212
213 let data = resolved
214 .with_repo(|gix_repo| {
215 let engine = GixEngine::new(gix_repo);
216 let namespaces = notes::list_namespaces(&engine).map_err(crate::git::GitError::from)?;
217 let mut out = Vec::with_capacity(namespaces.len());
218 for ns in namespaces {
219 // Counted from the tree rather than from the index: the index
220 // may be cold or behind, and a count that disagrees with what
221 // the next request returns is worse than a slightly dearer one.
222 let count =
223 notes::count_notes(&engine, ns.tip).map_err(crate::git::GitError::from)?;
224 out.push(NamespaceEntry {
225 name: ns.name,
226 git_ref: ns.full_ref,
227 tip: ns.tip.to_hex(),
228 notes: count as i64,
229 });
230 }
231 Ok(out)
232 })
233 .await?;
234
235 Ok(Json(NamespacesResponse { data }))
236 }
237
238 /// `GET /api/git/{owner}/{repo}/notes/{target}`: one note.
239 #[utoipa::path(
240 get,
241 path = "/api/git/{owner}/{repo}/notes/{target}",
242 tag = "Git Notes",
243 params(
244 ("owner" = String, Path, description = "Repository owner's username"),
245 ("repo" = String, Path, description = "Repository name"),
246 ("target" = String, Path, description = "Full object id of the annotated object"),
247 ("namespace" = Option<String>, Query, description = "Notes namespace, default `commits`"),
248 ("attribution" = Option<bool>, Query, description = "Include who wrote the note; costs a bounded walk of the notes ref"),
249 ),
250 responses(
251 (status = 200, description = "The note", body = NoteResponse),
252 (status = 404, description = "No such repository, namespace, or note"),
253 ),
254 )]
255 #[tracing::instrument(skip_all, name = "api::git_notes::get_note")]
256 pub(crate) async fn get_note(
257 State(db): State<PgPool>,
258 State(config): State<Config>,
259 MaybeUserVerified(maybe_user): MaybeUserVerified,
260 Path((owner, repo_name, target_hex)): Path<(String, String, String)>,
261 Query(query): Query<GetNoteQuery>,
262 headers: HeaderMap,
263 ) -> Result<impl IntoResponse> {
264 let namespace = namespace_or_default(query.namespace.as_deref());
265 let target = parse_target(&target_hex)?;
266 let resolved = read_repo(&db, &config, &owner, &repo_name, &headers, maybe_user).await?;
267
268 let want_attribution = query.attribution;
269 let ns_for_repo = namespace.clone();
270 let note = resolved
271 .with_repo(move |gix_repo| {
272 let engine = GixEngine::new(gix_repo);
273 // A namespace that does not exist and a namespace with no note on
274 // this target are the same 404: both mean the repository holds
275 // nothing here, and distinguishing them would tell an anonymous
276 // caller which namespaces a repository carries by another route.
277 let Some(ns) = notes::resolve_namespace(&engine, &ns_for_repo)
278 .map_err(crate::git::GitError::from)?
279 else {
280 return Err(AppError::NotFound);
281 };
282 let Some(note) =
283 notes::note_for(&engine, ns.tip, target).map_err(crate::git::GitError::from)?
284 else {
285 return Err(AppError::NotFound);
286 };
287
288 let attribution = if want_attribution {
289 notes::attribution(&engine, ns.tip, target, ATTRIBUTION_MAX_COMMITS)
290 .map_err(crate::git::GitError::from)?
291 .map(|a| NoteAttribution {
292 commit: a.note_commit.to_hex(),
293 name: a.by.name,
294 email: a.by.email,
295 at: a.by.time,
296 exact: a.exact,
297 })
298 } else {
299 None
300 };
301
302 Ok(NoteResponse {
303 namespace: ns.name,
304 target: note.target.to_hex(),
305 blob: note.blob.to_hex(),
306 content: note.content_lossy().into_owned(),
307 attribution,
308 })
309 })
310 .await?;
311
312 Ok(Json(note))
313 }
314
315 /// `PUT /api/git/{owner}/{repo}/notes/{target}`: add or replace a note.
316 #[utoipa::path(
317 put,
318 path = "/api/git/{owner}/{repo}/notes/{target}",
319 tag = "Git Notes",
320 params(
321 ("owner" = String, Path, description = "Repository owner's username"),
322 ("repo" = String, Path, description = "Repository name"),
323 ("target" = String, Path, description = "Full object id of the commit to annotate"),
324 ),
325 request_body = PutNoteRequest,
326 responses(
327 (status = 200, description = "What the write did", body = WriteResponse),
328 (status = 401, description = "No credential; writes need a push-scoped personal access token"),
329 (status = 403, description = "A session cookie, a read-only token, or an account that cannot push here"),
330 (status = 404, description = "No such repository, or no such commit in it"),
331 (status = 422, description = "Reserved namespace, empty or oversized content, or sustained write contention"),
332 ),
333 )]
334 #[tracing::instrument(skip_all, name = "api::git_notes::put_note")]
335 pub(crate) async fn put_note(
336 State(db): State<PgPool>,
337 State(config): State<Config>,
338 Path((owner, repo_name, target_hex)): Path<(String, String, String)>,
339 headers: HeaderMap,
340 Json(request): Json<PutNoteRequest>,
341 ) -> Result<impl IntoResponse> {
342 let namespace = namespace_or_default(request.namespace.as_deref());
343 validation::validate_note_namespace(&namespace)?;
344 validation::validate_note_content(&request.content)?;
345
346 let mut content = request.content.trim_end().to_string();
347 content.push('\n');
348
349 write(
350 &db,
351 &config,
352 &owner,
353 &repo_name,
354 &target_hex,
355 &headers,
356 namespace,
357 Some(content),
358 )
359 .await
360 .map(Json)
361 }
362
363 /// `DELETE /api/git/{owner}/{repo}/notes/{target}`: remove a note.
364 #[utoipa::path(
365 delete,
366 path = "/api/git/{owner}/{repo}/notes/{target}",
367 tag = "Git Notes",
368 params(
369 ("owner" = String, Path, description = "Repository owner's username"),
370 ("repo" = String, Path, description = "Repository name"),
371 ("target" = String, Path, description = "Full object id of the annotated commit"),
372 ("namespace" = Option<String>, Query, description = "Notes namespace, default `commits`"),
373 ),
374 responses(
375 (status = 204, description = "The note is gone, whether or not it was there"),
376 (status = 401, description = "No credential; writes need a push-scoped personal access token"),
377 (status = 403, description = "A session cookie, a read-only token, or an account that cannot push here"),
378 (status = 404, description = "No such repository, or no such commit in it"),
379 ),
380 )]
381 #[tracing::instrument(skip_all, name = "api::git_notes::delete_note")]
382 pub(crate) async fn delete_note(
383 State(db): State<PgPool>,
384 State(config): State<Config>,
385 Path((owner, repo_name, target_hex)): Path<(String, String, String)>,
386 Query(query): Query<NamespaceQuery>,
387 headers: HeaderMap,
388 ) -> Result<impl IntoResponse> {
389 let namespace = namespace_or_default(query.namespace.as_deref());
390 validation::validate_note_namespace(&namespace)?;
391
392 write(
393 &db,
394 &config,
395 &owner,
396 &repo_name,
397 &target_hex,
398 &headers,
399 namespace,
400 None,
401 )
402 .await?;
403
404 // Deleting a note that was not there is a no-op rather than a 404: the
405 // caller asked for a state, and the state holds.
406 Ok(StatusCode::NO_CONTENT)
407 }
408
409 /// `GET /api/git/{owner}/{repo}/notes/search`: full-text search over the index.
410 ///
411 /// `search` is a static segment and an object id is 40 or 64 hex characters, so
412 /// it can never be shadowed by, or shadow, a real target on `/notes/{target}`.
413 #[utoipa::path(
414 get,
415 path = "/api/git/{owner}/{repo}/notes/search",
416 tag = "Git Notes",
417 params(
418 ("owner" = String, Path, description = "Repository owner's username"),
419 ("repo" = String, Path, description = "Repository name"),
420 ("q" = String, Query, description = "Query: bare words, quoted phrases, `or`, `-excluded`"),
421 ("namespace" = Option<String>, Query, description = "Restrict to one namespace"),
422 ("commits_only" = Option<bool>, Query, description = "Drop notes on blobs and trees"),
423 ("limit" = Option<i64>, Query, description = "Maximum hits, default 50, capped at 200"),
424 ),
425 responses(
426 (status = 200, description = "Matching notes, and whether the index has seen this repository", body = SearchResponse),
427 (status = 404, description = "No such repository, or not visible to the caller"),
428 ),
429 )]
430 #[tracing::instrument(skip_all, name = "api::git_notes::search_notes")]
431 pub(crate) async fn search_notes(
432 State(db): State<PgPool>,
433 State(config): State<Config>,
434 MaybeUserVerified(maybe_user): MaybeUserVerified,
435 Path((owner, repo_name)): Path<(String, String)>,
436 Query(query): Query<SearchQuery>,
437 headers: HeaderMap,
438 ) -> Result<impl IntoResponse> {
439 let resolved = read_repo(&db, &config, &owner, &repo_name, &headers, maybe_user).await?;
440 let repo_id: GitRepoId = resolved.db_repo.id;
441
442 let indexed = db::git_notes::is_indexed(&db, repo_id).await?;
443 let limit = query
444 .limit
445 .unwrap_or(SEARCH_LIMIT_DEFAULT)
446 .clamp(1, SEARCH_LIMIT_MAX);
447 let term = query.q.trim();
448
449 // An empty query matches everything in `websearch_to_tsquery`, which is not
450 // what an empty search box means. Answer it as no hits rather than as the
451 // whole index.
452 let rows = if term.is_empty() {
453 Vec::new()
454 } else {
455 db::git_notes::search(
456 &db,
457 repo_id,
458 term,
459 query.namespace.as_deref(),
460 query.commits_only,
461 limit,
462 )
463 .await?
464 };
465
466 let data = rows
467 .into_iter()
468 .map(|n| SearchHit {
469 namespace: n.namespace,
470 target: n.target_oid,
471 blob: n.blob_oid,
472 content: n.content,
473 target_is_commit: n.target_is_commit,
474 summary: n.target_summary,
475 time: n.target_time,
476 updated_at: n.updated_at,
477 updated_by: n.updated_by,
478 })
479 .collect();
480
481 Ok(Json(SearchResponse { data, indexed }))
482 }
483
484 // --- Shared halves ---
485
486 /// Resolve a repository for a read, honouring either credential.
487 ///
488 /// Visibility is decided by `resolve_repo` on every request rather than
489 /// remembered anywhere: a note inherits the visibility of the repository holding
490 /// it, and that can change between two calls.
491 async fn read_repo(
492 db: &PgPool,
493 config: &Config,
494 owner: &str,
495 repo_name: &str,
496 headers: &HeaderMap,
497 maybe_user: Option<crate::auth::SessionUser>,
498 ) -> Result<ResolvedRepo> {
499 let principal =
500 crate::routes::git::resolve_git_http_principal(db, headers, maybe_user.map(|u| u.id)).await;
501 resolve_repo(db, config, owner, repo_name, principal.map(|p| p.user_id)).await
502 }
503
504 /// The write half of PUT and DELETE: authorize, write, reindex.
505 #[allow(clippy::too_many_arguments)]
506 async fn write(
507 db: &PgPool,
508 config: &Config,
509 owner: &str,
510 repo_name: &str,
511 target_hex: &str,
512 headers: &HeaderMap,
513 namespace: String,
514 content: Option<String>,
515 ) -> Result<WriteResponse> {
516 // No session is consulted: a write is token-only, so `None` here is the
517 // whole session half of `resolve_git_http_principal`.
518 let principal = crate::routes::git::resolve_git_http_principal(db, headers, None).await;
519 let principal = require_push_token(principal.as_ref())?;
520
521 // Account standing is already settled: `resolve_git_http_principal` refuses
522 // a suspended or deactivated user. This load is for the note identity.
523 let user = db::users::get_user_by_id(db, principal.user_id)
524 .await?
525 .ok_or(AppError::Unauthorized)?;
526
527 let resolved = resolve_repo(db, config, owner, repo_name, Some(principal.user_id)).await?;
528 if !notes_write::can_write_notes(db, &resolved, principal.user_id).await? {
529 return Err(AppError::Forbidden);
530 }
531
532 let target = parse_target(target_hex)?;
533 let gix_target =
534 gix::ObjectId::from_hex(target_hex.as_bytes()).map_err(|_| AppError::NotFound)?;
535 let who = notes_write::identity(user.display_name.as_deref(), user.username.as_str());
536 let written_namespace = namespace.clone();
537
538 let written = resolved
539 .with_repo(move |gix_repo| {
540 // The same check the browser write makes: a note on an id this
541 // repository does not hold is invisible in the one place it would
542 // have been read.
543 gix_repo
544 .find_commit(gix_target)
545 .map_err(|_| AppError::NotFound)?;
546
547 let engine = GixEngine::new(gix_repo);
548 notes::write_note(
549 &engine,
550 &namespace,
551 target,
552 content.as_deref().map(str::as_bytes),
553 &who,
554 )
555 .map_err(|e| match e {
556 notes::NotesError::Raced => AppError::validation(
557 "Another writer holds this namespace right now. Try again.".to_string(),
558 ),
559 other => crate::git::GitError::from(other).into(),
560 })
561 })
562 .await?;
563
564 // The fourth path that moves `refs/notes/*` server-side, and so the fourth
565 // that owes the index a reindex. A server-side ref write fires no hook.
566 notes_index::reindex_after_write(
567 db,
568 config,
569 resolved.db_repo.id,
570 owner,
571 repo_name,
572 &written_namespace,
573 )
574 .await;
575
576 Ok(match written {
577 notes::Written::Unchanged => WriteResponse {
578 namespace: written_namespace,
579 target: target.to_hex(),
580 status: "unchanged",
581 merged: false,
582 tip: None,
583 },
584 notes::Written::Committed { tip, merged } => WriteResponse {
585 namespace: written_namespace,
586 target: target.to_hex(),
587 status: "written",
588 merged,
589 tip: Some(tip.to_hex()),
590 },
591 })
592 }
593
594 /// Only a push-scoped personal access token may write.
595 ///
596 /// The same gate `receive-pack` applies (`routes::git::raw::require_push_token`),
597 /// and for the same reason: these routes carry no CSRF token, so a cookie would
598 /// be an ambient credential on an unsealed mutation. A read-only token is a 403
599 /// rather than a 401 because the credential was understood and refused.
600 fn require_push_token(principal: Option<&GitHttpPrincipal>) -> Result<&GitHttpPrincipal> {
601 let principal = principal.ok_or(AppError::Unauthorized)?;
602 if principal.token_push != Some(true) {
603 return Err(AppError::Forbidden);
604 }
605 Ok(principal)
606 }
607
608 /// `commits` is git's default namespace and the one a caller who says nothing
609 /// means.
610 fn namespace_or_default(namespace: Option<&str>) -> String {
611 namespace
612 .map(str::trim)
613 .filter(|n| !n.is_empty())
614 .unwrap_or(notes::DEFAULT_NAMESPACE)
615 .to_string()
616 }
617
618 /// Parse a target object id. A malformed id is a 404 rather than a 422: it names
619 /// nothing, which is indistinguishable from naming something absent, and saying
620 /// which would tell an anonymous caller whether an object exists.
621 fn parse_target(hex: &str) -> Result<Oid> {
622 Oid::from_hex(hex.as_bytes()).map_err(|_| AppError::NotFound)
623 }
624
625 #[cfg(test)]
626 mod tests {
627 use super::*;
628
629 #[test]
630 fn a_missing_namespace_is_gits_own_default() {
631 assert_eq!(namespace_or_default(None), notes::DEFAULT_NAMESPACE);
632 assert_eq!(namespace_or_default(Some(" ")), notes::DEFAULT_NAMESPACE);
633 assert_eq!(
634 namespace_or_default(Some(" review/security ")),
635 "review/security"
636 );
637 }
638
639 #[test]
640 fn only_a_push_scoped_token_may_write() {
641 let user_id = crate::db::UserId::from(uuid::Uuid::nil());
642 let cookie = GitHttpPrincipal {
643 user_id,
644 token_push: None,
645 };
646 let read_only = GitHttpPrincipal {
647 user_id,
648 token_push: Some(false),
649 };
650 let push = GitHttpPrincipal {
651 user_id,
652 token_push: Some(true),
653 };
654
655 // A session cookie is not a write credential here, the same call
656 // `receive-pack` makes. Without this the CSRF-skipped route would take
657 // an ambient credential.
658 assert!(matches!(
659 require_push_token(Some(&cookie)),
660 Err(AppError::Forbidden)
661 ));
662 assert!(matches!(
663 require_push_token(Some(&read_only)),
664 Err(AppError::Forbidden)
665 ));
666 assert!(matches!(
667 require_push_token(None),
668 Err(AppError::Unauthorized)
669 ));
670 assert!(require_push_token(Some(&push)).is_ok());
671 }
672
673 #[test]
674 fn a_target_that_is_not_a_full_object_id_is_not_found() {
675 assert!(parse_target("not-hex").is_err());
676 // A prefix is refused rather than resolved: note tree paths are always
677 // full ids, and accepting a short one would mean guessing.
678 assert!(parse_target("0123abc").is_err());
679 assert!(parse_target(&"a".repeat(40)).is_ok());
680 assert!(parse_target(&"a".repeat(64)).is_ok());
681 }
682 }
683