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