| 1 | //! Forks people make into their workspaces: a fork network. |
| 2 | //! |
| 3 | //! A fork is an ordinary repository of the workspace it goes in, with its |
| 4 | //! own access, settings, rules, issues and pull requests, that remembers the |
| 5 | //! repository it came from (`parent_id`). That is all it shares: it can be |
| 6 | //! synced from its parent and can open pull requests into it. It is not a |
| 7 | //! pull request's working copy (`fork_of`, namespace `pulls`, forks.rs), |
| 8 | //! which is hidden, belongs to one pull request and goes with its source. |
| 9 | //! |
| 10 | //! What a fork gets is what a fork gets elsewhere: the code (the default |
| 11 | //! branch, or every branch and tag), and its project starts with the source |
| 12 | //! project's description. Settings, secrets and variables, rules, webhooks, |
| 13 | //! environments, domains and deployments |
| 14 | //! stay behind, and the actions service keeps a fork's workflows off until |
| 15 | //! someone turns them on. |
| 16 | //! |
| 17 | //! Where it is stored: an Artifacts fork stays in its source's namespace, |
| 18 | //! so it is made there unless the workspace keeps its data in the EU and |
| 19 | //! the source is not there; then the code is copied into the EU namespace |
| 20 | //! instead. Either way its size counts toward the workspace it goes in, in |
| 21 | //! full: whether Artifacts shares a fork's objects with its source is not |
| 22 | //! documented, so a fork is never assumed to be cheaper than a copy. |
| 23 | //! |
| 24 | //! Who can see it: a fork is exactly as visible as its parent when it is |
| 25 | //! made, and cannot change that while it is a fork. When the parent goes |
| 26 | //! public or private, its forks are detached and keep what they were. A |
| 27 | //! private repository is forked only when its admins allow it. |
| 28 | |
| 29 | use std::collections::HashSet; |
| 30 | |
| 31 | use futures_util::future::try_join; |
| 32 | use g1t_contracts::access::Capability; |
| 33 | use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface}; |
| 34 | use g1t_contracts::events::{NewEvent, RepoCreated, RepoForked}; |
| 35 | use g1t_contracts::repos::{ |
| 36 | DetachForkArgs, ForkRepoArgs, ForksArgs, PullBranchUpdate, Repo, RepoPath, SyncForkArgs, SyncStatus, SyncedFork, |
| 37 | }; |
| 38 | use g1t_contracts::time::rfc3339; |
| 39 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, Viewer, is_valid_repo_name, new_id}; |
| 40 | use g1t_kit::now_ms; |
| 41 | use worker::Result; |
| 42 | |
| 43 | use crate::lifecycle::{VisibilityFacts, visibility_check}; |
| 44 | use crate::registry::{RepoRow, can, can_read, can_write, store_key}; |
| 45 | use crate::store::{GitRepo, GitStore, Scope, locate}; |
| 46 | use crate::{MAX_ANCESTRY, Repos, SOURCE, UNVERIFIED, copy, descends_from, land, lifecycle, not_found, shards}; |
| 47 | |
| 48 | /// The most forks `forks` lists. |
| 49 | const MAX_LISTED: u32 = 100; |
| 50 | /// The most conflicting files `sync_fork` names. |
| 51 | const MAX_CONFLICTS: usize = 20; |
| 52 | /// How many private forks one sweep checks, the least recently checked |
| 53 | /// first. |
| 54 | const CHECKS_PER_SWEEP: u32 = 50; |
| 55 | |
| 56 | type Refusal = (FailureCode, String); |
| 57 | |
| 58 | /// Why `source` may not be forked, if it may not. `None` when it may. |
| 59 | pub fn fork_refusal(source: &Repo) -> Option<Refusal> { |
| 60 | if source.fork_of.is_some() { |
| 61 | return Some((FailureCode::Invalid, "A pull request's working copy cannot be forked; fork the repository it belongs to.".to_owned())); |
| 62 | } |
| 63 | if source.is_private && !source.allow_forking { |
| 64 | return Some(( |
| 65 | FailureCode::Forbidden, |
| 66 | format!( |
| 67 | "{}/{} is private, and its admins have not allowed forking it. They can in its settings.", |
| 68 | source.namespace, source.name |
| 69 | ), |
| 70 | )); |
| 71 | } |
| 72 | None |
| 73 | } |
| 74 | |
| 75 | /// Whether a fork made into `namespace` with store key `wanted` can be an |
| 76 | /// Artifacts fork of a source kept in `source_namespace`: the same store |
| 77 | /// namespace, and the workspace's residency allows it there. |
| 78 | pub fn forks_in_place(source_namespace: &str, residency: shards::Residency, eu: Option<&str>) -> bool { |
| 79 | match residency { |
| 80 | shards::Residency::Anywhere => true, |
| 81 | shards::Residency::Eu => eu == Some(source_namespace), |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | /// The branch of the parent a fork's `branch` follows: the parent's |
| 86 | /// default branch for the fork's default branch, the same name otherwise. |
| 87 | pub fn upstream_branch(branch: &str, fork: &Repo, parent: &Repo) -> String { |
| 88 | if branch == fork.default_branch { parent.default_branch.clone() } else { branch.to_owned() } |
| 89 | } |
| 90 | |
| 91 | /// How many commits of `history` (newest first) come before any of |
| 92 | /// `known`: what one side has that the other lacks, as far as either was |
| 93 | /// read. |
| 94 | pub fn not_in(history: &[g1t_contracts::repos::Commit], known: &HashSet<&str>) -> u32 { |
| 95 | history.iter().take_while(|commit| !known.contains(commit.hash.as_str())).count() as u32 |
| 96 | } |
| 97 | |
| 98 | /// The most merge commits whose other parents `reachable` follows. |
| 99 | const MERGES_FOLLOWED: usize = 10; |
| 100 | |
| 101 | /// The commits `history` (a first-parent log, newest first) reaches: those |
| 102 | /// it lists, and the first-parent history of the other parents of its |
| 103 | /// first [`MERGES_FOLLOWED`] merge commits, so commits a sync merged in |
| 104 | /// count as had. |
| 105 | async fn reachable<R: GitRepo>(git: &R, history: &[g1t_contracts::repos::Commit]) -> Result<HashSet<String>> { |
| 106 | let mut known: HashSet<String> = history.iter().map(|commit| commit.hash.clone()).collect(); |
| 107 | let merged: Vec<&String> = history |
| 108 | .iter() |
| 109 | .filter(|commit| commit.parents.len() > 1) |
| 110 | .take(MERGES_FOLLOWED) |
| 111 | .flat_map(|commit| commit.parents.iter().skip(1)) |
| 112 | .collect(); |
| 113 | for parent in merged { |
| 114 | if known.contains(parent) { |
| 115 | continue; |
| 116 | } |
| 117 | known.extend(git.log(parent, MAX_ANCESTRY).await?.into_iter().map(|commit| commit.hash)); |
| 118 | } |
| 119 | Ok(known) |
| 120 | } |
| 121 | |
| 122 | impl<S: GitStore> Repos<S> { |
| 123 | /// `fork`: see `ForkRepoArgs`. |
| 124 | pub(crate) async fn fork(&self, a: ForkRepoArgs) -> Result<Outcome<Repo>> { |
| 125 | if !a.actor.verified { |
| 126 | return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED)); |
| 127 | } |
| 128 | let viewer = Some(a.actor.clone()); |
| 129 | let Some(source) = self.readable(&a.path, &viewer).await? else { |
| 130 | return Ok(not_found()); |
| 131 | }; |
| 132 | if let Some((code, message)) = fork_refusal(&source) { |
| 133 | return Ok(Outcome::fail(code, message)); |
| 134 | } |
| 135 | let name = a.name.as_deref().map(str::trim).filter(|name| !name.is_empty()).unwrap_or(&source.name).to_lowercase(); |
| 136 | if !is_valid_repo_name(&name) { |
| 137 | return Ok(Outcome::fail(FailureCode::Invalid, "Use letters, digits, dots, hyphens and underscores only.")); |
| 138 | } |
| 139 | let namespace = a.namespace.trim().to_lowercase(); |
| 140 | if namespace.is_empty() { |
| 141 | return Ok(Outcome::fail(FailureCode::Invalid, "Say which workspace to fork the repository into.")); |
| 142 | } |
| 143 | let Some(role) = a.actor.role_in(&namespace) else { |
| 144 | return Ok(Outcome::fail(FailureCode::Forbidden, "You are not a member of that workspace.")); |
| 145 | }; |
| 146 | let role = if a.actor.kind == PrincipalKind::Workspace { Role::Owner } else { role }; |
| 147 | if let Some(why) = a.actor.privileges_in(&namespace).creation_refusal(role, source.is_private, &namespace) { |
| 148 | return Ok(Outcome::fail(FailureCode::Forbidden, why)); |
| 149 | } |
| 150 | let path = RepoPath { namespace: namespace.clone(), name }; |
| 151 | if path.namespace == source.namespace && path.name == source.name { |
| 152 | return Ok(Outcome::fail(FailureCode::Conflict, "A repository cannot be forked onto itself. Choose another name or workspace.")); |
| 153 | } |
| 154 | match self.registry.by_path_any(&path).await? { |
| 155 | Some((_, None)) => { |
| 156 | return Ok(Outcome::fail(FailureCode::Conflict, "That workspace already has a repository with that name.")); |
| 157 | } |
| 158 | Some((_, Some(_))) => { |
| 159 | return Ok(Outcome::fail( |
| 160 | FailureCode::Conflict, |
| 161 | format!( |
| 162 | "{}/{} was deleted recently and can still be restored, so its name is taken. Restore it, or delete it permanently from the workspace's Recently deleted list.", |
| 163 | path.namespace, path.name |
| 164 | ), |
| 165 | )); |
| 166 | } |
| 167 | None => {} |
| 168 | } |
| 169 | // Its copy is made in the source's store namespace: not while that |
| 170 | // moves (moves.rs). |
| 171 | let source = match self.unpaused(source).await? { |
| 172 | Ok(source) => source, |
| 173 | Err((code, message)) => return Ok(Outcome::fail(code, message)), |
| 174 | }; |
| 175 | // A fork counts toward its workspace in full, so a free workspace's |
| 176 | // private storage has to have room for all of it. |
| 177 | let bytes = self.registry.stored_bytes(&source.id).await?; |
| 178 | if source.is_private { |
| 179 | let facts = VisibilityFacts { |
| 180 | to_private: true, |
| 181 | free: crate::git_ops::is_free(self.billing.as_ref(), &namespace).await, |
| 182 | private_bytes: self.registry.private_bytes(&namespace).await.unwrap_or(0), |
| 183 | bytes, |
| 184 | free_private_bytes: self.free_private_bytes, |
| 185 | }; |
| 186 | if let Err((code, message)) = visibility_check(&namespace, &facts) { |
| 187 | return Ok(Outcome::fail(code, message)); |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | let now = now_ms(); |
| 192 | let fork = Repo { |
| 193 | id: new_id("rep", now), |
| 194 | namespace: path.namespace, |
| 195 | name: path.name, |
| 196 | is_private: source.is_private, |
| 197 | owner_id: a.actor.id.clone(), |
| 198 | default_branch: source.default_branch.clone(), |
| 199 | fork_of: None, |
| 200 | parent_id: Some(source.id.clone()), |
| 201 | forked_from: Some(source.id.clone()), |
| 202 | parent: Some(RepoPath { namespace: source.namespace.clone(), name: source.name.clone() }), |
| 203 | allow_forking: false, |
| 204 | max_ref_updates: None, |
| 205 | protected: false, |
| 206 | created_at: rfc3339(now), |
| 207 | topics: Vec::new(), |
| 208 | website: None, |
| 209 | archived_at: None, |
| 210 | }; |
| 211 | let default = self.store.default_namespace(); |
| 212 | let (source_namespace, _) = locate(&store_key(&source)); |
| 213 | let residency = self.residency_of(&fork.namespace).await?; |
| 214 | if forks_in_place(&source_namespace, residency, self.placement.eu.as_deref()) { |
| 215 | self.registry.claim_store_key(&fork, Some(&source_namespace), &default).await?; |
| 216 | self.store.open(&store_key(&source)).await?.fork(&store_key(&fork), a.default_branch_only).await?; |
| 217 | } else { |
| 218 | // Kept in the EU: copied there, never forked across. |
| 219 | let placed = match self.place(&fork).await? { |
| 220 | Ok(placed) => placed, |
| 221 | Err(unplaced) => return Ok(Outcome::fail(FailureCode::Conflict, unplaced.message())), |
| 222 | }; |
| 223 | self.registry.claim_store_key(&fork, placed.as_deref(), &default).await?; |
| 224 | self.store.create(&store_key(&fork), None, &fork.default_branch).await?; |
| 225 | let from = self.store.open(&store_key(&source)).await?.access(Scope::Read).await?; |
| 226 | let to = self.store.open(&store_key(&fork)).await?.access(Scope::Write).await?; |
| 227 | let copied = if a.default_branch_only { |
| 228 | let head = self.store.open(&store_key(&source)).await?.log(&source.default_branch, 1).await?; |
| 229 | match head.first() { |
| 230 | Some(head) => land::fast_forward(&from, &to, &fork.default_branch, None, &head.hash).await?, |
| 231 | None => Ok(()), |
| 232 | } |
| 233 | } else { |
| 234 | copy::copy( |
| 235 | ©::Endpoint::bearer(&from.remote, &from.token), |
| 236 | ©::Endpoint::bearer(&to.remote, &to.token), |
| 237 | ) |
| 238 | .await? |
| 239 | .map(|_| ()) |
| 240 | .map_err(|problem| problem.to_string()) |
| 241 | }; |
| 242 | if let Err(reason) = copied { |
| 243 | return Ok(Outcome::fail(FailureCode::Unavailable, format!("The fork could not be copied: {reason}"))); |
| 244 | } |
| 245 | } |
| 246 | self.registry.insert(&fork).await?; |
| 247 | self.registry.set_stored_bytes(&fork.id, bytes).await?; |
| 248 | self.registry.drop_redirect(&RepoPath { namespace: fork.namespace.clone(), name: fork.name.clone() }).await?; |
| 249 | self.refs_moved(&fork.id).await; |
| 250 | // Whoever forks a repository is an Admin of the fork. |
| 251 | if a.actor.kind == PrincipalKind::User { |
| 252 | self.grant_creator(&fork, &a.actor.id).await; |
| 253 | } |
| 254 | let surface = a.surface.unwrap_or(Surface::Web); |
| 255 | self.record_fork(&a.actor, "repo.forked", surface, &fork, format!("Forked from {}/{}", source.namespace, source.name)) |
| 256 | .await; |
| 257 | if source.namespace != fork.namespace { |
| 258 | self.record_fork(&a.actor, "repo.forked", surface, &source, format!("Forked to {}/{}", fork.namespace, fork.name)) |
| 259 | .await; |
| 260 | } |
| 261 | self.publish(NewEvent { |
| 262 | kind: "repo.created", |
| 263 | source: SOURCE, |
| 264 | repo_id: Some(fork.id.clone()), |
| 265 | actor: Some(a.actor.id.clone()), |
| 266 | data: RepoCreated { |
| 267 | repo_id: fork.id.clone(), |
| 268 | namespace: fork.namespace.clone(), |
| 269 | name: fork.name.clone(), |
| 270 | is_private: fork.is_private, |
| 271 | parent_id: Some(source.id.clone()), |
| 272 | }, |
| 273 | }) |
| 274 | .await?; |
| 275 | self.publish(NewEvent { |
| 276 | kind: "repo.forked", |
| 277 | source: SOURCE, |
| 278 | repo_id: Some(source.id.clone()), |
| 279 | actor: Some(a.actor.id), |
| 280 | data: RepoForked { repo_id: fork.id.clone(), source_repo_id: source.id, pull_id: None }, |
| 281 | }) |
| 282 | .await?; |
| 283 | Ok(Outcome::Ok(fork)) |
| 284 | } |
| 285 | |
| 286 | /// `forks`: see `ForksArgs`. |
| 287 | pub(crate) async fn forks(&self, a: ForksArgs) -> Result<Outcome<Vec<Repo>>> { |
| 288 | let Some(repo) = self.readable(&a.path, &a.viewer).await? else { |
| 289 | return Ok(not_found()); |
| 290 | }; |
| 291 | let limit = a.limit.unwrap_or(30).clamp(1, MAX_LISTED); |
| 292 | let rows = self |
| 293 | .registry |
| 294 | .db |
| 295 | .prepare("SELECT * FROM repos WHERE parent_id = ? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT ?") |
| 296 | .bind(&[repo.id.as_str().into(), (limit * 2).into()])? |
| 297 | .all() |
| 298 | .await? |
| 299 | .results::<RepoRow>()?; |
| 300 | let parent = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }; |
| 301 | Ok(Outcome::Ok( |
| 302 | rows.into_iter() |
| 303 | .map(Repo::from) |
| 304 | .filter(|fork| can_read(fork, &a.viewer)) |
| 305 | .take(limit as usize) |
| 306 | .map(|mut fork| { |
| 307 | fork.parent = Some(parent.clone()); |
| 308 | fork |
| 309 | }) |
| 310 | .collect(), |
| 311 | )) |
| 312 | } |
| 313 | |
| 314 | /// Fills in where a fork's parent is, when the viewer may read it. |
| 315 | pub(crate) async fn with_parent(&self, mut repo: Repo, viewer: &Viewer) -> Result<Repo> { |
| 316 | if let Some(parent_id) = &repo.parent_id { |
| 317 | repo.parent = self |
| 318 | .registry |
| 319 | .by_id(parent_id) |
| 320 | .await? |
| 321 | .filter(|parent| can_read(parent, viewer)) |
| 322 | .map(|parent| RepoPath { namespace: parent.namespace, name: parent.name }); |
| 323 | } |
| 324 | Ok(repo) |
| 325 | } |
| 326 | |
| 327 | /// `sync_fork`: see `SyncForkArgs`. |
| 328 | pub(crate) async fn sync_fork(&self, a: SyncForkArgs) -> Result<Outcome<SyncedFork>> { |
| 329 | let viewer = Some(a.actor.clone()); |
| 330 | let Some(fork) = self.readable(&a.path, &viewer).await? else { |
| 331 | return Ok(not_found()); |
| 332 | }; |
| 333 | let full_name = format!("{}/{}", fork.namespace, fork.name); |
| 334 | let Some(parent_id) = fork.parent_id.clone() else { |
| 335 | return Ok(Outcome::fail(FailureCode::Invalid, format!("{full_name} is not a fork."))); |
| 336 | }; |
| 337 | if !can_write(&fork, &viewer) { |
| 338 | return Ok(Outcome::fail(FailureCode::Forbidden, g1t_contracts::access::needs(Capability::Push, &full_name))); |
| 339 | } |
| 340 | if !a.actor.verified { |
| 341 | return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED)); |
| 342 | } |
| 343 | if let Some((code, message)) = lifecycle::read_only_refusal(&fork) { |
| 344 | return Ok(Outcome::fail(code, message)); |
| 345 | } |
| 346 | let Some(parent) = self.registry.by_id(&parent_id).await?.filter(|parent| can_read(parent, &viewer)) else { |
| 347 | return Ok(Outcome::fail( |
| 348 | FailureCode::NotFound, |
| 349 | format!("The repository {full_name} was forked from is gone, or you can no longer read it."), |
| 350 | )); |
| 351 | }; |
| 352 | let (fork, parent) = match (self.unpaused(fork).await?, self.unpaused(parent).await?) { |
| 353 | (Ok(fork), Ok(parent)) => (fork, parent), |
| 354 | (Err((code, message)), _) | (_, Err((code, message))) => return Ok(Outcome::fail(code, message)), |
| 355 | }; |
| 356 | let branch = a.branch.as_deref().map(str::trim).filter(|branch| !branch.is_empty()).unwrap_or(&fork.default_branch).to_owned(); |
| 357 | let upstream = upstream_branch(&branch, &fork, &parent); |
| 358 | let fork_git = self.store.open(&store_key(&fork)).await?; |
| 359 | let parent_git = self.store.open(&store_key(&parent)).await?; |
| 360 | let (ours, theirs) = try_join(fork_git.log(&branch, MAX_ANCESTRY), parent_git.log(&upstream, MAX_ANCESTRY)).await?; |
| 361 | let Some(head) = ours.first().cloned() else { |
| 362 | return Ok(Outcome::fail(FailureCode::NotFound, format!("{full_name} has no branch named {branch}."))); |
| 363 | }; |
| 364 | let Some(their_head) = theirs.first().cloned() else { |
| 365 | return Ok(Outcome::fail( |
| 366 | FailureCode::NotFound, |
| 367 | format!("{}/{} has no branch named {upstream} to sync from.", parent.namespace, parent.name), |
| 368 | )); |
| 369 | }; |
| 370 | let (our_reach, their_reach) = try_join(reachable(&fork_git, &ours), reachable(&parent_git, &theirs)).await?; |
| 371 | let our_hashes: HashSet<&str> = our_reach.iter().map(String::as_str).collect(); |
| 372 | let their_hashes: HashSet<&str> = their_reach.iter().map(String::as_str).collect(); |
| 373 | let behind = not_in(&theirs, &our_hashes); |
| 374 | let ahead = not_in(&ours, &their_hashes); |
| 375 | let synced = |status, head: String, conflicts| SyncedFork { status, branch: branch.clone(), head, behind, ahead, conflicts }; |
| 376 | if descends_from(&fork_git, &ours, &their_head.hash).await? { |
| 377 | return Ok(Outcome::Ok(synced(SyncStatus::UpToDate, head.hash, Vec::new()))); |
| 378 | } |
| 379 | if descends_from(&parent_git, &theirs, &head.hash).await? { |
| 380 | // Only behind: the branch moves to where the parent's is, if it |
| 381 | // is still where it was, and if the fork's rules for the branch |
| 382 | // allow the push (rules.rs). Its commits are not read here, so a |
| 383 | // rule about what commits hold refuses it rather than pass. |
| 384 | let change = g1t_rules::push::RefChange { |
| 385 | git_ref: format!("refs/heads/{branch}"), |
| 386 | old: Some(head.hash.clone()), |
| 387 | new: Some(their_head.hash.clone()), |
| 388 | fast_forward: Some(true), |
| 389 | commits: Vec::new(), |
| 390 | complete: false, |
| 391 | }; |
| 392 | if let crate::rules::Ruled::Refused { message, .. } = |
| 393 | self.check_changes(&fork, &a.actor, g1t_contracts::rules::Action::Push, vec![change]).await? |
| 394 | { |
| 395 | return Ok(Outcome::fail(FailureCode::Forbidden, message)); |
| 396 | } |
| 397 | let from = parent_git.access(Scope::Read).await?; |
| 398 | let to = fork_git.access(Scope::Write).await?; |
| 399 | let moved = land::fast_forward(&from, &to, &branch, Some(&head.hash), &their_head.hash).await?; |
| 400 | self.refs_moved(&fork.id).await; |
| 401 | if moved.is_err() { |
| 402 | return Ok(Outcome::fail(FailureCode::Conflict, format!("{branch} moved while it was being synced. Nothing was lost; try again."))); |
| 403 | } |
| 404 | let git_ref = format!("refs/heads/{branch}"); |
| 405 | self.publish_push(&fork, &git_ref, Some(&head.hash), &their_head.hash, Some(&a.actor), Some(behind)).await?; |
| 406 | return Ok(Outcome::Ok(synced(SyncStatus::FastForwarded, their_head.hash, Vec::new()))); |
| 407 | } |
| 408 | let message = format!("Merge {}/{}:{upstream} into {branch}", parent.namespace, parent.name); |
| 409 | let merged = self.merge_up(fork, parent, branch.clone(), upstream, &a.actor, message, None).await?; |
| 410 | Ok(match merged { |
| 411 | Outcome::Ok(PullBranchUpdate::Updated { commit, .. }) => Outcome::Ok(synced(SyncStatus::Merged, commit, Vec::new())), |
| 412 | Outcome::Ok(PullBranchUpdate::UpToDate { commit }) => Outcome::Ok(synced(SyncStatus::UpToDate, commit, Vec::new())), |
| 413 | Outcome::Ok(PullBranchUpdate::NeedsAgent { paths, .. }) => { |
| 414 | Outcome::Ok(synced(SyncStatus::Conflicted, head.hash, paths.into_iter().take(MAX_CONFLICTS).collect())) |
| 415 | } |
| 416 | Outcome::Fail(failure) => Outcome::Fail(failure), |
| 417 | }) |
| 418 | } |
| 419 | |
| 420 | /// `detach_fork`: see `DetachForkArgs`. |
| 421 | pub(crate) async fn detach_fork(&self, a: DetachForkArgs) -> Result<Outcome<Repo>> { |
| 422 | let viewer = Some(a.actor.clone()); |
| 423 | let Some(fork) = self.readable(&a.path, &viewer).await? else { |
| 424 | return Ok(not_found()); |
| 425 | }; |
| 426 | let full_name = format!("{}/{}", fork.namespace, fork.name); |
| 427 | if fork.parent_id.is_none() { |
| 428 | return Ok(Outcome::fail(FailureCode::Invalid, format!("{full_name} is not a fork."))); |
| 429 | } |
| 430 | if !can(&fork, &viewer, Capability::Administer) { |
| 431 | return Ok(Outcome::fail(FailureCode::Forbidden, g1t_contracts::access::needs(Capability::Administer, &full_name))); |
| 432 | } |
| 433 | if !a.actor.verified { |
| 434 | return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED)); |
| 435 | } |
| 436 | self.registry.detach(&fork.id).await?; |
| 437 | if let Some(parent_id) = &fork.parent_id { |
| 438 | self.publish_detached(&fork.id, parent_id).await?; |
| 439 | } |
| 440 | self.record_fork(&a.actor, "repo.detached", a.surface.unwrap_or(Surface::Web), &fork, "Detached from its fork network".to_owned()) |
| 441 | .await; |
| 442 | let mut detached = fork; |
| 443 | detached.parent_id = None; |
| 444 | detached.parent = None; |
| 445 | Ok(Outcome::Ok(detached)) |
| 446 | } |
| 447 | |
| 448 | /// The hourly check of private forks: one whose maker can no longer |
| 449 | /// read the repository it came from is deleted, restorable for |
| 450 | /// `RESTORE_DAYS` days, as its workspace's owners would delete it. A |
| 451 | /// fork made with a workspace's own token, or whose parent is gone or |
| 452 | /// public, is kept. Returns how many were deleted. |
| 453 | pub(crate) async fn check_private_forks(&self) -> Result<usize> { |
| 454 | let Some(identity) = &self.identity else { return Ok(0) }; |
| 455 | let forks = self |
| 456 | .registry |
| 457 | .db |
| 458 | .prepare( |
| 459 | "SELECT * FROM repos WHERE parent_id IS NOT NULL AND is_private = 1 AND deleted_at IS NULL |
| 460 | ORDER BY COALESCE(fork_checked_ms, 0) LIMIT ?", |
| 461 | ) |
| 462 | .bind(&[CHECKS_PER_SWEEP.into()])? |
| 463 | .all() |
| 464 | .await? |
| 465 | .results::<RepoRow>()?; |
| 466 | let now = now_ms(); |
| 467 | let mut deleted = 0; |
| 468 | for fork in forks.into_iter().map(Repo::from) { |
| 469 | self.registry |
| 470 | .db |
| 471 | .prepare("UPDATE repos SET fork_checked_ms = ? WHERE id = ?") |
| 472 | .bind(&[(now as f64).into(), fork.id.as_str().into()])? |
| 473 | .run() |
| 474 | .await?; |
| 475 | let Some(parent_id) = &fork.parent_id else { continue }; |
| 476 | let Some(parent) = self.registry.by_id(parent_id).await? else { continue }; |
| 477 | if !fork.owner_id.starts_with("usr_") { |
| 478 | continue; |
| 479 | } |
| 480 | let readable: bool = g1t_kit::call( |
| 481 | identity, |
| 482 | "can_read_repo", |
| 483 | &g1t_contracts::access::CanReadRepoArgs { repo: parent.clone(), user_id: fork.owner_id.clone() }, |
| 484 | ) |
| 485 | .await?; |
| 486 | if readable { |
| 487 | continue; |
| 488 | } |
| 489 | let at = rfc3339(now); |
| 490 | let purge = lifecycle::purge_after(now); |
| 491 | self.registry.soft_delete(&fork.id, "g1t", &at, &purge).await?; |
| 492 | self.publish(NewEvent { |
| 493 | kind: "repo.deleted", |
| 494 | source: SOURCE, |
| 495 | repo_id: Some(fork.id.clone()), |
| 496 | actor: None, |
| 497 | data: g1t_contracts::events::RepoDeleted { |
| 498 | repo_id: fork.id.clone(), |
| 499 | namespace: fork.namespace.clone(), |
| 500 | name: fork.name.clone(), |
| 501 | is_private: true, |
| 502 | purge_after: purge.clone(), |
| 503 | with_workspace: false, |
| 504 | }, |
| 505 | }) |
| 506 | .await?; |
| 507 | self.record_fork_as( |
| 508 | AuditActor::system(), |
| 509 | "repo.deleted", |
| 510 | Surface::Web, |
| 511 | &fork, |
| 512 | format!( |
| 513 | "Deleted: whoever forked it can no longer read {}/{}; restorable until {purge}", |
| 514 | parent.namespace, parent.name |
| 515 | ), |
| 516 | ) |
| 517 | .await; |
| 518 | deleted += 1; |
| 519 | } |
| 520 | Ok(deleted) |
| 521 | } |
| 522 | |
| 523 | /// `repo.detached`, for a fork that left its parent's network. |
| 524 | pub(crate) async fn publish_detached(&self, repo_id: &str, parent_id: &str) -> Result<()> { |
| 525 | self.publish(NewEvent { |
| 526 | kind: "repo.detached", |
| 527 | source: SOURCE, |
| 528 | repo_id: Some(repo_id.to_owned()), |
| 529 | actor: None, |
| 530 | data: g1t_contracts::events::RepoDetached { repo_id: repo_id.to_owned(), parent_id: parent_id.to_owned() }, |
| 531 | }) |
| 532 | .await |
| 533 | } |
| 534 | |
| 535 | /// One entry in `repo`'s workspace's audit log. |
| 536 | async fn record_fork(&self, actor: &g1t_contracts::User, action: &str, surface: Surface, repo: &Repo, message: String) { |
| 537 | self.record_fork_as(AuditActor::of(actor), action, surface, repo, message).await; |
| 538 | } |
| 539 | |
| 540 | async fn record_fork_as(&self, actor: AuditActor, action: &str, surface: Surface, repo: &Repo, message: String) { |
| 541 | let entry = NewAuditEntry { |
| 542 | actor, |
| 543 | action: action.to_owned(), |
| 544 | surface, |
| 545 | target: AuditTarget { |
| 546 | workspace: repo.namespace.clone(), |
| 547 | repo: Some(format!("{}/{}", repo.namespace, repo.name)), |
| 548 | ..AuditTarget::default() |
| 549 | }, |
| 550 | outcome: AuditOutcome::Allowed, |
| 551 | rule: "person".to_owned(), |
| 552 | result: Some("ok".to_owned()), |
| 553 | message: Some(message), |
| 554 | request_id: new_id("req", now_ms()), |
| 555 | }; |
| 556 | let recorded: Result<u32> = g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries: vec![entry] }).await; |
| 557 | if let Err(error) = recorded { |
| 558 | worker::console_error!("{action} audit entry not recorded: {error}"); |
| 559 | } |
| 560 | } |
| 561 | } |
| 562 | |
| 563 | #[cfg(test)] |
| 564 | mod tests { |
| 565 | use super::*; |
| 566 | |
| 567 | fn repo(private: bool) -> Repo { |
| 568 | Repo { |
| 569 | id: "rep_1".into(), |
| 570 | namespace: "acme".into(), |
| 571 | name: "rocket".into(), |
| 572 | is_private: private, |
| 573 | owner_id: "usr_1".into(), |
| 574 | default_branch: "main".into(), |
| 575 | fork_of: None, |
| 576 | parent_id: None, |
| 577 | forked_from: None, |
| 578 | parent: None, |
| 579 | allow_forking: false, |
| 580 | max_ref_updates: None, |
| 581 | protected: false, |
| 582 | created_at: String::new(), |
| 583 | topics: Vec::new(), |
| 584 | website: None, |
| 585 | archived_at: None, |
| 586 | } |
| 587 | } |
| 588 | |
| 589 | #[test] |
| 590 | fn public_repositories_can_always_be_forked() { |
| 591 | assert_eq!(fork_refusal(&repo(false)), None); |
| 592 | } |
| 593 | |
| 594 | #[test] |
| 595 | fn private_repositories_only_when_allowed() { |
| 596 | let (code, message) = fork_refusal(&repo(true)).unwrap(); |
| 597 | assert_eq!(code, FailureCode::Forbidden); |
| 598 | assert!(message.contains("acme/rocket is private"), "{message}"); |
| 599 | let mut allowed = repo(true); |
| 600 | allowed.allow_forking = true; |
| 601 | assert_eq!(fork_refusal(&allowed), None); |
| 602 | } |
| 603 | |
| 604 | #[test] |
| 605 | fn working_copies_are_never_forked() { |
| 606 | let mut copy = repo(false); |
| 607 | copy.fork_of = Some("rep_0".into()); |
| 608 | assert_eq!(fork_refusal(©).unwrap().0, FailureCode::Invalid); |
| 609 | } |
| 610 | |
| 611 | #[test] |
| 612 | fn an_eu_workspace_forks_in_place_only_from_the_eu_namespace() { |
| 613 | assert!(forks_in_place("g1t-us-1", shards::Residency::Anywhere, Some("g1t-eu-1"))); |
| 614 | assert!(forks_in_place("g1t-eu-1", shards::Residency::Eu, Some("g1t-eu-1"))); |
| 615 | assert!(!forks_in_place("g1t-us-1", shards::Residency::Eu, Some("g1t-eu-1"))); |
| 616 | assert!(!forks_in_place("g1t-us-1", shards::Residency::Eu, None)); |
| 617 | } |
| 618 | |
| 619 | #[test] |
| 620 | fn the_default_branch_follows_the_parents_default() { |
| 621 | let mut fork = repo(false); |
| 622 | fork.default_branch = "main".into(); |
| 623 | let mut parent = repo(false); |
| 624 | parent.default_branch = "trunk".into(); |
| 625 | assert_eq!(upstream_branch("main", &fork, &parent), "trunk"); |
| 626 | assert_eq!(upstream_branch("feature", &fork, &parent), "feature"); |
| 627 | } |
| 628 | |
| 629 | #[test] |
| 630 | fn counts_what_one_side_lacks() { |
| 631 | let commit = |hash: &str| g1t_contracts::repos::Commit { |
| 632 | hash: hash.into(), |
| 633 | tree_hash: String::new(), |
| 634 | message: String::new(), |
| 635 | author: g1t_contracts::repos::Signature { name: String::new(), email: String::new() }, |
| 636 | parents: Vec::new(), |
| 637 | authored_at: String::new(), |
| 638 | }; |
| 639 | let ours = vec![commit("c"), commit("b"), commit("a")]; |
| 640 | let theirs = vec![commit("e"), commit("d"), commit("b"), commit("a")]; |
| 641 | let our_hashes: HashSet<&str> = ours.iter().map(|c| c.hash.as_str()).collect(); |
| 642 | let their_hashes: HashSet<&str> = theirs.iter().map(|c| c.hash.as_str()).collect(); |
| 643 | assert_eq!(not_in(&theirs, &our_hashes), 2); |
| 644 | assert_eq!(not_in(&ours, &their_hashes), 1); |
| 645 | } |
| 646 | } |