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