Skip to content

g1t/services/repos/src/lifecycle.rs

1,757 lines69,656 bytesCodeBlame
1//! A repository's lifecycle after it is made: renaming it, archiving it,
2//! making it public or private, changing or renaming its default branch,
3//! and deleting it with a window to restore it.
4//!
5//! Deleting is soft. The row gets `deleted_at` and `purge_after`
6//! ([`RESTORE_DAYS`] on), every read in the registry leaves it out, git
7//! refuses it, and `repo.deleted` tells every service to stop what runs for
8//! it and hide it. Restoring clears the columns (`repo.restored`). Purging,
9//! by an owner from the Recently deleted list or by the hourly sweep once
10//! `purge_after` has passed, removes the git data from the store, then the
11//! rows (its pull requests' working copies with it) and its redirects, and
12//! announces `repo.purged`, on which services drop what they keep for it.
13//! Until then its name stays taken, so a restore always has its path back.
14//!
15//! A rename is a path change like a transfer: the old path is kept in
16//! `repo_redirects`, the git store key never changes, the tokens of agents
17//! at work on it are moved with identity, and `repo.renamed` is handled by
18//! services with the same helper as `repo.transferred`
19//! (`g1t_kit::transfer`).
20
21use g1t_contracts::audit::{
22 AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface,
23};
24use g1t_contracts::events::{
25 BranchRenamed, NewEvent, RepoArchived, RepoDefaultBranchChanged, RepoDeleted, RepoPurged,
26 RepoRenamed, RepoRestored, RepoUpdated, RepoVisibilityChanged, WorkspaceDeleted,
27 WorkspaceDeleting, WorkspaceRestored,
28};
29use g1t_contracts::identity::TransferRepoScopesArgs;
30use g1t_contracts::repos::{
31 ArchiveArgs, DeleteArgs, DeletedArgs, DeletedRepo, DeletedRepoArgs, PurgeDueArgs,
32 RESTORE_DAYS, RenameArgs, RenameBranchArgs, Repo, RepoPath, RepoStatus, ResolveBranchArgs,
33 SetDefaultBranchArgs, SetVisibilityArgs, StatusByIdArgs, archived_message,
34 is_valid_branch_name,
35};
36use g1t_contracts::access::{self, Capability, RepoRole};
37use g1t_contracts::time::rfc3339;
38use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, is_valid_repo_name, new_id};
39use g1t_kit::now_ms;
40use serde::Deserialize;
41use worker::Result;
42use worker::wasm_bindgen::JsValue;
43
44use crate::registry::{Registry, remember_store, store_key};
45use crate::store::{GitRepo, GitStore, Scope};
46use crate::{Repos, SOURCE, UNVERIFIED, git_ops, land, not_found};
47
48use crate::land::EMPTY_PACK;
49
50/// How many pull request working copies follow a change of the default
51/// branch (newest first). Older ones keep the branch they were made with.
52const FORKS_FOLLOWING: u32 = 100;
53
54/// How many deleted repositories one sweep purges.
55const PURGES_PER_SWEEP: u32 = 25;
56
57type Refusal = (FailureCode, String);
58
59/// Who is asking, as far as an owner's or an admin's action cares.
60#[derive(Clone, Copy, Debug)]
61pub struct Asker {
62 /// A person, not a workspace's or an agent's token.
63 pub person: bool,
64 pub verified: bool,
65 /// Their role in the repository's workspace.
66 pub role: Option<Role>,
67 /// Their role on the repository itself (see g1t_contracts::access).
68 /// None where only the workspace is known, as for deleted ones.
69 pub repo_role: Option<RepoRole>,
70 /// The workspace's member privileges, as their membership carries them.
71 pub privileges: g1t_contracts::MemberPrivileges,
72}
73
74impl Asker {
75 pub fn of(user: &User, namespace: &str) -> Self {
76 Asker {
77 person: user.kind == PrincipalKind::User,
78 verified: user.verified,
79 role: user.role_in(&namespace.to_lowercase()),
80 repo_role: None,
81 privileges: user.privileges_in(&namespace.to_lowercase()),
82 }
83 }
84
85 /// The asker, with their role on `repo`.
86 pub fn on(user: &User, repo: &Repo) -> Self {
87 Asker {
88 repo_role: crate::registry::role(repo, &Some(user.clone())),
89 ..Asker::of(user, &repo.namespace)
90 }
91 }
92}
93
94/// Whether `asker` may `what` ("rename", "archive"...) a repository of
95/// `namespace` that takes `capability`: a verified person with the role
96/// the permission table asks (Admin), and for changing visibility,
97/// transferring and deleting, an owner of the workspace as well, unless its
98/// member privileges let its members with Admin do it.
99pub fn admin_only(asker: Asker, namespace: &str, what: &str, capability: Capability) -> std::result::Result<(), Refusal> {
100 let admins_may = asker.role == Some(Role::Member)
101 && asker.repo_role == Some(RepoRole::Admin)
102 && !access::owner_only(capability, &asker.privileges);
103 if access::OWNER_ONLY.contains(&capability) && !admins_may {
104 // Someone who can see the repository is told why, not that it is missing.
105 if asker.role.is_none() && asker.repo_role.is_some() && asker.person {
106 return Err((
107 FailureCode::Forbidden,
108 format!("Only an owner of {namespace} can {what} its repositories."),
109 ));
110 }
111 return owner_only(asker, namespace, what);
112 }
113 if asker.role.is_none() && asker.repo_role.is_none() {
114 return Err((FailureCode::NotFound, "Repository not found.".into()));
115 }
116 if !asker.person {
117 return Err((
118 FailureCode::Forbidden,
119 format!("Only a person can {what} a repository. Sign in, or use a personal access token."),
120 ));
121 }
122 if !asker.repo_role.is_some_and(|role| access::allows(role, capability)) {
123 return Err((
124 FailureCode::Forbidden,
125 format!(
126 "You need the {} role on a repository of {namespace} to {what} it.",
127 access::least_role(capability).label()
128 ),
129 ));
130 }
131 if !asker.verified {
132 return Err((FailureCode::Forbidden, UNVERIFIED.into()));
133 }
134 Ok(())
135}
136
137/// Making a repository public or private is, as far as the workspace's
138/// member privileges go, creating one of that kind: a member may only make
139/// it what they may create.
140pub fn visibility_refusal(actor: &User, repo: &Repo, private: bool) -> Option<String> {
141 if repo.is_private == private {
142 return None;
143 }
144 let role = actor.role_in(&repo.namespace.to_lowercase())?;
145 actor.privileges_in(&repo.namespace.to_lowercase()).creation_refusal(role, private, &repo.namespace)
146}
147
148/// Whether `asker` may `what` ("delete", "rename"...) a repository of
149/// `namespace`: a verified person who owns the workspace.
150pub fn owner_only(asker: Asker, namespace: &str, what: &str) -> std::result::Result<(), Refusal> {
151 if asker.role.is_none() {
152 return Err((FailureCode::NotFound, "Repository not found.".into()));
153 }
154 if !asker.person {
155 return Err((
156 FailureCode::Forbidden,
157 format!("Only a person can {what} a repository. Sign in, or use a personal access token."),
158 ));
159 }
160 if asker.role != Some(Role::Owner) {
161 return Err((
162 FailureCode::Forbidden,
163 format!("Only an owner of {namespace} can {what} its repositories."),
164 ));
165 }
166 if !asker.verified {
167 return Err((FailureCode::Forbidden, UNVERIFIED.into()));
168 }
169 Ok(())
170}
171
172/// Whether what was typed to confirm names the repository: its full name,
173/// `namespace/name`, in any case.
174pub fn confirmed(path: &RepoPath, typed: &str) -> bool {
175 typed.trim().to_lowercase() == format!("{}/{}", path.namespace, path.name).to_lowercase()
176}
177
178fn confirm_refusal(path: &RepoPath) -> Refusal {
179 (
180 FailureCode::Invalid,
181 format!("Type {}/{} to confirm.", path.namespace, path.name),
182 )
183}
184
185/// When a repository deleted at `now_ms` is purged.
186pub fn purge_after(now_ms: u64) -> String {
187 rfc3339(now_ms + RESTORE_DAYS * 86_400_000)
188}
189
190/// Whether a repository to be purged at `purge_after` can still be
191/// restored at `now` (both RFC 3339, which compare as text).
192pub fn restorable(purge_after: &str, now: &str) -> bool {
193 now < purge_after
194}
195
196/// Whether a workspace's repositories may go with it: never a protected
197/// workspace's (`protected` is `g1t_contracts::identity::protected_names`),
198/// however the event came to be published.
199pub fn may_go_with_workspace(namespace: &str, protected: &[String]) -> std::result::Result<(), String> {
200 if protected.iter().any(|name| name.eq_ignore_ascii_case(namespace)) {
201 return Err(g1t_contracts::identity::protected_refusal(namespace));
202 }
203 Ok(())
204}
205
206/// Whether a deleted repository comes back when the workspace
207/// `workspace_id` is restored: only if it went with that workspace
208/// (`deleted_with`), not if it was deleted on its own before.
209pub fn comes_back_with(deleted_with: Option<&str>, workspace_id: &str) -> bool {
210 deleted_with == Some(workspace_id)
211}
212
213/// Where a repository is in its life, from its row.
214#[derive(Clone, Copy, Debug, PartialEq, Eq)]
215pub enum State {
216 Active,
217 Archived,
218 /// Deleted, and restorable until purged.
219 Deleted,
220 /// Deleted, and due to be purged by the next sweep.
221 Due,
222}
223
224pub fn state(archived_at: Option<&str>, deleted: Option<(&str, &str)>, now: &str) -> State {
225 match deleted {
226 Some((_, purge_after)) if !restorable(purge_after, now) => State::Due,
227 Some(_) => State::Deleted,
228 None if archived_at.is_some() => State::Archived,
229 None => State::Active,
230 }
231}
232
233/// What holds a name in a workspace.
234#[derive(Clone, Copy, Debug, PartialEq, Eq)]
235pub enum Held {
236 Free,
237 ByRepo,
238 /// A repository deleted but not yet purged.
239 ByDeleted,
240}
241
242/// The name a repository at `current` can be renamed to, tidied, or why
243/// not.
244pub fn new_name(namespace: &str, current: &str, wanted: &str, held: Held) -> std::result::Result<String, Refusal> {
245 let name = wanted.trim().to_lowercase();
246 if !is_valid_repo_name(&name) {
247 return Err((
248 FailureCode::Invalid,
249 "Use letters, digits, dots, hyphens and underscores only.".into(),
250 ));
251 }
252 if name == current {
253 return Err((FailureCode::Invalid, format!("It is already called {name}.")));
254 }
255 match held {
256 Held::Free => Ok(name),
257 Held::ByRepo => Err((
258 FailureCode::Conflict,
259 format!("{namespace} already has a repository named {name}."),
260 )),
261 Held::ByDeleted => Err((
262 FailureCode::Conflict,
263 format!(
264 "{namespace}/{name} was deleted recently and can still be restored. Restore it and rename it, or delete it permanently from the workspace's Recently deleted list first."
265 ),
266 )),
267 }
268}
269
270/// Why a write to `repo` is refused because it is archived, if it is.
271pub fn archived_refusal(repo: &Repo) -> Option<Refusal> {
272 repo.archived()
273 .then(|| (FailureCode::Forbidden, archived_message(&repo.namespace, &repo.name)))
274}
275
276/// Everything that decides whether a repository may go private or public.
277#[derive(Debug, Default)]
278pub struct VisibilityFacts {
279 pub to_private: bool,
280 /// The workspace is on no plan, so its private storage is capped.
281 pub free: bool,
282 /// What its private repositories hold now.
283 pub private_bytes: i64,
284 /// What this repository holds.
285 pub bytes: i64,
286 /// What a free workspace's private repositories may hold.
287 pub free_private_bytes: i64,
288}
289
290/// Whether the visibility change `facts` describe may happen.
291pub fn visibility_check(namespace: &str, facts: &VisibilityFacts) -> std::result::Result<(), Refusal> {
292 if facts.to_private
293 && facts.free
294 && git_ops::storage_full(facts.private_bytes + facts.bytes, facts.free_private_bytes)
295 {
296 return Err((
297 FailureCode::PaymentRequired,
298 format!(
299 "{namespace}'s private repositories would hold {:.2} GB, more than the {:.0} GB a free workspace has. Start the g1t plan in {namespace}, or keep the repository public.",
300 (facts.private_bytes + facts.bytes) as f64 / 1e9,
301 facts.free_private_bytes as f64 / 1e9,
302 ),
303 ));
304 }
305 Ok(())
306}
307
308/// Whether `from` can be renamed to `to` in a repository whose branches
309/// are `branches`. `to` is tidied of surrounding space.
310pub fn branch_rename(from: &str, to: &str, branches: &[String]) -> std::result::Result<String, Refusal> {
311 let to = to.trim().to_owned();
312 if !branches.iter().any(|branch| branch == from) {
313 return Err((FailureCode::NotFound, format!("There is no branch named {from}.")));
314 }
315 if !is_valid_branch_name(&to) {
316 return Err((
317 FailureCode::Invalid,
318 format!("{to:?} cannot be a branch name. Use letters, digits, '/', '-', '_' and '.', and no spaces."),
319 ));
320 }
321 if to == from {
322 return Err((FailureCode::Invalid, format!("It is already called {to}.")));
323 }
324 if branches.contains(&to) {
325 return Err((FailureCode::Conflict, format!("There is already a branch named {to}.")));
326 }
327 Ok(to)
328}
329
330/// The row of a deleted repository.
331#[derive(Deserialize)]
332struct DeletedRow {
333 id: String,
334 namespace: String,
335 name: String,
336 description: Option<String>,
337 is_private: u8,
338 deleted_at: String,
339 #[serde(default)]
340 deleted_by: Option<String>,
341 purge_after: String,
342 #[serde(default)]
343 deleted_with: Option<String>,
344}
345
346impl From<DeletedRow> for DeletedRepo {
347 fn from(row: DeletedRow) -> Self {
348 DeletedRepo {
349 id: row.id,
350 namespace: row.namespace,
351 name: row.name,
352 description: row.description,
353 is_private: row.is_private != 0,
354 deleted_at: row.deleted_at,
355 deleted_by: row.deleted_by.unwrap_or_default(),
356 purge_after: row.purge_after,
357 }
358 }
359}
360
361const DELETED_COLUMNS: &str =
362 "id, namespace, name, description, is_private, deleted_at, deleted_by, purge_after, deleted_with";
363
364impl Registry {
365 /// Deletes a repository and its pull requests' working copies, softly.
366 pub async fn soft_delete(&self, id: &str, by: &str, at: &str, purge_after: &str) -> Result<()> {
367 self.db
368 .prepare(
369 "UPDATE repos SET deleted_at = ?1, deleted_by = ?2, purge_after = ?3
370 WHERE (id = ?4 OR fork_of = ?4) AND deleted_at IS NULL",
371 )
372 .bind(&[at.into(), by.into(), purge_after.into(), id.into()])?
373 .run()
374 .await?;
375 Ok(())
376 }
377
378 /// Brings a deleted repository and its working copies back.
379 pub async fn undelete(&self, id: &str) -> Result<()> {
380 self.db
381 .prepare(
382 "UPDATE repos SET deleted_at = NULL, deleted_by = NULL, purge_after = NULL
383 WHERE id = ?1 OR fork_of = ?1",
384 )
385 .bind(&[id.into()])?
386 .run()
387 .await?;
388 Ok(())
389 }
390
391 /// Deletes every live repository of `namespace`, and the working
392 /// copies of its repositories, softly, each marked as gone with the
393 /// workspace `workspace_id`. Those already deleted are left as they
394 /// are. Returns every repository so marked, this time or before, so a
395 /// delivery again tells services again.
396 pub async fn delete_with_workspace(
397 &self,
398 namespace: &str,
399 workspace_id: &str,
400 by: &str,
401 at: &str,
402 purge_after: &str,
403 ) -> Result<Vec<DeletedRepo>> {
404 self.db
405 .prepare(
406 "UPDATE repos SET deleted_at = ?1, deleted_by = ?2, purge_after = ?3, deleted_with = ?4
407 WHERE deleted_at IS NULL
408 AND (namespace = ?5 OR fork_of IN (SELECT id FROM repos WHERE namespace = ?5))",
409 )
410 .bind(&[at.into(), by.into(), purge_after.into(), workspace_id.into(), namespace.into()])?
411 .run()
412 .await?;
413 self.deleted_with(workspace_id).await
414 }
415
416 /// The repositories deleted with the workspace `workspace_id`.
417 pub async fn deleted_with(&self, workspace_id: &str) -> Result<Vec<DeletedRepo>> {
418 Ok(self
419 .db
420 .prepare(format!(
421 "SELECT {DELETED_COLUMNS} FROM repos WHERE deleted_with = ? AND fork_of IS NULL"
422 ))
423 .bind(&[workspace_id.into()])?
424 .all()
425 .await?
426 .results::<DeletedRow>()?
427 .into_iter()
428 .filter(|row| comes_back_with(row.deleted_with.as_deref(), workspace_id))
429 .map(DeletedRepo::from)
430 .collect())
431 }
432
433 /// Brings back the repositories, and their working copies, deleted
434 /// with the workspace `workspace_id`, and only those.
435 pub async fn undelete_with_workspace(&self, workspace_id: &str) -> Result<()> {
436 self.db
437 .prepare(
438 "UPDATE repos SET deleted_at = NULL, deleted_by = NULL, purge_after = NULL, deleted_with = NULL
439 WHERE deleted_with = ?",
440 )
441 .bind(&[workspace_id.into()])?
442 .run()
443 .await?;
444 Ok(())
445 }
446
447 /// A workspace's deleted repositories, newest first.
448 pub async fn deleted_in(&self, namespace: &str) -> Result<Vec<DeletedRepo>> {
449 Ok(self
450 .db
451 .prepare(format!(
452 "SELECT {DELETED_COLUMNS} FROM repos
453 WHERE namespace = ? AND deleted_at IS NOT NULL AND fork_of IS NULL
454 ORDER BY deleted_at DESC LIMIT 200"
455 ))
456 .bind(&[namespace.to_lowercase().into()])?
457 .all()
458 .await?
459 .results::<DeletedRow>()?
460 .into_iter()
461 .map(DeletedRepo::from)
462 .collect())
463 }
464
465 /// The deleted repository at `path`, if that is what holds it.
466 pub async fn deleted_at(&self, path: &RepoPath) -> Result<Option<DeletedRepo>> {
467 Ok(self
468 .db
469 .prepare(format!(
470 "SELECT {DELETED_COLUMNS} FROM repos
471 WHERE namespace = ? AND name = ? AND deleted_at IS NOT NULL AND fork_of IS NULL"
472 ))
473 .bind(&[
474 path.namespace.to_lowercase().into(),
475 path.name.to_lowercase().into(),
476 ])?
477 .first::<DeletedRow>(None)
478 .await?
479 .map(DeletedRepo::from))
480 }
481
482 /// Deleted repositories whose time to be restored has passed.
483 pub async fn due(&self, now: &str, limit: u32) -> Result<Vec<DeletedRepo>> {
484 Ok(self
485 .db
486 .prepare(format!(
487 "SELECT {DELETED_COLUMNS} FROM repos
488 WHERE deleted_at IS NOT NULL AND purge_after <= ? AND fork_of IS NULL
489 ORDER BY purge_after LIMIT ?"
490 ))
491 .bind(&[now.into(), limit.into()])?
492 .all()
493 .await?
494 .results::<DeletedRow>()?
495 .into_iter()
496 .map(DeletedRepo::from)
497 .collect())
498 }
499
500 /// Every deleted repository left in a workspace, for when the
501 /// workspace itself is deleted.
502 pub async fn deleted_ids_in(&self, namespace: &str) -> Result<Vec<DeletedRepo>> {
503 self.deleted_in(namespace).await
504 }
505
506 /// The git store keys of a repository and of its working copies.
507 pub async fn store_keys(&self, id: &str) -> Result<Vec<String>> {
508 #[derive(Deserialize)]
509 struct Row {
510 namespace: String,
511 name: String,
512 #[serde(default)]
513 store: Option<String>,
514 }
515 Ok(self
516 .db
517 .prepare("SELECT namespace, name, store FROM repos WHERE id = ?1 OR fork_of = ?1")
518 .bind(&[id.into()])?
519 .all()
520 .await?
521 .results::<Row>()?
522 .into_iter()
523 .map(|row| row.store.unwrap_or_else(|| format!("{}--{}", row.namespace, row.name)))
524 .collect())
525 }
526
527 /// Forgets a purged repository: its rows, its working copies' rows and
528 /// every redirect to it.
529 pub async fn erase(&self, id: &str) -> Result<()> {
530 self.db
531 .batch(vec![
532 self.db.prepare("DELETE FROM repos WHERE fork_of = ?1").bind(&[id.into()])?,
533 self.db.prepare("DELETE FROM repos WHERE id = ?1").bind(&[id.into()])?,
534 self.db
535 .prepare("DELETE FROM repo_redirects WHERE repo_id = ?1")
536 .bind(&[id.into()])?,
537 self.db
538 .prepare("DELETE FROM branch_redirects WHERE repo_id = ?1")
539 .bind(&[id.into()])?,
540 // Its About, stars and releases (about.rs).
541 self.db.prepare("DELETE FROM repo_stats WHERE repo_id = ?1").bind(&[id.into()])?,
542 self.db.prepare("DELETE FROM repo_stars WHERE repo_id = ?1").bind(&[id.into()])?,
543 self.db.prepare("DELETE FROM releases WHERE repo_id = ?1").bind(&[id.into()])?,
544 ])
545 .await?;
546 Ok(())
547 }
548
549 /// Renames a repository, keeping its old path as a redirect. Any
550 /// redirect held by the new path gives way.
551 pub async fn rename(&self, repo: &Repo, name: &str) -> Result<()> {
552 let now = rfc3339(now_ms());
553 self.db
554 .batch(vec![
555 self.db
556 .prepare("UPDATE repos SET name = ? WHERE id = ? AND name = ?")
557 .bind(&[name.into(), repo.id.as_str().into(), repo.name.as_str().into()])?,
558 self.db
559 .prepare("DELETE FROM repo_redirects WHERE namespace = ? AND name = ?")
560 .bind(&[repo.namespace.as_str().into(), name.into()])?,
561 self.db
562 .prepare(
563 "INSERT OR REPLACE INTO repo_redirects (namespace, name, repo_id, created_at)
564 VALUES (?, ?, ?, ?)",
565 )
566 .bind(&[
567 repo.namespace.as_str().into(),
568 repo.name.as_str().into(),
569 repo.id.as_str().into(),
570 now.as_str().into(),
571 ])?,
572 ])
573 .await?;
574 Ok(())
575 }
576
577 pub async fn set_archived(&self, id: &str, at: Option<&str>) -> Result<()> {
578 self.db
579 .prepare("UPDATE repos SET archived_at = ? WHERE id = ?")
580 .bind(&[at.map_or(JsValue::NULL, JsValue::from), id.into()])?
581 .run()
582 .await?;
583 Ok(())
584 }
585
586 /// Makes a repository and its working copies public or private.
587 pub async fn set_private(&self, id: &str, private: bool) -> Result<()> {
588 self.db
589 .prepare("UPDATE repos SET is_private = ?1 WHERE id = ?2 OR fork_of = ?2")
590 .bind(&[u32::from(private).into(), id.into()])?
591 .run()
592 .await?;
593 Ok(())
594 }
595
596 pub async fn set_default_branch(&self, id: &str, branch: &str) -> Result<()> {
597 self.db
598 .prepare("UPDATE repos SET default_branch = ? WHERE id = ?")
599 .bind(&[branch.into(), id.into()])?
600 .run()
601 .await?;
602 Ok(())
603 }
604
605 /// Working copies of a repository, newest first, at most `limit`.
606 pub async fn forks_of(&self, id: &str, limit: u32) -> Result<Vec<Repo>> {
607 let rows = self
608 .db
609 .prepare(
610 "SELECT * FROM repos WHERE fork_of = ? AND deleted_at IS NULL AND retired_at IS NULL
611 ORDER BY created_at DESC LIMIT ?",
612 )
613 .bind(&[id.into(), limit.into()])?
614 .all()
615 .await?
616 .results::<crate::registry::RepoRow>()?;
617 Ok(rows.into_iter().map(Repo::from).collect())
618 }
619
620 /// Records that `from` is now called `to`. Redirects that pointed at
621 /// `from` point at `to`, and one held by `to` itself ends.
622 pub async fn add_branch_redirect(&self, repo_id: &str, from: &str, to: &str) -> Result<()> {
623 let now = rfc3339(now_ms());
624 self.db
625 .batch(vec![
626 self.db
627 .prepare("DELETE FROM branch_redirects WHERE repo_id = ? AND branch = ?")
628 .bind(&[repo_id.into(), to.into()])?,
629 self.db
630 .prepare("UPDATE branch_redirects SET now = ? WHERE repo_id = ? AND now = ?")
631 .bind(&[to.into(), repo_id.into(), from.into()])?,
632 self.db
633 .prepare(
634 "INSERT OR REPLACE INTO branch_redirects (repo_id, branch, now, created_at)
635 VALUES (?, ?, ?, ?)",
636 )
637 .bind(&[repo_id.into(), from.into(), to.into(), now.as_str().into()])?,
638 ])
639 .await?;
640 Ok(())
641 }
642
643 pub async fn branch_redirect(&self, repo_id: &str, branch: &str) -> Result<Option<String>> {
644 #[derive(Deserialize)]
645 struct Row {
646 now: String,
647 }
648 Ok(self
649 .db
650 .prepare("SELECT now FROM branch_redirects WHERE repo_id = ? AND branch = ?")
651 .bind(&[repo_id.into(), branch.into()])?
652 .first::<Row>(None)
653 .await?
654 .map(|row| row.now))
655 }
656
657 /// Whether a repository is archived or deleted; unknown is deleted.
658 pub async fn status(&self, id: &str) -> Result<RepoStatus> {
659 #[derive(Deserialize)]
660 struct Row {
661 #[serde(default)]
662 archived_at: Option<String>,
663 #[serde(default)]
664 deleted_at: Option<String>,
665 }
666 Ok(self
667 .db
668 .prepare("SELECT archived_at, deleted_at FROM repos WHERE id = ?")
669 .bind(&[id.into()])?
670 .first::<Row>(None)
671 .await?
672 .map_or(
673 RepoStatus {
674 archived: false,
675 deleted: true,
676 },
677 |row| RepoStatus {
678 archived: row.archived_at.is_some(),
679 deleted: row.deleted_at.is_some(),
680 },
681 ))
682 }
683}
684
685/// An audit entry for something done to a repository.
686fn entry(actor: AuditActor, action: &str, surface: Option<Surface>, path: &RepoPath, rule: &str, message: String) -> NewAuditEntry {
687 NewAuditEntry {
688 actor,
689 action: action.to_owned(),
690 surface: surface.unwrap_or(Surface::Web),
691 target: AuditTarget {
692 workspace: path.namespace.clone(),
693 repo: Some(format!("{}/{}", path.namespace, path.name)),
694 ..AuditTarget::default()
695 },
696 outcome: AuditOutcome::Allowed,
697 rule: rule.to_owned(),
698 result: Some("ok".to_owned()),
699 message: Some(message),
700 request_id: new_id("req", now_ms()),
701 }
702}
703
704/// g1t itself, as the actor of what its schedule does.
705fn g1t_actor() -> AuditActor {
706 AuditActor::system()
707}
708
709fn fail<T>((code, message): Refusal) -> Outcome<T> {
710 Outcome::fail(code, message)
711}
712
713fn path_of(repo: &Repo) -> RepoPath {
714 RepoPath {
715 namespace: repo.namespace.clone(),
716 name: repo.name.clone(),
717 }
718}
719
720impl<S: GitStore> Repos<S> {
721 pub(crate) async fn record(&self, entries: Vec<NewAuditEntry>) {
722 let recorded: Result<u32> =
723 g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries }).await;
724 if let Err(error) = recorded {
725 worker::console_error!("audit entries not recorded: {error}");
726 }
727 }
728
729 /// The repository at `path` an admin is acting on: found, not a
730 /// working copy, and the actor allowed `capability` on it (Admin, and
731 /// for deleting, an owner of its workspace).
732 async fn owned(
733 &self,
734 actor: &User,
735 path: &RepoPath,
736 what: &str,
737 capability: Capability,
738 ) -> Result<std::result::Result<Repo, Refusal>> {
739 let viewer = Some(actor.clone());
740 let Some(repo) = self.readable(path, &viewer).await? else {
741 return Ok(Err((FailureCode::NotFound, "Repository not found.".into())));
742 };
743 if repo.fork_of.is_some() {
744 return Ok(Err((FailureCode::NotFound, "Repository not found.".into())));
745 }
746 if let Err(refusal) = admin_only(Asker::on(actor, &repo), &repo.namespace, what, capability) {
747 return Ok(Err(refusal));
748 }
749 Ok(Ok(repo))
750 }
751
752 /// `delete`: see `g1t_contracts::repos::DeleteArgs`.
753 pub(crate) async fn delete(&self, a: DeleteArgs) -> Result<Outcome<DeletedRepo>> {
754 let repo = match self.owned(&a.actor, &a.path, "delete", Capability::Delete).await? {
755 Ok(repo) => repo,
756 Err(refusal) => return Ok(fail(refusal)),
757 };
758 let path = path_of(&repo);
759 if !confirmed(&path, &a.confirm) {
760 return Ok(fail(confirm_refusal(&path)));
761 }
762 let now = now_ms();
763 let at = rfc3339(now);
764 let purge = purge_after(now);
765 self.registry
766 .soft_delete(&repo.id, &a.actor.username, &at, &purge)
767 .await?;
768 self.publish(NewEvent {
769 kind: "repo.deleted",
770 source: SOURCE,
771 repo_id: Some(repo.id.clone()),
772 actor: Some(a.actor.id.clone()),
773 data: RepoDeleted {
774 repo_id: repo.id.clone(),
775 namespace: repo.namespace.clone(),
776 name: repo.name.clone(),
777 is_private: repo.is_private,
778 purge_after: purge.clone(),
779 with_workspace: false,
780 },
781 })
782 .await?;
783 self.record(vec![entry(
784 AuditActor::of(&a.actor),
785 "repo.deleted",
786 a.surface,
787 &path,
788 "owner",
789 format!("Deleted; restorable until {purge}"),
790 )])
791 .await;
792 Ok(Outcome::Ok(DeletedRepo {
793 id: repo.id,
794 namespace: repo.namespace,
795 name: repo.name,
796 description: repo.description,
797 is_private: repo.is_private,
798 deleted_at: at,
799 deleted_by: a.actor.username,
800 purge_after: purge,
801 }))
802 }
803
804 /// `deleted`: see `g1t_contracts::repos::DeletedArgs`.
805 pub(crate) async fn deleted(&self, a: DeletedArgs) -> Result<Vec<DeletedRepo>> {
806 let namespace = a.namespace.to_lowercase();
807 let owner = a
808 .viewer
809 .as_ref()
810 .is_some_and(|user| user.role_in(&namespace) == Some(Role::Owner));
811 if !owner {
812 return Ok(Vec::new());
813 }
814 self.registry.deleted_in(&namespace).await
815 }
816
817 /// The deleted repository an owner is acting on.
818 async fn owned_deleted(
819 &self,
820 actor: &User,
821 path: &RepoPath,
822 what: &str,
823 ) -> Result<std::result::Result<DeletedRepo, Refusal>> {
824 if let Err(refusal) = owner_only(Asker::of(actor, &path.namespace), &path.namespace.to_lowercase(), what) {
825 return Ok(Err(refusal));
826 }
827 Ok(match self.registry.deleted_at(path).await? {
828 Some(deleted) => Ok(deleted),
829 None => Err((
830 FailureCode::NotFound,
831 format!(
832 "{}/{} is not among the workspace's recently deleted repositories.",
833 path.namespace, path.name
834 ),
835 )),
836 })
837 }
838
839 /// `restore`: see `g1t_contracts::repos::DeletedRepoArgs`.
840 pub(crate) async fn restore(&self, a: DeletedRepoArgs) -> Result<Outcome<Repo>> {
841 let deleted = match self.owned_deleted(&a.actor, &a.path, "restore").await? {
842 Ok(deleted) => deleted,
843 Err(refusal) => return Ok(fail(refusal)),
844 };
845 let deleted_state = state(
846 None,
847 Some((&deleted.deleted_at, &deleted.purge_after)),
848 &rfc3339(now_ms()),
849 );
850 if deleted_state == State::Due {
851 return Ok(Outcome::fail(
852 FailureCode::Conflict,
853 format!("{}/{} is being purged and can no longer be restored.", deleted.namespace, deleted.name),
854 ));
855 }
856 self.registry.undelete(&deleted.id).await?;
857 let Some(repo) = self.registry.by_id(&deleted.id).await? else {
858 return Ok(not_found());
859 };
860 self.publish(NewEvent {
861 kind: "repo.restored",
862 source: SOURCE,
863 repo_id: Some(repo.id.clone()),
864 actor: Some(a.actor.id.clone()),
865 data: RepoRestored {
866 repo_id: repo.id.clone(),
867 namespace: repo.namespace.clone(),
868 name: repo.name.clone(),
869 is_private: repo.is_private,
870 with_workspace: false,
871 },
872 })
873 .await?;
874 self.record(vec![entry(
875 AuditActor::of(&a.actor),
876 "repo.restored",
877 a.surface,
878 &path_of(&repo),
879 "owner",
880 format!("Restored; deleted by {} at {}", deleted.deleted_by, deleted.deleted_at),
881 )])
882 .await;
883 Ok(Outcome::Ok(repo))
884 }
885
886 /// `purge`: see `g1t_contracts::repos::DeletedRepoArgs`.
887 pub(crate) async fn purge(&self, a: DeletedRepoArgs) -> Result<Outcome<bool>> {
888 let deleted = match self.owned_deleted(&a.actor, &a.path, "permanently delete").await? {
889 Ok(deleted) => deleted,
890 Err(refusal) => return Ok(fail(refusal)),
891 };
892 let path = RepoPath {
893 namespace: deleted.namespace.clone(),
894 name: deleted.name.clone(),
895 };
896 if !confirmed(&path, a.confirm.as_deref().unwrap_or_default()) {
897 return Ok(fail(confirm_refusal(&path)));
898 }
899 self.purge_now(&deleted, Some(&a.actor.id)).await?;
900 self.record(vec![entry(
901 AuditActor::of(&a.actor),
902 "repo.purged",
903 a.surface,
904 &path,
905 "owner",
906 "Permanently deleted, with its git data".to_owned(),
907 )])
908 .await;
909 Ok(Outcome::Ok(true))
910 }
911
912 /// Removes a deleted repository for good: git data first, so a failure
913 /// leaves it to the next sweep, then its rows; then says so.
914 async fn purge_now(&self, deleted: &DeletedRepo, actor: Option<&str>) -> Result<()> {
915 for key in self.registry.store_keys(&deleted.id).await? {
916 self.store.delete(&key).await?;
917 }
918 self.registry.erase(&deleted.id).await?;
919 // Who had access to it goes with it.
920 if let Some(identity) = &self.identity {
921 let forgotten: Result<bool> = g1t_kit::call(
922 identity,
923 "forget_repo_access",
924 &g1t_contracts::access::ForgetRepoAccessArgs {
925 repo_id: deleted.id.clone(),
926 },
927 )
928 .await;
929 if let Err(error) = forgotten {
930 worker::console_error!("access to {} not forgotten: {error}", deleted.id);
931 }
932 }
933 self.publish(NewEvent {
934 kind: "repo.purged",
935 source: SOURCE,
936 repo_id: Some(deleted.id.clone()),
937 actor: actor.map(str::to_owned),
938 data: RepoPurged {
939 repo_id: deleted.id.clone(),
940 namespace: deleted.namespace.clone(),
941 name: deleted.name.clone(),
942 },
943 })
944 .await
945 }
946
947 /// `purge_due`: see `g1t_contracts::repos::PurgeDueArgs`.
948 pub(crate) async fn purge_due(&self, a: PurgeDueArgs) -> Result<u32> {
949 let limit = a.limit.unwrap_or(PURGES_PER_SWEEP).clamp(1, 100);
950 let due = self.registry.due(&rfc3339(now_ms()), limit).await?;
951 let mut purged = 0;
952 for deleted in due {
953 match self.purge_now(&deleted, None).await {
954 Ok(()) => {
955 purged += 1;
956 let path = RepoPath {
957 namespace: deleted.namespace.clone(),
958 name: deleted.name.clone(),
959 };
960 self.record(vec![entry(
961 g1t_actor(),
962 "repo.purged",
963 None,
964 &path,
965 "schedule",
966 format!("Purged {RESTORE_DAYS} days after {} deleted it", deleted.deleted_by),
967 )])
968 .await;
969 }
970 Err(error) => worker::console_error!("{} not purged: {error}", deleted.id),
971 }
972 }
973 Ok(purged)
974 }
975
976 /// `workspace.deleting`: every live repository of the workspace is
977 /// deleted with it, marked so its restore brings back exactly these,
978 /// and `repo.deleted` (with `with_workspace`) tells services to stop
979 /// what runs for each and hide it. They are purged with the workspace,
980 /// or by the sweep at the same `purge_after`. Never for a protected
981 /// workspace, whoever published it.
982 pub(crate) async fn delete_with_workspace(&self, deleting: &WorkspaceDeleting, protected: &[String]) -> Result<()> {
983 let namespace = deleting.slug.to_lowercase();
984 if let Err(why) = may_go_with_workspace(&namespace, protected) {
985 worker::console_error!("workspace.deleting for {namespace} ignored: {why}");
986 return Ok(());
987 }
988 let marked = self
989 .registry
990 .delete_with_workspace(
991 &namespace,
992 &deleting.workspace_id,
993 &deleting.by,
994 &rfc3339(now_ms()),
995 &deleting.purge_after,
996 )
997 .await?;
998 for repo in marked {
999 self.publish(NewEvent {
1000 kind: "repo.deleted",
1001 source: SOURCE,
1002 repo_id: Some(repo.id.clone()),
1003 actor: None,
1004 data: RepoDeleted {
1005 repo_id: repo.id.clone(),
1006 namespace: repo.namespace.clone(),
1007 name: repo.name.clone(),
1008 is_private: repo.is_private,
1009 purge_after: repo.purge_after.clone(),
1010 with_workspace: true,
1011 },
1012 })
1013 .await?;
1014 }
1015 Ok(())
1016 }
1017
1018 /// `workspace.restored`: the repositories deleted with the workspace
1019 /// come back, and `repo.restored` (with `with_workspace`) says so for
1020 /// each. Ones deleted on their own before stay deleted.
1021 pub(crate) async fn restore_with_workspace(&self, restored: &WorkspaceRestored) -> Result<()> {
1022 let marked = self.registry.deleted_with(&restored.workspace_id).await?;
1023 self.registry.undelete_with_workspace(&restored.workspace_id).await?;
1024 for deleted in marked {
1025 let Some(repo) = self.registry.by_id(&deleted.id).await? else {
1026 continue;
1027 };
1028 self.publish(NewEvent {
1029 kind: "repo.restored",
1030 source: SOURCE,
1031 repo_id: Some(repo.id.clone()),
1032 actor: None,
1033 data: RepoRestored {
1034 repo_id: repo.id.clone(),
1035 namespace: repo.namespace.clone(),
1036 name: repo.name.clone(),
1037 is_private: repo.is_private,
1038 with_workspace: true,
1039 },
1040 })
1041 .await?;
1042 }
1043 Ok(())
1044 }
1045
1046 /// `workspace.deleted`: the workspace is purged, and every repository
1047 /// it had goes with it: those deleted with it, those deleted before,
1048 /// and any still live (a `workspace.deleting` that never arrived).
1049 pub(crate) async fn purge_workspace(&self, deleted: &WorkspaceDeleted, protected: &[String]) -> Result<()> {
1050 let namespace = deleted.slug.to_lowercase();
1051 if let Err(why) = may_go_with_workspace(&namespace, protected) {
1052 worker::console_error!("workspace.deleted for {namespace} ignored: {why}");
1053 return Ok(());
1054 }
1055 let now = rfc3339(now_ms());
1056 self.registry
1057 .delete_with_workspace(&namespace, &deleted.workspace_id, "g1t", &now, &now)
1058 .await?;
1059 // A page at a time; one that fails is left to the hourly sweep.
1060 loop {
1061 let page = self.registry.deleted_ids_in(&namespace).await?;
1062 let mut purged = 0;
1063 for repo in &page {
1064 match self.purge_now(repo, None).await {
1065 Ok(()) => purged += 1,
1066 Err(error) => worker::console_error!("{} not purged with its workspace: {error}", repo.id),
1067 }
1068 }
1069 if purged == 0 {
1070 break;
1071 }
1072 }
1073 Ok(())
1074 }
1075
1076 /// `rename`: see `g1t_contracts::repos::RenameArgs`.
1077 pub(crate) async fn rename(&self, a: RenameArgs) -> Result<Outcome<Repo>> {
1078 let repo = match self.owned(&a.actor, &a.path, "rename", Capability::Administer).await? {
1079 Ok(repo) => repo,
1080 Err(refusal) => return Ok(fail(refusal)),
1081 };
1082 let wanted = RepoPath {
1083 namespace: repo.namespace.clone(),
1084 name: a.name.trim().to_lowercase(),
1085 };
1086 let held = match self.registry.by_path_any(&wanted).await? {
1087 None => Held::Free,
1088 Some((_, None)) => Held::ByRepo,
1089 Some((_, Some(_))) => Held::ByDeleted,
1090 };
1091 let name = match new_name(&repo.namespace, &repo.name, &a.name, held) {
1092 Ok(name) => name,
1093 Err(refusal) => return Ok(fail(refusal)),
1094 };
1095 // The git store key stays what it was; the new path must not
1096 // change where it is read from.
1097 let key = store_key(&repo);
1098 self.registry.rename(&repo, &name).await?;
1099 let renamed = Repo {
1100 name: name.clone(),
1101 ..repo.clone()
1102 };
1103 remember_store(&renamed, &key);
1104 let from = path_of(&repo);
1105 let to = path_of(&renamed);
1106 if let Some(identity) = &self.identity {
1107 let moved: Result<bool> = g1t_kit::call(
1108 identity,
1109 "transfer_repo_scopes",
1110 &TransferRepoScopesArgs {
1111 from: from.clone(),
1112 to: to.clone(),
1113 },
1114 )
1115 .await;
1116 if let Err(error) = moved {
1117 worker::console_error!("agent scopes for {} not moved: {error}", repo.id);
1118 }
1119 }
1120 self.publish(NewEvent {
1121 kind: "repo.renamed",
1122 source: SOURCE,
1123 repo_id: Some(repo.id.clone()),
1124 actor: Some(a.actor.id.clone()),
1125 data: RepoRenamed {
1126 repo_id: repo.id.clone(),
1127 namespace: repo.namespace.clone(),
1128 from: repo.name.clone(),
1129 to: name.clone(),
1130 },
1131 })
1132 .await?;
1133 self.record(vec![entry(
1134 AuditActor::of(&a.actor),
1135 "repo.renamed",
1136 a.surface,
1137 &to,
1138 "owner",
1139 format!("Renamed from {}/{}", from.namespace, from.name),
1140 )])
1141 .await;
1142 Ok(Outcome::Ok(renamed))
1143 }
1144
1145 /// `archive`: see `g1t_contracts::repos::ArchiveArgs`.
1146 pub(crate) async fn archive(&self, a: ArchiveArgs) -> Result<Outcome<Repo>> {
1147 let what = if a.archived { "archive" } else { "unarchive" };
1148 let repo = match self.owned(&a.actor, &a.path, what, Capability::Administer).await? {
1149 Ok(repo) => repo,
1150 Err(refusal) => return Ok(fail(refusal)),
1151 };
1152 if repo.archived() == a.archived {
1153 return Ok(Outcome::Ok(repo));
1154 }
1155 let at = a.archived.then(|| rfc3339(now_ms()));
1156 self.registry.set_archived(&repo.id, at.as_deref()).await?;
1157 let changed = Repo {
1158 archived_at: at,
1159 ..repo
1160 };
1161 let kind = if a.archived { "repo.archived" } else { "repo.unarchived" };
1162 self.publish(NewEvent {
1163 kind,
1164 source: SOURCE,
1165 repo_id: Some(changed.id.clone()),
1166 actor: Some(a.actor.id.clone()),
1167 data: RepoArchived {
1168 repo_id: changed.id.clone(),
1169 namespace: changed.namespace.clone(),
1170 name: changed.name.clone(),
1171 archived: a.archived,
1172 },
1173 })
1174 .await?;
1175 self.record(vec![entry(
1176 AuditActor::of(&a.actor),
1177 kind,
1178 a.surface,
1179 &path_of(&changed),
1180 "owner",
1181 if a.archived {
1182 "Archived: read-only".to_owned()
1183 } else {
1184 "Unarchived".to_owned()
1185 },
1186 )])
1187 .await;
1188 Ok(Outcome::Ok(changed))
1189 }
1190
1191 /// `set_visibility`: see `g1t_contracts::repos::SetVisibilityArgs`.
1192 pub(crate) async fn set_visibility(&self, a: SetVisibilityArgs) -> Result<Outcome<Repo>> {
1193 let repo = match self.owned(&a.actor, &a.path, "change the visibility of", Capability::ChangeVisibility).await? {
1194 Ok(repo) => repo,
1195 Err(refusal) => return Ok(fail(refusal)),
1196 };
1197 if !confirmed(&path_of(&repo), &a.confirm) {
1198 return Ok(fail(confirm_refusal(&path_of(&repo))));
1199 }
1200 if let Some(why) = visibility_refusal(&a.actor, &repo, a.is_private) {
1201 return Ok(Outcome::fail(FailureCode::Forbidden, why));
1202 }
1203 self.change_visibility(repo, a.is_private, &a.actor, a.surface).await
1204 }
1205
1206 /// Makes `repo` public or private, if the workspace's storage allows,
1207 /// and says so: `repo.updated` and `repo.visibility_changed`. The
1208 /// caller has checked the actor may.
1209 pub(crate) async fn change_visibility(
1210 &self,
1211 repo: Repo,
1212 private: bool,
1213 actor: &User,
1214 surface: Option<Surface>,
1215 ) -> Result<Outcome<Repo>> {
1216 if repo.is_private == private {
1217 return Ok(Outcome::Ok(repo));
1218 }
1219 let facts = if private {
1220 VisibilityFacts {
1221 to_private: true,
1222 free: git_ops::is_free(self.billing.as_ref(), &repo.namespace).await,
1223 private_bytes: self.registry.private_bytes(&repo.namespace).await.unwrap_or(0),
1224 bytes: self.registry.stored_bytes(&repo.id).await?,
1225 free_private_bytes: self.free_private_bytes,
1226 }
1227 } else {
1228 VisibilityFacts::default()
1229 };
1230 if let Err(refusal) = visibility_check(&repo.namespace, &facts) {
1231 return Ok(fail(refusal));
1232 }
1233 self.registry.set_private(&repo.id, private).await?;
1234 let changed = Repo {
1235 is_private: private,
1236 ..repo
1237 };
1238 self.publish(NewEvent {
1239 kind: "repo.updated",
1240 source: SOURCE,
1241 repo_id: Some(changed.id.clone()),
1242 actor: Some(actor.id.clone()),
1243 data: RepoUpdated {
1244 repo_id: changed.id.clone(),
1245 namespace: changed.namespace.clone(),
1246 name: changed.name.clone(),
1247 is_private: private,
1248 visibility_changed: true,
1249 },
1250 })
1251 .await?;
1252 self.publish(NewEvent {
1253 kind: "repo.visibility_changed",
1254 source: SOURCE,
1255 repo_id: Some(changed.id.clone()),
1256 actor: Some(actor.id.clone()),
1257 data: RepoVisibilityChanged {
1258 repo_id: changed.id.clone(),
1259 is_private: private,
1260 },
1261 })
1262 .await?;
1263 self.record(vec![entry(
1264 AuditActor::of(actor),
1265 "repo.visibility_changed",
1266 surface,
1267 &path_of(&changed),
1268 "owner",
1269 if private { "Made private".to_owned() } else { "Made public".to_owned() },
1270 )])
1271 .await;
1272 Ok(Outcome::Ok(changed))
1273 }
1274
1275 /// The repository at `path` someone is changing the branches of:
1276 /// found, not a working copy, the actor allowed `capability` on it
1277 /// (Push to rename a branch, Administer to change the default),
1278 /// verified, and not archived.
1279 async fn writable_by(
1280 &self,
1281 actor: &User,
1282 path: &RepoPath,
1283 capability: Capability,
1284 ) -> Result<std::result::Result<Repo, Refusal>> {
1285 let viewer = Some(actor.clone());
1286 let Some(repo) = self.readable(path, &viewer).await? else {
1287 return Ok(Err((FailureCode::NotFound, "Repository not found.".into())));
1288 };
1289 if repo.fork_of.is_some() || !crate::registry::can(&repo, &viewer, capability) {
1290 return Ok(Err((
1291 FailureCode::Forbidden,
1292 access::needs(capability, &format!("{}/{}", repo.namespace, repo.name)),
1293 )));
1294 }
1295 if !actor.verified {
1296 return Ok(Err((FailureCode::Forbidden, UNVERIFIED.into())));
1297 }
1298 if let Some(refusal) = archived_refusal(&repo) {
1299 return Ok(Err(refusal));
1300 }
1301 // Moving between namespaces: wait for it (moves.rs).
1302 self.unpaused(repo).await
1303 }
1304
1305 /// `set_default_branch`: see `g1t_contracts::repos::SetDefaultBranchArgs`.
1306 pub(crate) async fn set_default_branch(&self, a: SetDefaultBranchArgs) -> Result<Outcome<Repo>> {
1307 let repo = match self.writable_by(&a.actor, &a.path, Capability::Administer).await? {
1308 Ok(repo) => repo,
1309 Err(refusal) => return Ok(fail(refusal)),
1310 };
1311 let branch = a.branch.trim().to_owned();
1312 if branch == repo.default_branch {
1313 return Ok(Outcome::Ok(repo));
1314 }
1315 let git = self.store.open(&store_key(&repo)).await?;
1316 if !git.branches().await?.iter().any(|b| b.name == branch) {
1317 return Ok(Outcome::fail(
1318 FailureCode::Invalid,
1319 format!("There is no branch named {branch}. Push it first."),
1320 ));
1321 }
1322 self.registry.set_default_branch(&repo.id, &branch).await?;
1323 // HEAD in what git is told follows it.
1324 self.refs_moved(&repo.id).await;
1325 let from = repo.default_branch.clone();
1326 let changed = Repo {
1327 default_branch: branch.clone(),
1328 ..repo
1329 };
1330 self.forks_follow(&changed, &from, &branch, false).await;
1331 self.publish(NewEvent {
1332 kind: "repo.default_branch_changed",
1333 source: SOURCE,
1334 repo_id: Some(changed.id.clone()),
1335 actor: Some(a.actor.id.clone()),
1336 data: RepoDefaultBranchChanged {
1337 repo_id: changed.id.clone(),
1338 from: from.clone(),
1339 to: branch.clone(),
1340 renamed: false,
1341 },
1342 })
1343 .await?;
1344 self.record(vec![entry(
1345 AuditActor::of(&a.actor),
1346 "repo.default_branch_changed",
1347 a.surface,
1348 &path_of(&changed),
1349 "member",
1350 format!("Default branch changed from {from} to {branch}"),
1351 )])
1352 .await;
1353 Ok(Outcome::Ok(changed))
1354 }
1355
1356 /// `rename_branch`: see `g1t_contracts::repos::RenameBranchArgs`.
1357 pub(crate) async fn rename_branch(&self, a: RenameBranchArgs) -> Result<Outcome<Repo>> {
1358 let repo = match self.writable_by(&a.actor, &a.path, Capability::Push).await? {
1359 Ok(repo) => repo,
1360 Err(refusal) => return Ok(fail(refusal)),
1361 };
1362 let from = a.from.trim().to_owned();
1363 let is_default = from == repo.default_branch;
1364 if is_default
1365 && let Err(refusal) = admin_only(
1366 Asker::on(&a.actor, &repo),
1367 &repo.namespace,
1368 "rename the default branch of",
1369 Capability::Administer,
1370 )
1371 {
1372 return Ok(fail(refusal));
1373 }
1374 let git = self.store.open(&store_key(&repo)).await?;
1375 let branches = git.branches().await?;
1376 let names: Vec<String> = branches.iter().map(|b| b.name.clone()).collect();
1377 let to = match branch_rename(&from, &a.to, &names) {
1378 Ok(to) => to,
1379 Err(refusal) => return Ok(fail(refusal)),
1380 };
1381 let Some(head) = branches.iter().find(|b| b.name == from).map(|b| b.hash.clone()) else {
1382 return Ok(not_found());
1383 };
1384 // A rename deletes one name and creates another: the rules of both
1385 // hold (rules.rs).
1386 let renamed = vec![
1387 g1t_rules::push::RefChange {
1388 git_ref: format!("refs/heads/{from}"),
1389 old: Some(head.clone()),
1390 new: None,
1391 complete: true,
1392 ..Default::default()
1393 },
1394 g1t_rules::push::RefChange {
1395 git_ref: format!("refs/heads/{to}"),
1396 old: None,
1397 new: Some(head.clone()),
1398 complete: true,
1399 ..Default::default()
1400 },
1401 ];
1402 if let crate::rules::Ruled::Refused { message, .. } =
1403 self.check_changes(&repo, &a.actor, g1t_contracts::rules::Action::RenameRef, renamed).await?
1404 {
1405 return Ok(Outcome::fail(FailureCode::Forbidden, message));
1406 }
1407 let access = git.access(Scope::Write).await?;
1408 let made = land::push_pack(&access, &to, None, &head, EMPTY_PACK.to_vec()).await?;
1409 self.refs_moved(&repo.id).await;
1410 if let Err(reason) = made {
1411 return Ok(Outcome::fail(FailureCode::Conflict, format!("{to} could not be made: {reason}")));
1412 }
1413 // The default moves before the old name goes, so it never names a
1414 // branch that is not there.
1415 if is_default {
1416 self.registry.set_default_branch(&repo.id, &to).await?;
1417 }
1418 let removed = land::delete_ref(&access, &from, &head).await;
1419 self.refs_moved(&repo.id).await;
1420 if let Err(reason) = removed? {
1421 worker::console_error!("{from} not removed after renaming it to {to}: {reason}");
1422 }
1423 self.registry.add_branch_redirect(&repo.id, &from, &to).await?;
1424 let changed = if is_default {
1425 Repo {
1426 default_branch: to.clone(),
1427 ..repo
1428 }
1429 } else {
1430 repo
1431 };
1432 if is_default {
1433 self.forks_follow(&changed, &from, &to, true).await;
1434 }
1435 self.publish(NewEvent {
1436 kind: "branch.renamed",
1437 source: SOURCE,
1438 repo_id: Some(changed.id.clone()),
1439 actor: Some(a.actor.id.clone()),
1440 data: BranchRenamed {
1441 repo_id: changed.id.clone(),
1442 from: from.clone(),
1443 to: to.clone(),
1444 default_branch: is_default,
1445 },
1446 })
1447 .await?;
1448 if is_default {
1449 self.publish(NewEvent {
1450 kind: "repo.default_branch_changed",
1451 source: SOURCE,
1452 repo_id: Some(changed.id.clone()),
1453 actor: Some(a.actor.id.clone()),
1454 data: RepoDefaultBranchChanged {
1455 repo_id: changed.id.clone(),
1456 from: from.clone(),
1457 to: to.clone(),
1458 renamed: true,
1459 },
1460 })
1461 .await?;
1462 }
1463 self.record(vec![entry(
1464 AuditActor::of(&a.actor),
1465 "branch.renamed",
1466 a.surface,
1467 &path_of(&changed),
1468 if is_default { "owner" } else { "member" },
1469 format!("Branch {from} renamed to {to}"),
1470 )])
1471 .await;
1472 Ok(Outcome::Ok(changed))
1473 }
1474
1475 /// Pull requests' working copies name their branch after the default
1476 /// branch of the repository they came from. When it changes, the
1477 /// newest of them get a branch of the new name at the same commit (and,
1478 /// for a rename, lose the old one), so agents and merges find it.
1479 /// Best effort: a copy that cannot follow is logged and left.
1480 async fn forks_follow(&self, repo: &Repo, from: &str, to: &str, renamed: bool) {
1481 let forks = match self.registry.forks_of(&repo.id, FORKS_FOLLOWING).await {
1482 Ok(forks) => forks,
1483 Err(error) => {
1484 worker::console_error!("working copies of {} not listed: {error}", repo.id);
1485 return;
1486 }
1487 };
1488 for fork in forks {
1489 let followed: Result<()> = async {
1490 let git = self.store.open(&store_key(&fork)).await?;
1491 let branches = git.branches().await?;
1492 let Some(head) = branches.iter().find(|b| b.name == from).map(|b| b.hash.clone()) else {
1493 return Ok(());
1494 };
1495 let access = git.access(Scope::Write).await?;
1496 if !branches.iter().any(|b| b.name == to) {
1497 let made = land::push_pack(&access, to, None, &head, EMPTY_PACK.to_vec()).await;
1498 self.refs_moved(&fork.id).await;
1499 if let Err(reason) = made? {
1500 worker::console_error!("working copy {} did not get {to}: {reason}", fork.id);
1501 return Ok(());
1502 }
1503 }
1504 self.registry.set_default_branch(&fork.id, to).await?;
1505 if renamed {
1506 let removed = land::delete_ref(&access, from, &head).await;
1507 self.refs_moved(&fork.id).await;
1508 if let Err(reason) = removed? {
1509 worker::console_error!("working copy {} kept {from}: {reason}", fork.id);
1510 }
1511 }
1512 Ok(())
1513 }
1514 .await;
1515 if let Err(error) = followed {
1516 worker::console_error!("working copy {} did not follow {from} → {to}: {error}", fork.id);
1517 }
1518 }
1519 }
1520
1521 /// `resolve_branch`: see `g1t_contracts::repos::ResolveBranchArgs`.
1522 pub(crate) async fn resolve_branch(&self, a: ResolveBranchArgs) -> Result<Option<String>> {
1523 let Some(now) = self.registry.branch_redirect(&a.repo_id, &a.branch).await? else {
1524 return Ok(None);
1525 };
1526 // A branch made again under the old name ends the redirect.
1527 let Some(repo) = self.registry.by_id(&a.repo_id).await? else {
1528 return Ok(None);
1529 };
1530 let git = self.store.open(&store_key(&repo)).await?;
1531 let branches = git.branches().await?;
1532 if branches.iter().any(|b| b.name == a.branch) || !branches.iter().any(|b| b.name == now) {
1533 return Ok(None);
1534 }
1535 Ok(Some(now))
1536 }
1537
1538 /// `status_by_id`: see `g1t_contracts::repos::StatusByIdArgs`.
1539 pub(crate) async fn status_by_id(&self, a: StatusByIdArgs) -> Result<RepoStatus> {
1540 self.registry.status(&a.id).await
1541 }
1542}
1543
1544#[cfg(test)]
1545mod tests {
1546 use super::*;
1547
1548 fn owner() -> Asker {
1549 Asker {
1550 person: true,
1551 verified: true,
1552 role: Some(Role::Owner),
1553 repo_role: Some(RepoRole::Admin),
1554 privileges: g1t_contracts::MemberPrivileges::default(),
1555 }
1556 }
1557
1558 fn path() -> RepoPath {
1559 RepoPath {
1560 namespace: "acme".into(),
1561 name: "rocket".into(),
1562 }
1563 }
1564
1565 #[test]
1566 fn only_a_verified_person_who_owns_the_workspace_may() {
1567 assert!(owner_only(owner(), "acme", "delete").is_ok());
1568 let member = Asker { role: Some(Role::Member), ..owner() };
1569 let (code, message) = owner_only(member, "acme", "delete").unwrap_err();
1570 assert_eq!(code, FailureCode::Forbidden);
1571 assert_eq!(message, "Only an owner of acme can delete its repositories.");
1572 assert_eq!(owner_only(Asker { person: false, ..owner() }, "acme", "delete").unwrap_err().0, FailureCode::Forbidden);
1573 assert_eq!(owner_only(Asker { verified: false, ..owner() }, "acme", "delete").unwrap_err().0, FailureCode::Forbidden);
1574 // Outside the workspace, a private repository is not there at all.
1575 assert_eq!(owner_only(Asker { role: None, ..owner() }, "acme", "delete").unwrap_err().0, FailureCode::NotFound);
1576 }
1577
1578 /// Renaming, archiving and changing visibility take Admin on the
1579 /// repository, which a direct grant can give; deleting and
1580 /// transferring still take an owner of the workspace.
1581 #[test]
1582 fn admin_on_the_repository_may_administer_but_not_delete() {
1583 let admin = Asker { role: None, repo_role: Some(RepoRole::Admin), ..owner() };
1584 assert!(admin_only(admin, "acme", "rename", Capability::Administer).is_ok());
1585 let (code, message) = admin_only(admin, "acme", "delete", Capability::Delete).unwrap_err();
1586 assert_eq!(code, FailureCode::Forbidden);
1587 assert_eq!(message, "Only an owner of acme can delete its repositories.");
1588 let member_admin = Asker { role: Some(Role::Member), ..admin };
1589 assert_eq!(admin_only(member_admin, "acme", "delete", Capability::Delete).unwrap_err().0, FailureCode::Forbidden);
1590 // Write (the default base permission) cannot rename.
1591 // By default a member with Admin changes visibility but does not
1592 // delete; the workspace's member privileges change both.
1593 assert!(admin_only(member_admin, "acme", "change the visibility of", Capability::ChangeVisibility).is_ok());
1594 let open = Asker {
1595 privileges: g1t_contracts::MemberPrivileges { members_can_delete_repositories: true, ..g1t_contracts::MemberPrivileges::default() },
1596 ..member_admin
1597 };
1598 assert!(admin_only(open, "acme", "delete", Capability::Delete).is_ok());
1599 let closed = Asker {
1600 privileges: g1t_contracts::MemberPrivileges { members_can_change_repo_visibility: false, ..g1t_contracts::MemberPrivileges::default() },
1601 ..member_admin
1602 };
1603 assert_eq!(
1604 admin_only(closed, "acme", "change the visibility of", Capability::ChangeVisibility).unwrap_err().0,
1605 FailureCode::Forbidden
1606 );
1607 // An outside collaborator with Admin never can.
1608 assert_eq!(admin_only(Asker { privileges: open.privileges, ..admin }, "acme", "delete", Capability::Delete).unwrap_err().0, FailureCode::Forbidden);
1609 let writer = Asker { role: Some(Role::Member), repo_role: Some(RepoRole::Write), ..owner() };
1610 let (code, message) = admin_only(writer, "acme", "rename", Capability::Administer).unwrap_err();
1611 assert_eq!(code, FailureCode::Forbidden);
1612 assert_eq!(message, "You need the Admin role on a repository of acme to rename it.");
1613 // Someone who can read a public repository is refused, not told it is missing.
1614 let reader = Asker { role: None, repo_role: Some(RepoRole::Read), ..owner() };
1615 assert_eq!(admin_only(reader, "acme", "archive", Capability::Administer).unwrap_err().0, FailureCode::Forbidden);
1616 let stranger = Asker { role: None, repo_role: None, ..owner() };
1617 assert_eq!(admin_only(stranger, "acme", "archive", Capability::Administer).unwrap_err().0, FailureCode::NotFound);
1618 assert_eq!(admin_only(Asker { person: false, ..owner() }, "acme", "rename", Capability::Administer).unwrap_err().0, FailureCode::Forbidden);
1619 }
1620
1621 #[test]
1622 fn a_workspace_restore_brings_back_only_what_went_with_it() {
1623 assert!(comes_back_with(Some("wsp_1"), "wsp_1"));
1624 // Deleted on its own before the workspace was.
1625 assert!(!comes_back_with(None, "wsp_1"));
1626 // Went with another workspace (it was transferred since).
1627 assert!(!comes_back_with(Some("wsp_2"), "wsp_1"));
1628 }
1629
1630 #[test]
1631 fn a_protected_workspace_keeps_its_repositories_whatever_is_published() {
1632 let protected = g1t_contracts::identity::protected_names(None);
1633 assert_eq!(
1634 may_go_with_workspace("flagon-io", &protected).unwrap_err(),
1635 "flagon-io is protected and can never be deleted."
1636 );
1637 assert!(may_go_with_workspace("FLAGON-IO", &protected).is_err());
1638 assert!(may_go_with_workspace("acme", &protected).is_ok());
1639 let configured = g1t_contracts::identity::protected_names(Some("acme"));
1640 assert!(may_go_with_workspace("acme", &configured).is_err());
1641 }
1642
1643 #[test]
1644 fn the_full_name_confirms_in_any_case() {
1645 assert!(confirmed(&path(), "acme/rocket"));
1646 assert!(confirmed(&path(), " ACME/Rocket "));
1647 assert!(!confirmed(&path(), "rocket"));
1648 assert!(!confirmed(&path(), ""));
1649 }
1650
1651 #[test]
1652 fn a_deleted_repository_is_restorable_for_thirty_days_then_due() {
1653 let deleted_at = 1_790_000_000_000u64;
1654 let purge = purge_after(deleted_at);
1655 assert_eq!(purge, rfc3339(deleted_at + 30 * 86_400_000));
1656 let day = 86_400_000u64;
1657 let at = |ms: u64| rfc3339(ms);
1658 assert_eq!(state(None, None, &at(deleted_at)), State::Active);
1659 assert_eq!(state(Some("2026-10-01T00:00:00.000Z"), None, &at(deleted_at)), State::Archived);
1660 let deleted = Some((at(deleted_at), purge.clone()));
1661 let deleted = deleted.as_ref().map(|(a, b)| (a.as_str(), b.as_str()));
1662 assert_eq!(state(None, deleted, &at(deleted_at + day)), State::Deleted);
1663 assert_eq!(state(None, deleted, &at(deleted_at + 30 * day - 1)), State::Deleted);
1664 assert_eq!(state(None, deleted, &at(deleted_at + 30 * day)), State::Due);
1665 // Archived and then deleted: deleted is what counts.
1666 assert_eq!(state(Some("x"), deleted, &at(deleted_at + day)), State::Deleted);
1667 assert!(restorable(&purge, &at(deleted_at + 29 * day)));
1668 assert!(!restorable(&purge, &at(deleted_at + 31 * day)));
1669 }
1670
1671 #[test]
1672 fn a_rename_needs_a_valid_free_name() {
1673 assert_eq!(new_name("acme", "rocket", " Booster ", Held::Free).unwrap(), "booster");
1674 assert_eq!(new_name("acme", "rocket", "rocket", Held::Free).unwrap_err().0, FailureCode::Invalid);
1675 assert_eq!(new_name("acme", "rocket", "no spaces", Held::Free).unwrap_err().0, FailureCode::Invalid);
1676 assert_eq!(new_name("acme", "rocket", "x.git", Held::Free).unwrap_err().0, FailureCode::Invalid);
1677 let (code, message) = new_name("acme", "rocket", "booster", Held::ByRepo).unwrap_err();
1678 assert_eq!(code, FailureCode::Conflict);
1679 assert_eq!(message, "acme already has a repository named booster.");
1680 let (code, message) = new_name("acme", "rocket", "booster", Held::ByDeleted).unwrap_err();
1681 assert_eq!(code, FailureCode::Conflict);
1682 assert!(message.contains("deleted recently"));
1683 }
1684
1685 fn repo(archived: bool) -> Repo {
1686 Repo {
1687 id: "rep_1".into(),
1688 namespace: "acme".into(),
1689 name: "rocket".into(),
1690 description: None,
1691 is_private: false,
1692 owner_id: "usr_1".into(),
1693 default_branch: "main".into(),
1694 fork_of: None,
1695 protected: false,
1696 created_at: String::new(),
1697 topics: Vec::new(),
1698 website: None,
1699 archived_at: archived.then(|| "2026-10-05T00:00:00.000Z".to_owned()),
1700 }
1701 }
1702
1703 #[test]
1704 fn an_archived_repository_refuses_writes_with_the_reason() {
1705 assert!(archived_refusal(&repo(false)).is_none());
1706 let (code, message) = archived_refusal(&repo(true)).unwrap();
1707 assert_eq!(code, FailureCode::Forbidden);
1708 assert_eq!(message, "acme/rocket is archived, so it is read-only. An owner can unarchive it in its settings.");
1709 }
1710
1711 #[test]
1712 fn going_private_on_a_free_workspace_needs_room() {
1713 let full = VisibilityFacts {
1714 to_private: true,
1715 free: true,
1716 private_bytes: 900_000_000,
1717 bytes: 200_000_000,
1718 free_private_bytes: 1_000_000_000,
1719 };
1720 assert_eq!(visibility_check("acme", &full).unwrap_err().0, FailureCode::PaymentRequired);
1721 assert!(visibility_check("acme", &VisibilityFacts { free: false, ..full }).is_ok());
1722 let full = VisibilityFacts {
1723 to_private: true,
1724 free: true,
1725 private_bytes: 900_000_000,
1726 bytes: 200_000_000,
1727 free_private_bytes: 1_000_000_000,
1728 };
1729 // Going public is never refused.
1730 assert!(visibility_check("acme", &VisibilityFacts { to_private: false, ..full }).is_ok());
1731 let light = VisibilityFacts {
1732 to_private: true,
1733 free: true,
1734 private_bytes: 1_000,
1735 bytes: 1_000,
1736 free_private_bytes: 1_000_000_000,
1737 };
1738 assert!(visibility_check("acme", &light).is_ok());
1739 }
1740
1741 #[test]
1742 fn a_branch_is_renamed_to_a_free_valid_name() {
1743 let branches = vec!["main".to_owned(), "dev".to_owned()];
1744 assert_eq!(branch_rename("main", " trunk ", &branches).unwrap(), "trunk");
1745 assert_eq!(branch_rename("nope", "trunk", &branches).unwrap_err().0, FailureCode::NotFound);
1746 assert_eq!(branch_rename("main", "dev", &branches).unwrap_err().0, FailureCode::Conflict);
1747 assert_eq!(branch_rename("main", "main", &branches).unwrap_err().0, FailureCode::Invalid);
1748 assert_eq!(branch_rename("main", "a b", &branches).unwrap_err().0, FailureCode::Invalid);
1749 assert_eq!(branch_rename("main", "g1t-queue", &branches).unwrap_err().0, FailureCode::Invalid);
1750 }
1751
1752 #[test]
1753 fn the_empty_pack_is_well_formed() {
1754 assert_eq!(EMPTY_PACK.len(), 32);
1755 assert!(EMPTY_PACK.starts_with(b"PACK\0\0\0\x02\0\0\0\0"));
1756 }
1757}