worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | // Repository lifecycle on disk. |
| 2 | // |
| 3 | // Hooks are symlinks into `$DATA_DIR/hooks/`, installed through git's |
| 4 | // `init.templateDir` at creation. Symlinks rather than copies so upgrading a hook |
| 5 | // is atomic across every existing repository. |
| 6 | // |
| 7 | // The hooks contain no policy: each is a one-line `exec` into this binary. The ref |
| 8 | // rule therefore lives in Rust rather than in shell, where it would have been a |
| 9 | // second implementation that drifts. It has one caller — the `update` hook — because |
| 10 | // there is no API path that moves a ref; git is the only writer. |
| 11 | |
| 12 | use crate::brand; |
| 13 | use crate::error::{Error, Result}; |
| 14 | use crate::git::exec::Git; |
| 15 | use crate::git::validate::{self, Name}; |
| 16 | use anyhow::Context; |
| 17 | use std::path::{Path, PathBuf}; |
| 18 | |
| 19 | /// Hooks installed into every repository. Each delegates to `<binary> hook <name>` |
| 20 | /// so policy lives in Rust rather than in shell. |
| 21 | const HOOKS: &[&str] = &["update", "post-receive"]; |
| 22 | |
| 23 | #[derive(Debug, Clone)] |
| 24 | pub struct GitStore { |
| 25 | git_root: PathBuf, |
| 26 | hooks_dir: PathBuf, |
| 27 | template_dir: PathBuf, |
| 28 | deleted_dir: PathBuf, |
| 29 | /// Mirrors the transport's cap so `receive.maxInputSize` cannot disagree with |
| 30 | /// the byte limit the HTTP layer enforces. |
| 31 | max_pack_bytes: u64, |
| 32 | } |
| 33 | |
| 34 | impl GitStore { |
| 35 | pub fn open(data_dir: &Path, max_pack_bytes: u64) -> Result<Self> { |
| 36 | let store = GitStore { |
| 37 | git_root: data_dir.join("git"), |
| 38 | hooks_dir: data_dir.join("hooks"), |
| 39 | template_dir: data_dir.join("template"), |
| 40 | deleted_dir: data_dir.join("tmp").join("deleted"), |
| 41 | max_pack_bytes, |
| 42 | }; |
| 43 | store.install_hooks()?; |
| 44 | Ok(store) |
| 45 | } |
| 46 | |
| 47 | pub fn git_root(&self) -> &Path { |
| 48 | &self.git_root |
| 49 | } |
| 50 | |
| 51 | /// Write hook shims and point the template's `hooks/` at them. |
| 52 | /// |
| 53 | /// Idempotent, and rerun on every boot so an upgraded binary lands its hook |
| 54 | /// changes across repositories that already exist. |
| 55 | fn install_hooks(&self) -> Result<()> { |
| 56 | for dir in [&self.git_root, &self.hooks_dir, &self.deleted_dir] { |
| 57 | std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; |
| 58 | } |
| 59 | let template_hooks = self.template_dir.join("hooks"); |
| 60 | std::fs::create_dir_all(&template_hooks) |
| 61 | .with_context(|| format!("create {}", template_hooks.display()))?; |
| 62 | |
| 63 | let binary = std::env::current_exe().context("locate own executable")?; |
| 64 | |
| 65 | for name in HOOKS { |
| 66 | let target = self.hooks_dir.join(name); |
| 67 | let body = format!( |
| 68 | "#!/bin/sh\n# Installed by {}. Policy lives in the binary.\nexec {} hook {name} \"$@\"\n", |
| 69 | brand::NAME, |
| 70 | shell_quote(&binary.to_string_lossy()) |
| 71 | ); |
| 72 | std::fs::write(&target, body) |
| 73 | .with_context(|| format!("write hook {}", target.display()))?; |
| 74 | set_executable(&target)?; |
| 75 | |
| 76 | let link = template_hooks.join(name); |
| 77 | match std::fs::remove_file(&link) { |
| 78 | Ok(()) => {} |
| 79 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} |
| 80 | Err(e) => { |
| 81 | return Err(Error::Internal( |
| 82 | anyhow::Error::from(e).context(format!("unlink {}", link.display())), |
| 83 | )) |
| 84 | } |
| 85 | } |
| 86 | symlink(&target, &link).with_context(|| format!("link {}", link.display()))?; |
| 87 | } |
| 88 | Ok(()) |
| 89 | } |
| 90 | |
| 91 | pub fn repo_path(&self, account: &Name, repo: &Name) -> Result<PathBuf> { |
| 92 | validate::repo_path(&self.git_root, account, repo) |
| 93 | } |
| 94 | |
| 95 | pub fn exists(&self, account: &Name, repo: &Name) -> Result<bool> { |
| 96 | Ok(self.repo_path(account, repo)?.join("HEAD").is_file()) |
| 97 | } |
| 98 | |
| 99 | /// Initialise a bare repository with hooks installed and HEAD pointed at the |
| 100 | /// default branch. |
| 101 | /// |
| 102 | /// HEAD must be a symref to the branch even though it does not exist yet, or |
| 103 | /// cloning the empty repo leaves the client on its own `init.defaultBranch`. |
| 104 | pub fn create(&self, account: &Name, repo: &Name, default_branch: &str) -> Result<PathBuf> { |
| 105 | validate::ref_name(default_branch)?; |
| 106 | let path = self.repo_path(account, repo)?; |
| 107 | |
| 108 | let parent = path.parent().expect("repo path always has a parent"); |
| 109 | std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; |
| 110 | |
| 111 | let path_str = path |
| 112 | .to_str() |
| 113 | .ok_or_else(|| Error::invalid("invalid-name", "repository path is not valid UTF-8"))?; |
| 114 | |
| 115 | Git::plain().run(&[ |
| 116 | "init", |
| 117 | "--bare", |
| 118 | "--quiet", |
| 119 | &format!("--template={}", self.template_dir.display()), |
| 120 | &format!( |
| 121 | "--initial-branch={}", |
| 122 | default_branch |
| 123 | .strip_prefix("refs/heads/") |
| 124 | .unwrap_or(default_branch) |
| 125 | ), |
| 126 | path_str, |
| 127 | ])?; |
| 128 | |
| 129 | self.apply_repo_config(&path)?; |
| 130 | Ok(path) |
| 131 | } |
| 132 | |
| 133 | /// Config every repository must carry. Reapplied on open so an upgrade reaches |
| 134 | /// repositories created before a setting existed. |
| 135 | fn apply_repo_config(&self, path: &Path) -> Result<()> { |
| 136 | let git = Git::at(path); |
| 137 | let max_pack = self.max_pack_bytes.to_string(); |
| 138 | |
| 139 | for setting in [ |
| 140 | // Reject malformed and unreachable objects at receive time rather than |
| 141 | // storing them and failing later on read. |
| 142 | ["receive.fsckObjects", "true"].as_slice(), |
| 143 | // Git's own bound, applied at index-pack before the pack reaches disk. |
| 144 | // Taken from configuration so it cannot disagree with the transport cap. |
| 145 | ["receive.maxInputSize", &max_pack].as_slice(), |
| 146 | ["core.logAllRefUpdates", "true"].as_slice(), |
| 147 | // Maintenance is scheduled, not opportunistic: git's own auto-gc |
| 148 | // otherwise fires inside a push, at a moment nobody chose and |
| 149 | // unsynchronised with everything else. See `jobs::gc`. |
| 150 | ["gc.auto", "0"].as_slice(), |
| 151 | ["receive.autogc", "false"].as_slice(), |
| 152 | ] { |
| 153 | git.run(&["config", setting[0], setting[1]])?; |
| 154 | } |
| 155 | Ok(()) |
| 156 | } |
| 157 | |
| 158 | /// Apply our config and hooks to a repository that arrived from elsewhere. |
| 159 | /// |
| 160 | /// An imported repository is untrusted input and must end up indistinguishable |
| 161 | /// from one we created: same hooks, same receive limits, same maintenance |
| 162 | /// posture. `clone --mirror` brings none of that. |
| 163 | pub fn adopt(&self, account: &Name, repo: &Name) -> Result<()> { |
| 164 | let path = self.repo_path(account, repo)?; |
| 165 | self.apply_repo_config(&path)?; |
| 166 | |
| 167 | let hooks = path.join("hooks"); |
| 168 | std::fs::create_dir_all(&hooks).with_context(|| format!("create {}", hooks.display()))?; |
| 169 | |
| 170 | for name in HOOKS { |
| 171 | let link = hooks.join(name); |
| 172 | let _ = std::fs::remove_file(&link); |
| 173 | symlink(&self.hooks_dir.join(name), &link) |
| 174 | .with_context(|| format!("link {}", link.display()))?; |
| 175 | } |
| 176 | Ok(()) |
| 177 | } |
| 178 | |
| 179 | /// The branch HEAD points at, if it is a symbolic ref. |
| 180 | pub fn head_ref(&self, account: &Name, repo: &Name) -> Option<String> { |
| 181 | let path = self.repo_path(account, repo).ok()?; |
| 182 | let head = Git::at(&path) |
| 183 | .text(&["symbolic-ref", "--quiet", "HEAD"]) |
| 184 | .ok()?; |
| 185 | (!head.is_empty()).then_some(head) |
| 186 | } |
| 187 | |
| 188 | /// Move a repository aside. Recoverable until the sweep runs. |
| 189 | pub fn soft_delete(&self, account: &Name, repo: &Name) -> Result<bool> { |
| 190 | let path = self.repo_path(account, repo)?; |
| 191 | if !path.exists() { |
| 192 | return Ok(false); |
| 193 | } |
| 194 | let stamp = std::time::SystemTime::now() |
| 195 | .duration_since(std::time::UNIX_EPOCH) |
| 196 | .map(|d| d.as_millis()) |
| 197 | .unwrap_or(0); |
| 198 | let grave = self |
| 199 | .deleted_dir |
| 200 | .join(format!("{account}__{repo}__{stamp}.git")); |
| 201 | std::fs::rename(&path, &grave) |
| 202 | .with_context(|| format!("move {} to {}", path.display(), grave.display()))?; |
| 203 | Ok(true) |
| 204 | } |
| 205 | |
| 206 | /// List refs under `refs/heads/`, `refs/tags/` and `refs/notes/`. |
| 207 | pub fn list_refs(&self, account: &Name, repo: &Name) -> Result<Vec<(String, String)>> { |
| 208 | let path = self.repo_path(account, repo)?; |
| 209 | let out = Git::at(&path).run(&[ |
| 210 | "for-each-ref", |
| 211 | "--format=%(refname) %(objectname)", |
| 212 | "refs/heads/", |
| 213 | "refs/tags/", |
| 214 | "refs/notes/", |
| 215 | ])?; |
| 216 | |
| 217 | Ok(String::from_utf8_lossy(&out) |
| 218 | .lines() |
| 219 | .filter_map(|line| { |
| 220 | let (name, sha) = line.split_once(' ')?; |
| 221 | Some((name.to_string(), sha.to_string())) |
| 222 | }) |
| 223 | .collect()) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | /// The all-zero object id git uses for "this ref does not exist". |
| 228 | /// |
| 229 | /// Length varies with the repository's hash algorithm, so this matches on content |
| 230 | /// rather than against a hardcoded 40-character SHA-1 sentinel. |
| 231 | pub fn is_null_oid(oid: &str) -> bool { |
| 232 | !oid.is_empty() && oid.bytes().all(|b| b == b'0') |
| 233 | } |
| 234 | |
| 235 | /// Git namespace for immutable direct-package releases. |
| 236 | /// |
| 237 | /// A repository is the package, so a normal Git tag is the smallest release |
| 238 | /// record we can have. Keeping package tags below their own prefix leaves ordinary |
| 239 | /// project tags alone while giving `0.0.1` URLs one permanent Git anchor. |
| 240 | const PACKAGE_RELEASE_PREFIX: &str = "refs/tags/pkg/"; |
| 241 | |
| 242 | /// The exact release version encoded by a package tag. |
| 243 | /// |
| 244 | /// Package v1 intentionally admits stable `MAJOR.MINOR.PATCH` only. It is enough |
| 245 | /// to make `0.0.1` honest, avoids silently normalising multiple spellings, and |
| 246 | /// leaves prerelease/channel policy for a later explicit design. |
| 247 | pub fn package_release_version(ref_name: &str) -> Option<&str> { |
| 248 | let version = ref_name.strip_prefix(PACKAGE_RELEASE_PREFIX)?; |
| 249 | let mut pieces = version.split('.'); |
| 250 | let (Some(major), Some(minor), Some(patch), None) = |
| 251 | (pieces.next(), pieces.next(), pieces.next(), pieces.next()) |
| 252 | else { |
| 253 | return None; |
| 254 | }; |
| 255 | [major, minor, patch] |
| 256 | .into_iter() |
| 257 | .all(valid_version_part) |
| 258 | .then_some(version) |
| 259 | } |
| 260 | |
| 261 | pub fn package_release_ref(version: &str) -> Option<String> { |
| 262 | let ref_name = format!("{PACKAGE_RELEASE_PREFIX}{version}"); |
| 263 | package_release_version(&ref_name) |
| 264 | .is_some() |
| 265 | .then_some(ref_name) |
| 266 | } |
| 267 | |
| 268 | fn valid_version_part(part: &str) -> bool { |
| 269 | !part.is_empty() |
| 270 | && part.bytes().all(|byte| byte.is_ascii_digit()) |
| 271 | && (part == "0" || !part.starts_with('0')) |
| 272 | } |
| 273 | |
| 274 | /// Git config key holding the refs that may not be rewritten. |
| 275 | fn protected_key() -> String { |
| 276 | brand::git_config_key("protected") |
| 277 | } |
| 278 | |
| 279 | /// Refs protected from rewriting, read from the repository's own git config. |
| 280 | /// |
| 281 | /// Kept in git config rather than in our metadata so the hook can read it with no |
| 282 | /// data directory, no environment and no lookup — and so the setting travels with |
| 283 | /// the repository if it is ever copied or restored from a backup. |
| 284 | pub fn protected_refs(git_dir: &Path) -> Vec<String> { |
| 285 | let Ok(Some(out)) = Git::at(git_dir).query(&["config", "--get-all", &protected_key()]) else { |
| 286 | return Vec::new(); |
| 287 | }; |
| 288 | String::from_utf8_lossy(&out) |
| 289 | .lines() |
| 290 | .map(str::trim) |
| 291 | .filter(|line| !line.is_empty()) |
| 292 | .map(String::from) |
| 293 | .collect() |
| 294 | } |
| 295 | |
| 296 | /// Replace the protected-ref list. |
| 297 | pub fn set_protected_refs(git_dir: &Path, refs: &[String]) -> Result<()> { |
| 298 | for name in refs { |
| 299 | validate::ref_name(name)?; |
| 300 | } |
| 301 | |
| 302 | let key = protected_key(); |
| 303 | let git = Git::at(git_dir); |
| 304 | // `--unset-all` exits 5 when the key is absent, which is not a failure here. |
| 305 | git.ignore(&["config", "--unset-all", &key]); |
| 306 | |
| 307 | for name in refs { |
| 308 | git.run(&["config", "--add", &key, name])?; |
| 309 | } |
| 310 | Ok(()) |
| 311 | } |
| 312 | |
| 313 | /// Point HEAD at a branch. |
| 314 | /// |
| 315 | /// A symref rather than a detached id, so a clone of the repository checks the |
| 316 | /// branch out by name. |
| 317 | pub fn set_head(git_dir: &Path, branch: &str) -> Result<()> { |
| 318 | validate::ref_name(branch)?; |
| 319 | Git::at(git_dir) |
| 320 | .run(&["symbolic-ref", "HEAD", branch]) |
| 321 | .map(|_| ()) |
| 322 | } |
| 323 | |
| 324 | /// Whether a ref may move from `old` to `new`. |
| 325 | /// |
| 326 | /// **Rewriting is allowed by default.** There is one agent and no reviewer here, so |
| 327 | /// a blanket non-fast-forward block protects nobody while breaking `rebase`, |
| 328 | /// `commit --amend` and `filter-branch` — ordinary git that this service exists to |
| 329 | /// serve. Recovery is git's own reflog, enabled on every repository at creation. |
| 330 | /// |
| 331 | /// Protection is opt-in per ref, for the branch something deploys from. Its one |
| 332 | /// caller is the `update` hook, reached through `<binary> hook update`. |
| 333 | pub fn check_ref_move(git_dir: &Path, ref_name: &str, old: &str, new: &str) -> Result<()> { |
| 334 | // Release tags are created by ordinary `git push` and become their own |
| 335 | // immutable source anchor. Unlike protected branches, deletion also has to be |
| 336 | // refused: a version URL is cached for a year and must never point elsewhere |
| 337 | // or become an unreachable object after someone has imported it. |
| 338 | if package_release_version(ref_name).is_some() && !is_null_oid(old) { |
| 339 | return Err(Error::conflict( |
| 340 | "immutable-release", |
| 341 | format!("{ref_name} is an immutable package release and cannot move or be deleted"), |
| 342 | )); |
| 343 | } |
| 344 | // Creating or deleting a ref is not a rewrite. |
| 345 | if is_null_oid(old) || is_null_oid(new) { |
| 346 | return Ok(()); |
| 347 | } |
| 348 | if !protected_refs(git_dir).iter().any(|p| p == ref_name) { |
| 349 | return Ok(()); |
| 350 | } |
| 351 | |
| 352 | if Git::at(git_dir).succeeds(&["merge-base", "--is-ancestor", old, new]) { |
| 353 | return Ok(()); |
| 354 | } |
| 355 | |
| 356 | Err(Error::conflict( |
| 357 | "non-fast-forward", |
| 358 | format!("{ref_name} is protected; non-fast-forward update rejected"), |
| 359 | )) |
| 360 | } |
| 361 | |
| 362 | /// Single-quote a path for embedding in a `sh` script. |
| 363 | fn shell_quote(value: &str) -> String { |
| 364 | format!("'{}'", value.replace('\'', r"'\''")) |
| 365 | } |
| 366 | |
| 367 | #[cfg(unix)] |
| 368 | fn set_executable(path: &Path) -> Result<()> { |
| 369 | use std::os::unix::fs::PermissionsExt; |
| 370 | let mut perms = std::fs::metadata(path) |
| 371 | .with_context(|| format!("stat {}", path.display()))? |
| 372 | .permissions(); |
| 373 | perms.set_mode(0o755); |
| 374 | std::fs::set_permissions(path, perms).with_context(|| format!("chmod {}", path.display()))?; |
| 375 | Ok(()) |
| 376 | } |
| 377 | |
| 378 | #[cfg(not(unix))] |
| 379 | fn set_executable(_path: &Path) -> Result<()> { |
| 380 | Ok(()) |
| 381 | } |
| 382 | |
| 383 | #[cfg(unix)] |
| 384 | fn symlink(target: &Path, link: &Path) -> anyhow::Result<()> { |
| 385 | std::os::unix::fs::symlink(target, link)?; |
| 386 | Ok(()) |
| 387 | } |
| 388 | |
| 389 | #[cfg(not(unix))] |
| 390 | fn symlink(target: &Path, link: &Path) -> anyhow::Result<()> { |
| 391 | std::fs::copy(target, link)?; |
| 392 | Ok(()) |
| 393 | } |
| 394 | |
| 395 | #[cfg(all(test, unix))] |
| 396 | mod tests { |
| 397 | use super::*; |
| 398 | use crate::git::validate::name; |
| 399 | // Tests build commits directly, which needs stdin; the Git helper is |
| 400 | // deliberately stdin-less because no production call site needs it. |
| 401 | use std::process::Command; |
| 402 | |
| 403 | fn store() -> (tempfile::TempDir, GitStore) { |
| 404 | let dir = tempfile::tempdir().unwrap(); |
| 405 | let store = GitStore::open(dir.path(), 512 * 1024 * 1024).unwrap(); |
| 406 | (dir, store) |
| 407 | } |
| 408 | |
| 409 | fn make(store: &GitStore, account: &str, repo: &str) -> PathBuf { |
| 410 | store |
| 411 | .create( |
| 412 | &name(account).unwrap(), |
| 413 | &name(repo).unwrap(), |
| 414 | "refs/heads/main", |
| 415 | ) |
| 416 | .unwrap() |
| 417 | } |
| 418 | |
| 419 | #[test] |
| 420 | fn creates_a_bare_repo_with_head_pointed_at_the_default_branch() { |
| 421 | let (_dir, store) = store(); |
| 422 | let path = make(&store, "alice", "site"); |
| 423 | |
| 424 | assert!(path.join("HEAD").is_file()); |
| 425 | assert!(!path.join(".git").exists(), "repo must be bare"); |
| 426 | assert_eq!( |
| 427 | std::fs::read_to_string(path.join("HEAD")).unwrap().trim(), |
| 428 | "ref: refs/heads/main" |
| 429 | ); |
| 430 | } |
| 431 | |
| 432 | #[test] |
| 433 | fn every_repo_carries_the_pack_safety_config() { |
| 434 | let (_dir, store) = store(); |
| 435 | let path = make(&store, "alice", "site"); |
| 436 | |
| 437 | for (key, expected) in [ |
| 438 | ("receive.fsckObjects", "true"), |
| 439 | ("receive.maxInputSize", "536870912"), |
| 440 | ] { |
| 441 | assert_eq!( |
| 442 | Git::at(&path).text(&["config", "--get", key]).unwrap(), |
| 443 | expected, |
| 444 | "{key} must be set at create" |
| 445 | ); |
| 446 | } |
| 447 | } |
| 448 | |
| 449 | #[test] |
| 450 | fn hooks_are_executable_symlinks_that_delegate_to_the_binary() { |
| 451 | let (dir, store) = store(); |
| 452 | let path = make(&store, "alice", "site"); |
| 453 | |
| 454 | for name in HOOKS { |
| 455 | let hook = path.join("hooks").join(name); |
| 456 | assert!( |
| 457 | std::fs::symlink_metadata(&hook) |
| 458 | .unwrap() |
| 459 | .file_type() |
| 460 | .is_symlink(), |
| 461 | "{name} must be a symlink" |
| 462 | ); |
| 463 | assert_eq!( |
| 464 | std::fs::read_link(&hook).unwrap(), |
| 465 | dir.path().join("hooks").join(name) |
| 466 | ); |
| 467 | |
| 468 | let body = std::fs::read_to_string(&hook).unwrap(); |
| 469 | assert!( |
| 470 | body.contains(&format!("hook {name}")), |
| 471 | "{name} must delegate to the binary, not carry policy in shell" |
| 472 | ); |
| 473 | } |
| 474 | } |
| 475 | |
| 476 | #[test] |
| 477 | fn a_hook_upgrade_reaches_repos_that_already_exist() { |
| 478 | let dir = tempfile::tempdir().unwrap(); |
| 479 | let store = GitStore::open(dir.path(), 512 * 1024 * 1024).unwrap(); |
| 480 | let path = make(&store, "alice", "site"); |
| 481 | |
| 482 | std::fs::write( |
| 483 | dir.path().join("hooks").join("update"), |
| 484 | "#!/bin/sh\nexit 7\n", |
| 485 | ) |
| 486 | .unwrap(); |
| 487 | let body = std::fs::read_to_string(path.join("hooks").join("update")).unwrap(); |
| 488 | assert!( |
| 489 | body.contains("exit 7"), |
| 490 | "the symlink must carry the upgrade" |
| 491 | ); |
| 492 | } |
| 493 | |
| 494 | #[test] |
| 495 | fn a_case_variant_resolves_to_the_same_repository() { |
| 496 | let (_dir, store) = store(); |
| 497 | let created = make(&store, "alice", "site"); |
| 498 | let variant = store |
| 499 | .repo_path(&name("Alice").unwrap(), &name("SITE").unwrap()) |
| 500 | .unwrap(); |
| 501 | assert_eq!(created, variant); |
| 502 | } |
| 503 | |
| 504 | #[test] |
| 505 | fn rejects_a_default_branch_that_is_not_a_valid_ref() { |
| 506 | let (_dir, store) = store(); |
| 507 | assert!(store |
| 508 | .create( |
| 509 | &name("alice").unwrap(), |
| 510 | &name("site").unwrap(), |
| 511 | "refs/heads/../config" |
| 512 | ) |
| 513 | .is_err()); |
| 514 | } |
| 515 | |
| 516 | #[test] |
| 517 | fn soft_delete_moves_the_repo_aside_rather_than_removing_it() { |
| 518 | let (dir, store) = store(); |
| 519 | let path = make(&store, "alice", "site"); |
| 520 | let (alice, site) = (name("alice").unwrap(), name("site").unwrap()); |
| 521 | |
| 522 | assert!(store.soft_delete(&alice, &site).unwrap()); |
| 523 | assert!(!path.exists()); |
| 524 | assert_eq!( |
| 525 | std::fs::read_dir(dir.path().join("tmp").join("deleted")) |
| 526 | .unwrap() |
| 527 | .count(), |
| 528 | 1, |
| 529 | "repo must be recoverable after delete" |
| 530 | ); |
| 531 | assert!(!store.soft_delete(&alice, &site).unwrap()); |
| 532 | } |
| 533 | |
| 534 | #[test] |
| 535 | fn an_empty_repo_lists_no_refs() { |
| 536 | let (_dir, store) = store(); |
| 537 | make(&store, "alice", "site"); |
| 538 | assert!(store |
| 539 | .list_refs(&name("alice").unwrap(), &name("site").unwrap()) |
| 540 | .unwrap() |
| 541 | .is_empty()); |
| 542 | } |
| 543 | |
| 544 | #[test] |
| 545 | fn the_null_oid_is_recognised_at_any_hash_length() { |
| 546 | assert!(is_null_oid(&"0".repeat(40)), "sha-1 sentinel"); |
| 547 | assert!(is_null_oid(&"0".repeat(64)), "sha-256 sentinel"); |
| 548 | assert!(!is_null_oid("")); |
| 549 | assert!(!is_null_oid("0123")); |
| 550 | } |
| 551 | |
| 552 | #[test] |
| 553 | fn ref_creation_and_deletion_are_always_allowed() { |
| 554 | let (_dir, store) = store(); |
| 555 | let path = make(&store, "alice", "site"); |
| 556 | let zero = "0".repeat(40); |
| 557 | |
| 558 | check_ref_move(&path, "refs/heads/main", &zero, "abc").unwrap(); |
| 559 | check_ref_move(&path, "refs/heads/main", "abc", &zero).unwrap(); |
| 560 | } |
| 561 | |
| 562 | #[test] |
| 563 | fn rewriting_an_unprotected_ref_is_allowed() { |
| 564 | let (_dir, store) = store(); |
| 565 | let path = make(&store, "alice", "site"); |
| 566 | |
| 567 | // Two unrelated commits: neither is an ancestor of the other. |
| 568 | let first = commit(&path, "one"); |
| 569 | let second = commit(&path, "two"); |
| 570 | |
| 571 | check_ref_move(&path, "refs/heads/main", &second, &first).unwrap_or_else(|e| { |
| 572 | panic!("rebase and amend are ordinary git and must not be blocked: {e}") |
| 573 | }); |
| 574 | } |
| 575 | |
| 576 | #[test] |
| 577 | fn a_package_release_tag_is_created_once_then_immutable() { |
| 578 | let (_dir, store) = store(); |
| 579 | let path = make(&store, "alice", "package"); |
| 580 | let zero = "0".repeat(40); |
| 581 | let first = "a".repeat(40); |
| 582 | let second = "b".repeat(40); |
| 583 | let release = "refs/tags/pkg/0.0.1"; |
| 584 | |
| 585 | // The first push creates the release. Every later move, including a |
| 586 | // deletion, is refused so a version URL remains one source tree forever. |
| 587 | check_ref_move(&path, release, &zero, &first).unwrap(); |
| 588 | assert!(check_ref_move(&path, release, &first, &second).is_err()); |
| 589 | assert!(check_ref_move(&path, release, &first, &zero).is_err()); |
| 590 | |
| 591 | // Project tags outside the package namespace retain ordinary Git rules. |
| 592 | check_ref_move(&path, "refs/tags/v0.0.1", &first, &second).unwrap(); |
| 593 | } |
| 594 | |
| 595 | #[test] |
| 596 | fn package_versions_have_one_canonical_stable_spelling() { |
| 597 | assert_eq!( |
| 598 | package_release_version("refs/tags/pkg/0.0.1"), |
| 599 | Some("0.0.1") |
| 600 | ); |
| 601 | assert_eq!( |
| 602 | package_release_ref("1.2.3"), |
| 603 | Some("refs/tags/pkg/1.2.3".into()) |
| 604 | ); |
| 605 | for bad in [ |
| 606 | "refs/tags/pkg/v0.0.1", |
| 607 | "refs/tags/pkg/01.0.1", |
| 608 | "refs/tags/pkg/0.1", |
| 609 | "refs/tags/pkg/0.0.1-beta.1", |
| 610 | "refs/tags/v0.0.1", |
| 611 | ] { |
| 612 | assert_eq!(package_release_version(bad), None, "{bad}"); |
| 613 | } |
| 614 | } |
| 615 | |
| 616 | #[test] |
| 617 | fn rewriting_a_protected_ref_is_refused() { |
| 618 | let (_dir, store) = store(); |
| 619 | let path = make(&store, "alice", "site"); |
| 620 | set_protected_refs(&path, &["refs/heads/main".to_string()]).unwrap(); |
| 621 | |
| 622 | let first = commit(&path, "one"); |
| 623 | let second = commit(&path, "two"); |
| 624 | |
| 625 | let err = check_ref_move(&path, "refs/heads/main", &second, &first).unwrap_err(); |
| 626 | assert_eq!(err.status(), 409); |
| 627 | |
| 628 | // A different branch is untouched by main's protection. |
| 629 | check_ref_move(&path, "refs/heads/topic", &second, &first).unwrap(); |
| 630 | } |
| 631 | |
| 632 | #[test] |
| 633 | fn a_fast_forward_is_allowed_even_when_protected() { |
| 634 | let (_dir, store) = store(); |
| 635 | let path = make(&store, "alice", "site"); |
| 636 | set_protected_refs(&path, &["refs/heads/main".to_string()]).unwrap(); |
| 637 | |
| 638 | let first = commit(&path, "one"); |
| 639 | let second = commit_onto(&path, &first, "two"); |
| 640 | check_ref_move(&path, "refs/heads/main", &first, &second).unwrap(); |
| 641 | } |
| 642 | |
| 643 | #[test] |
| 644 | fn protected_refs_round_trip_through_the_repository_config() { |
| 645 | let (_dir, store) = store(); |
| 646 | let path = make(&store, "alice", "site"); |
| 647 | |
| 648 | assert!( |
| 649 | protected_refs(&path).is_empty(), |
| 650 | "nothing is protected by default" |
| 651 | ); |
| 652 | |
| 653 | let wanted = vec!["refs/heads/main".to_string(), "refs/tags/v1".to_string()]; |
| 654 | set_protected_refs(&path, &wanted).unwrap(); |
| 655 | assert_eq!(protected_refs(&path), wanted); |
| 656 | |
| 657 | set_protected_refs(&path, &[]).unwrap(); |
| 658 | assert!(protected_refs(&path).is_empty()); |
| 659 | } |
| 660 | |
| 661 | #[test] |
| 662 | fn a_protected_ref_name_is_validated_before_it_reaches_git_config() { |
| 663 | let (_dir, store) = store(); |
| 664 | let path = make(&store, "alice", "site"); |
| 665 | assert!(set_protected_refs(&path, &["../../config".to_string()]).is_err()); |
| 666 | } |
| 667 | |
| 668 | /// Create a root commit directly in the bare repo, returning its oid. |
| 669 | fn commit(git_dir: &Path, message: &str) -> String { |
| 670 | let tree = git(git_dir, &["hash-object", "-t", "tree", "-w", "--stdin"]); |
| 671 | let out = Command::new("git") |
| 672 | .arg("--git-dir") |
| 673 | .arg(git_dir) |
| 674 | .args(["commit-tree", &tree, "-m", message]) |
| 675 | .env("GIT_AUTHOR_NAME", "t") |
| 676 | .env("GIT_AUTHOR_EMAIL", "t@e") |
| 677 | .env("GIT_COMMITTER_NAME", "t") |
| 678 | .env("GIT_COMMITTER_EMAIL", "t@e") |
| 679 | .env("GIT_AUTHOR_DATE", format!("@{} +0000", message.len())) |
| 680 | .output() |
| 681 | .unwrap(); |
| 682 | String::from_utf8_lossy(&out.stdout).trim().to_string() |
| 683 | } |
| 684 | |
| 685 | fn commit_onto(git_dir: &Path, parent: &str, message: &str) -> String { |
| 686 | let tree = git(git_dir, &["hash-object", "-t", "tree", "-w", "--stdin"]); |
| 687 | let out = Command::new("git") |
| 688 | .arg("--git-dir") |
| 689 | .arg(git_dir) |
| 690 | .args(["commit-tree", &tree, "-p", parent, "-m", message]) |
| 691 | .env("GIT_AUTHOR_NAME", "t") |
| 692 | .env("GIT_AUTHOR_EMAIL", "t@e") |
| 693 | .env("GIT_COMMITTER_NAME", "t") |
| 694 | .env("GIT_COMMITTER_EMAIL", "t@e") |
| 695 | .output() |
| 696 | .unwrap(); |
| 697 | String::from_utf8_lossy(&out.stdout).trim().to_string() |
| 698 | } |
| 699 | |
| 700 | fn git(git_dir: &Path, args: &[&str]) -> String { |
| 701 | use std::io::Write; |
| 702 | use std::process::Stdio; |
| 703 | let mut child = Command::new("git") |
| 704 | .arg("--git-dir") |
| 705 | .arg(git_dir) |
| 706 | .args(args) |
| 707 | .stdin(Stdio::piped()) |
| 708 | .stdout(Stdio::piped()) |
| 709 | .spawn() |
| 710 | .unwrap(); |
| 711 | child.stdin.take().unwrap().write_all(b"").unwrap(); |
| 712 | let out = child.wait_with_output().unwrap(); |
| 713 | String::from_utf8_lossy(&out.stdout).trim().to_string() |
| 714 | } |
| 715 | } |