Git hosting and a container registry in one Rust binary (axum + Astro)
Merge branch 'worktree-agent-ad4dacdd8848fc6a4'
9 files changed, +1880 -11
+374-5backend/src/backup.rs
| @@ -1,10 +1,379 @@ | ||
| 1 | -//! Nightly backups of repositories and Postgres to R2. Stub. | |
| 1 | +//! Backups of every repository and of Postgres to R2. | |
| 2 | +//! | |
| 3 | +//! Each run writes under `backups/<stamp>/`: | |
| 4 | +//! repos/<id>-<owner>-<name>.bundle `git bundle create --all`, restorable | |
| 5 | +//! with `git clone <bundle>` | |
| 6 | +//! postgres.sql.gz plain `pg_dump`, when pg_dump exists | |
| 7 | +//! manifest.json what the run contains, with ref shas | |
| 8 | +//! | |
| 9 | +//! Runs every BACKUP_INTERVAL_HOURS (first one ten minutes after start), or | |
| 10 | +//! on demand from the admin panel. The newest seven runs are kept. | |
| 2 | 11 | |
| 3 | -use crate::state::AppState; | |
| 12 | +use std::{ | |
| 13 | + collections::BTreeSet, | |
| 14 | + path::{Path, PathBuf}, | |
| 15 | + process::Stdio, | |
| 16 | + time::Duration, | |
| 17 | +}; | |
| 4 | 18 | |
| 5 | -pub fn spawn(_state: AppState) {} | |
| 19 | +use anyhow::Context; | |
| 20 | +use chrono::Utc; | |
| 21 | +use serde_json::json; | |
| 22 | +use tokio::sync::Mutex; | |
| 23 | + | |
| 24 | +use crate::{git::Git, models, state::AppState, storage::keys, web::ui}; | |
| 25 | + | |
| 26 | +const KEEP_RUNS: usize = 7; | |
| 27 | +const FIRST_RUN_DELAY: Duration = Duration::from_secs(10 * 60); | |
| 28 | + | |
| 29 | +/// One backup at a time, whether scheduled or requested. | |
| 30 | +static RUNNING: Mutex<()> = Mutex::const_new(()); | |
| 31 | + | |
| 32 | +pub fn spawn(state: AppState) { | |
| 33 | + tokio::spawn(async move { | |
| 34 | + // A crash mid-run leaves a row stuck in 'running'. | |
| 35 | + if let Err(error) = sqlx::query( | |
| 36 | + "update backup_runs set status = 'failed', finished_at = now(), error = 'interrupted (server restarted)' where status = 'running'", | |
| 37 | + ) | |
| 38 | + .execute(&state.db) | |
| 39 | + .await | |
| 40 | + { | |
| 41 | + tracing::warn!(%error, "could not close interrupted backup runs"); | |
| 42 | + } | |
| 43 | + let interval = Duration::from_secs(state.config.backup_interval_hours.max(1) * 3600); | |
| 44 | + tokio::time::sleep(FIRST_RUN_DELAY).await; | |
| 45 | + loop { | |
| 46 | + match run_now(&state).await { | |
| 47 | + Ok(summary) => tracing::info!(%summary, "scheduled backup finished"), | |
| 48 | + Err(error) => tracing::error!(error = ?error, "scheduled backup failed"), | |
| 49 | + } | |
| 50 | + tokio::time::sleep(interval).await; | |
| 51 | + } | |
| 52 | + }); | |
| 53 | +} | |
| 6 | 54 | |
| 7 | 55 | /// Runs one backup immediately (admin "Back up now"). Returns a summary. |
| 8 | -pub async fn run_now(_state: &AppState) -> anyhow::Result<String> { | |
| 9 | - anyhow::bail!("backups are not implemented yet") | |
| 56 | +pub async fn run_now(state: &AppState) -> anyhow::Result<String> { | |
| 57 | + let Ok(_guard) = RUNNING.try_lock() else { | |
| 58 | + anyhow::bail!("a backup is already running"); | |
| 59 | + }; | |
| 60 | + let run_id: i64 = sqlx::query_scalar("insert into backup_runs default values returning id").fetch_one(&state.db).await?; | |
| 61 | + let stamp = Utc::now().format("%Y%m%dT%H%M%SZ").to_string(); | |
| 62 | + let work_dir = state.config.data_dir.join("backup-tmp").join(&stamp); | |
| 63 | + tracing::info!(run_id, %stamp, "backup started"); | |
| 64 | + | |
| 65 | + let result = run(state, &stamp, &work_dir).await; | |
| 66 | + let _ = tokio::fs::remove_dir_all(&work_dir).await; | |
| 67 | + | |
| 68 | + match result { | |
| 69 | + Ok(report) => { | |
| 70 | + let status = if report.failures.is_empty() { "ok" } else { "failed" }; | |
| 71 | + let error = (!report.failures.is_empty()).then(|| report.failures.join("; ")); | |
| 72 | + sqlx::query("update backup_runs set finished_at = now(), status = $2, repo_count = $3, bytes = $4, error = $5 where id = $1") | |
| 73 | + .bind(run_id) | |
| 74 | + .bind(status) | |
| 75 | + .bind(report.repos as i32) | |
| 76 | + .bind(report.bytes as i64) | |
| 77 | + .bind(&error) | |
| 78 | + .execute(&state.db) | |
| 79 | + .await?; | |
| 80 | + let summary = report.summary(&stamp); | |
| 81 | + if report.failures.is_empty() { | |
| 82 | + tracing::info!(run_id, %summary, "backup finished"); | |
| 83 | + } else { | |
| 84 | + tracing::warn!(run_id, %summary, "backup finished with failures"); | |
| 85 | + } | |
| 86 | + Ok(summary) | |
| 87 | + } | |
| 88 | + Err(error) => { | |
| 89 | + sqlx::query("update backup_runs set finished_at = now(), status = 'failed', error = $2 where id = $1") | |
| 90 | + .bind(run_id) | |
| 91 | + .bind(format!("{error:#}")) | |
| 92 | + .execute(&state.db) | |
| 93 | + .await?; | |
| 94 | + Err(error) | |
| 95 | + } | |
| 96 | + } | |
| 97 | +} | |
| 98 | + | |
| 99 | +#[derive(Default)] | |
| 100 | +struct Report { | |
| 101 | + repos: usize, | |
| 102 | + skipped_empty: usize, | |
| 103 | + bytes: u64, | |
| 104 | + postgres: Option<u64>, | |
| 105 | + notes: Vec<String>, | |
| 106 | + failures: Vec<String>, | |
| 107 | + pruned: usize, | |
| 108 | +} | |
| 109 | + | |
| 110 | +impl Report { | |
| 111 | + fn summary(&self, stamp: &str) -> String { | |
| 112 | + let mut parts = vec![format!( | |
| 113 | + "backup {stamp}: {} repositories ({} empty skipped), {} uploaded", | |
| 114 | + self.repos, | |
| 115 | + self.skipped_empty, | |
| 116 | + ui::bytes(self.bytes) | |
| 117 | + )]; | |
| 118 | + match self.postgres { | |
| 119 | + Some(size) => parts.push(format!("postgres dump {}", ui::bytes(size))), | |
| 120 | + None => parts.push("no postgres dump".into()), | |
| 121 | + } | |
| 122 | + if self.pruned > 0 { | |
| 123 | + parts.push(format!("pruned {} old backups", self.pruned)); | |
| 124 | + } | |
| 125 | + parts.extend(self.notes.iter().cloned()); | |
| 126 | + if !self.failures.is_empty() { | |
| 127 | + parts.push(format!("{} failures: {}", self.failures.len(), self.failures.join("; "))); | |
| 128 | + } | |
| 129 | + parts.join(". ") | |
| 130 | + } | |
| 131 | +} | |
| 132 | + | |
| 133 | +#[derive(sqlx::FromRow)] | |
| 134 | +struct RepoRow { | |
| 135 | + id: i64, | |
| 136 | + owner_name: String, | |
| 137 | + name: String, | |
| 138 | + is_empty: bool, | |
| 139 | +} | |
| 140 | + | |
| 141 | +async fn run(state: &AppState, stamp: &str, work_dir: &Path) -> anyhow::Result<Report> { | |
| 142 | + tokio::fs::create_dir_all(work_dir).await?; | |
| 143 | + let mut report = Report::default(); | |
| 144 | + let repos: Vec<RepoRow> = sqlx::query_as( | |
| 145 | + "select r.id, a.name::text as owner_name, r.name::text as name, r.is_empty from repos r join accounts a on a.id = r.owner_id order by r.id", | |
| 146 | + ) | |
| 147 | + .fetch_all(&state.db) | |
| 148 | + .await?; | |
| 149 | + | |
| 150 | + let mut manifest_repos = Vec::new(); | |
| 151 | + for repo in &repos { | |
| 152 | + if repo.is_empty { | |
| 153 | + report.skipped_empty += 1; | |
| 154 | + continue; | |
| 155 | + } | |
| 156 | + let path = models::repo_disk_path(&state.config, repo.id); | |
| 157 | + let name = format!("repos/{}-{}-{}.bundle", repo.id, repo.owner_name, repo.name); | |
| 158 | + match backup_repo(state, &path, &work_dir.join(format!("{}.bundle", repo.id)), &keys::backup(stamp, &name)).await { | |
| 159 | + Ok(Some((size, refs))) => { | |
| 160 | + report.repos += 1; | |
| 161 | + report.bytes += size; | |
| 162 | + manifest_repos.push(json!({ | |
| 163 | + "id": repo.id, "owner": repo.owner_name, "name": repo.name, | |
| 164 | + "key": keys::backup(stamp, &name), "size": size, "refs": refs, | |
| 165 | + })); | |
| 166 | + } | |
| 167 | + Ok(None) => report.skipped_empty += 1, | |
| 168 | + Err(error) => { | |
| 169 | + tracing::error!(repo = %format!("{}/{}", repo.owner_name, repo.name), error = ?error, "repository backup failed"); | |
| 170 | + report.failures.push(format!("{}/{}: {error:#}", repo.owner_name, repo.name)); | |
| 171 | + } | |
| 172 | + } | |
| 173 | + } | |
| 174 | + | |
| 175 | + let dump_path = work_dir.join("postgres.sql.gz"); | |
| 176 | + match dump_postgres(&state.config.database_url, &work_dir.join("postgres.sql"), &dump_path).await { | |
| 177 | + Ok(Some(_)) => { | |
| 178 | + let key = keys::backup(stamp, "postgres.sql.gz"); | |
| 179 | + let size = state.storage.put_file(&key, &dump_path).await.context("uploading postgres dump")?; | |
| 180 | + report.bytes += size; | |
| 181 | + report.postgres = Some(size); | |
| 182 | + } | |
| 183 | + Ok(None) => report.notes.push("pg_dump is not installed on the server, so Postgres was not dumped".into()), | |
| 184 | + Err(error) => { | |
| 185 | + tracing::error!(error = ?error, "postgres dump failed"); | |
| 186 | + report.notes.push(format!("Postgres dump failed: {error:#}")); | |
| 187 | + } | |
| 188 | + } | |
| 189 | + | |
| 190 | + let manifest = json!({ | |
| 191 | + "stamp": stamp, | |
| 192 | + "created_at": Utc::now().to_rfc3339(), | |
| 193 | + "bucket": state.storage.bucket_name(), | |
| 194 | + "repos": manifest_repos, | |
| 195 | + "postgres": report.postgres.map(|size| json!({ "key": keys::backup(stamp, "postgres.sql.gz"), "size": size })), | |
| 196 | + "failures": report.failures, | |
| 197 | + "restore": "git clone <bundle> <dir>; gunzip -c postgres.sql.gz | psql <database>", | |
| 198 | + }); | |
| 199 | + state | |
| 200 | + .storage | |
| 201 | + .put_bytes(&keys::backup(stamp, "manifest.json"), serde_json::to_vec_pretty(&manifest)?.into(), "application/json") | |
| 202 | + .await | |
| 203 | + .context("uploading manifest")?; | |
| 204 | + | |
| 205 | + match prune(state, stamp).await { | |
| 206 | + Ok(pruned) => report.pruned = pruned, | |
| 207 | + Err(error) => report.notes.push(format!("pruning old backups failed: {error:#}")), | |
| 208 | + } | |
| 209 | + Ok(report) | |
| 210 | +} | |
| 211 | + | |
| 212 | +/// Bundles one repository and uploads it. None when it has no refs. | |
| 213 | +async fn backup_repo(state: &AppState, repo_path: &Path, bundle: &Path, key: &str) -> anyhow::Result<Option<(u64, serde_json::Value)>> { | |
| 214 | + let git = Git::new(repo_path); | |
| 215 | + let refs = git.ref_snapshot().await?; | |
| 216 | + if refs.is_empty() { | |
| 217 | + return Ok(None); | |
| 218 | + } | |
| 219 | + let output = git | |
| 220 | + .command() | |
| 221 | + .args(["bundle", "create", "--quiet"]) | |
| 222 | + .arg(bundle) | |
| 223 | + .arg("--all") | |
| 224 | + .stdout(Stdio::null()) | |
| 225 | + .stderr(Stdio::piped()) | |
| 226 | + .output() | |
| 227 | + .await?; | |
| 228 | + if !output.status.success() { | |
| 229 | + anyhow::bail!("git bundle failed: {}", String::from_utf8_lossy(&output.stderr).trim()); | |
| 230 | + } | |
| 231 | + let size = state.storage.put_file(key, bundle).await.context("uploading bundle")?; | |
| 232 | + let _ = tokio::fs::remove_file(bundle).await; | |
| 233 | + Ok(Some((size, json!(refs)))) | |
| 234 | +} | |
| 235 | + | |
| 236 | +/// `pg_dump` to a plain SQL file, then gzip it. None when pg_dump is absent. | |
| 237 | +async fn dump_postgres(database_url: &str, plain: &Path, gzipped: &Path) -> anyhow::Result<Option<PathBuf>> { | |
| 238 | + let probe = tokio::process::Command::new("pg_dump").arg("--version").stdout(Stdio::null()).stderr(Stdio::null()).status().await; | |
| 239 | + if probe.is_err() { | |
| 240 | + return Ok(None); | |
| 241 | + } | |
| 242 | + let output = tokio::process::Command::new("pg_dump") | |
| 243 | + .args(["--no-owner", "--no-privileges", "--format=plain", "--file"]) | |
| 244 | + .arg(plain) | |
| 245 | + .arg("--dbname") | |
| 246 | + .arg(database_url) | |
| 247 | + .stdin(Stdio::null()) | |
| 248 | + .stdout(Stdio::null()) | |
| 249 | + .stderr(Stdio::piped()) | |
| 250 | + .kill_on_drop(true) | |
| 251 | + .output() | |
| 252 | + .await | |
| 253 | + .context("running pg_dump")?; | |
| 254 | + if !output.status.success() { | |
| 255 | + // stderr may echo the connection string; keep only the first line and | |
| 256 | + // strip anything that looks like credentials. | |
| 257 | + let message = String::from_utf8_lossy(&output.stderr).lines().next().unwrap_or("").replace(database_url, "<DATABASE_URL>"); | |
| 258 | + anyhow::bail!("pg_dump exited with {}: {message}", output.status); | |
| 259 | + } | |
| 260 | + let (plain, gzipped, out) = (plain.to_path_buf(), gzipped.to_path_buf(), gzipped.to_path_buf()); | |
| 261 | + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { | |
| 262 | + let mut input = std::fs::File::open(&plain)?; | |
| 263 | + let mut encoder = flate2::write::GzEncoder::new(std::fs::File::create(&gzipped)?, flate2::Compression::default()); | |
| 264 | + std::io::copy(&mut input, &mut encoder)?; | |
| 265 | + encoder.finish()?; | |
| 266 | + std::fs::remove_file(&plain)?; | |
| 267 | + Ok(()) | |
| 268 | + }) | |
| 269 | + .await??; | |
| 270 | + Ok(Some(out)) | |
| 271 | +} | |
| 272 | + | |
| 273 | +/// Deletes every backup stamp but the newest `KEEP_RUNS`. | |
| 274 | +async fn prune(state: &AppState, current: &str) -> anyhow::Result<usize> { | |
| 275 | + let objects = state.storage.list("backups/").await?; | |
| 276 | + let keys: Vec<&str> = objects.iter().map(|o| o.key.as_str()).collect(); | |
| 277 | + let (doomed, stamps) = keys_to_prune(&keys, current, KEEP_RUNS); | |
| 278 | + for key in &doomed { | |
| 279 | + state.storage.delete(key).await?; | |
| 280 | + } | |
| 281 | + if !stamps.is_empty() { | |
| 282 | + tracing::info!(pruned = ?stamps, objects = doomed.len(), "old backups deleted"); | |
| 283 | + } | |
| 284 | + Ok(stamps.len()) | |
| 285 | +} | |
| 286 | + | |
| 287 | +/// Object keys (and their stamps) outside the newest `keep` runs. Stamps | |
| 288 | +/// sort chronologically as strings; `current` is never pruned. | |
| 289 | +fn keys_to_prune<'a>(keys: &[&'a str], current: &str, keep: usize) -> (Vec<&'a str>, BTreeSet<String>) { | |
| 290 | + let stamp_of = |key: &'a str| key.strip_prefix("backups/").and_then(|k| k.split('/').next()).filter(|s| !s.is_empty()); | |
| 291 | + let stamps: BTreeSet<&str> = keys.iter().filter_map(|k| stamp_of(k)).collect(); | |
| 292 | + let kept: BTreeSet<&str> = stamps.iter().rev().take(keep).copied().chain(std::iter::once(current)).collect(); | |
| 293 | + let mut doomed = Vec::new(); | |
| 294 | + let mut doomed_stamps = BTreeSet::new(); | |
| 295 | + for key in keys { | |
| 296 | + if let Some(stamp) = stamp_of(key).filter(|s| !kept.contains(s)) { | |
| 297 | + doomed.push(*key); | |
| 298 | + doomed_stamps.insert(stamp.to_string()); | |
| 299 | + } | |
| 300 | + } | |
| 301 | + (doomed, doomed_stamps) | |
| 302 | +} | |
| 303 | + | |
| 304 | +#[cfg(test)] | |
| 305 | +mod tests { | |
| 306 | + use std::sync::Arc; | |
| 307 | + | |
| 308 | + use super::*; | |
| 309 | + | |
| 310 | + #[test] | |
| 311 | + fn prunes_all_but_newest_runs() { | |
| 312 | + let keys = [ | |
| 313 | + "backups/20260101T000000Z/manifest.json", | |
| 314 | + "backups/20260101T000000Z/repos/1-a-b.bundle", | |
| 315 | + "backups/20260102T000000Z/manifest.json", | |
| 316 | + "backups/20260103T000000Z/manifest.json", | |
| 317 | + "backups/20260104T000000Z/postgres.sql.gz", | |
| 318 | + "backups/", | |
| 319 | + ]; | |
| 320 | + let (doomed, stamps) = keys_to_prune(&keys, "20260104T000000Z", 2); | |
| 321 | + assert_eq!(doomed, vec!["backups/20260101T000000Z/manifest.json", "backups/20260101T000000Z/repos/1-a-b.bundle", "backups/20260102T000000Z/manifest.json"]); | |
| 322 | + assert_eq!(stamps.into_iter().collect::<Vec<_>>(), vec!["20260101T000000Z", "20260102T000000Z"]); | |
| 323 | + // The current run survives even if it would sort below the cutoff. | |
| 324 | + let (doomed, _) = keys_to_prune(&keys, "20260101T000000Z", 1); | |
| 325 | + assert!(!doomed.iter().any(|k| k.contains("20260101"))); | |
| 326 | + assert!(doomed.iter().any(|k| k.contains("20260102"))); | |
| 327 | + let (doomed, _) = keys_to_prune(&keys, "x", 10); | |
| 328 | + assert!(doomed.is_empty()); | |
| 329 | + } | |
| 330 | + | |
| 331 | + /// Runs one real backup against the configured database and bucket and | |
| 332 | + /// prints the summary and stamp. Needs .env: | |
| 333 | + /// `cargo test -p irongit backup::tests::backup_now -- --ignored --nocapture` | |
| 334 | + #[tokio::test] | |
| 335 | + #[ignore] | |
| 336 | + async fn backup_now() { | |
| 337 | + // Relative paths in .env (DATA_DIR) are relative to the workspace root. | |
| 338 | + std::env::set_current_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/..")).unwrap(); | |
| 339 | + let _ = dotenvy::dotenv(); | |
| 340 | + let config = crate::config::Config::from_env().unwrap(); | |
| 341 | + let db = sqlx::PgPool::connect(&config.database_url).await.unwrap(); | |
| 342 | + let (reload_tx, _) = tokio::sync::broadcast::channel(1); | |
| 343 | + let state = AppState { | |
| 344 | + storage: crate::storage::Storage::new(&config.r2).unwrap(), | |
| 345 | + http: reqwest::Client::new(), | |
| 346 | + config: Arc::new(config), | |
| 347 | + db, | |
| 348 | + reload_tx, | |
| 349 | + }; | |
| 350 | + let summary = run_now(&state).await.unwrap(); | |
| 351 | + println!("SUMMARY: {summary}"); | |
| 352 | + let (status, repos, bytes): (String, i32, i64) = | |
| 353 | + sqlx::query_as("select status, repo_count, bytes from backup_runs order by id desc limit 1").fetch_one(&state.db).await.unwrap(); | |
| 354 | + println!("RUN: status={status} repos={repos} bytes={bytes}"); | |
| 355 | + assert_eq!(status, "ok"); | |
| 356 | + | |
| 357 | + // Pull the run back out of R2 so the caller can restore from it. | |
| 358 | + let stamp = summary.strip_prefix("backup ").and_then(|s| s.split(':').next()).unwrap().to_string(); | |
| 359 | + let manifest = state.storage.get_bytes(&keys::backup(&stamp, "manifest.json")).await.unwrap().expect("manifest uploaded"); | |
| 360 | + let manifest: serde_json::Value = serde_json::from_slice(&manifest).unwrap(); | |
| 361 | + assert!(state.storage.head(&keys::backup(&stamp, "postgres.sql.gz")).await.unwrap().is_some_and(|s| s > 0)); | |
| 362 | + if let Ok(dir) = std::env::var("E2E_RESTORE_DIR") { | |
| 363 | + std::fs::create_dir_all(&dir).unwrap(); | |
| 364 | + let dump = state.storage.get_bytes(&keys::backup(&stamp, "postgres.sql.gz")).await.unwrap().unwrap(); | |
| 365 | + std::fs::write(Path::new(&dir).join("postgres.sql.gz"), dump).unwrap(); | |
| 366 | + for repo in manifest["repos"].as_array().unwrap() { | |
| 367 | + let key = repo["key"].as_str().unwrap(); | |
| 368 | + let bytes = state.storage.get_bytes(key).await.unwrap().expect("bundle uploaded"); | |
| 369 | + let file = Path::new(&dir).join(format!("{}-{}.bundle", repo["owner"].as_str().unwrap(), repo["name"].as_str().unwrap())); | |
| 370 | + std::fs::write(&file, bytes).unwrap(); | |
| 371 | + println!("BUNDLE {} {}", file.display(), repo["refs"]); | |
| 372 | + } | |
| 373 | + } | |
| 374 | + let kept = state.storage.list("backups/").await.unwrap(); | |
| 375 | + let stamps: BTreeSet<&str> = kept.iter().filter_map(|o| o.key.strip_prefix("backups/")?.split('/').next()).collect(); | |
| 376 | + println!("STAMPS IN R2: {stamps:?}"); | |
| 377 | + assert!(stamps.len() <= KEEP_RUNS); | |
| 378 | + } | |
| 10 | 379 | } |
+465-3backend/src/lfs.rs
| @@ -1,9 +1,471 @@ | ||
| 1 | -//! Git LFS batch API backed by R2. Stub. | |
| 1 | +//! Git LFS batch API backed by R2. | |
| 2 | +//! | |
| 3 | +//! POST /{owner}/{repo}/info/lfs/objects/batch | |
| 4 | +//! POST /{owner}/{repo}/info/lfs/verify | |
| 5 | +//! POST /{owner}/{repo}/info/lfs/locks/verify | |
| 6 | +//! GET /{owner}/{repo}/info/lfs/locks | |
| 7 | +//! | |
| 8 | +//! Object bytes never pass through this server: the batch response hands | |
| 9 | +//! out presigned R2 URLs. Objects are stored once under `lfs/<oid>` and | |
| 10 | +//! linked per repo; downloads require a link to the repo being read. | |
| 11 | +//! | |
| 12 | +//! Uploads are presigned with `x-amz-checksum-sha256` set to the oid, so R2 | |
| 13 | +//! rejects any body whose SHA-256 is not the oid. That makes a shared, | |
| 14 | +//! content-addressed key impossible to overwrite with other bytes, and it | |
| 15 | +//! is why an object stored by another repo is uploaded again rather than | |
| 16 | +//! linked: re-uploading is the proof that the pusher actually has it. | |
| 2 | 17 | |
| 3 | -use axum::Router; | |
| 18 | +use std::time::Duration; | |
| 4 | 19 | |
| 5 | -use crate::state::AppState; | |
| 20 | +use axum::{ | |
| 21 | + Router, | |
| 22 | + body::Bytes, | |
| 23 | + extract::{Path, State}, | |
| 24 | + http::{HeaderMap, HeaderValue, StatusCode, header}, | |
| 25 | + response::{IntoResponse, Response}, | |
| 26 | + routing::{get, post}, | |
| 27 | +}; | |
| 28 | +use base64::Engine; | |
| 29 | +use serde::{Deserialize, Serialize}; | |
| 30 | +use serde_json::{Value, json}; | |
| 31 | + | |
| 32 | +use crate::{ | |
| 33 | + analytics, | |
| 34 | + auth::{self, HeaderAuth, Viewer}, | |
| 35 | + models::Repo, | |
| 36 | + perm::{self, Access, Area}, | |
| 37 | + signed, | |
| 38 | + state::AppState, | |
| 39 | + storage::keys, | |
| 40 | +}; | |
| 41 | + | |
| 42 | +const CONTENT_TYPE: &str = "application/vnd.git-lfs+json"; | |
| 43 | +const TOKEN_PURPOSE: &str = "lfs"; | |
| 44 | +/// Lifetime of tokens minted over SSH and of presigned URLs. | |
| 45 | +pub const TOKEN_TTL_SECS: i64 = 3600; | |
| 46 | +const URL_TTL: Duration = Duration::from_secs(TOKEN_TTL_SECS as u64); | |
| 47 | +const MAX_OBJECTS_PER_BATCH: usize = 1000; | |
| 6 | 48 | |
| 7 | 49 | pub fn router() -> Router<AppState> { |
| 8 | 50 | Router::new() |
| 51 | + .route("/{owner}/{repo}/info/lfs/objects/batch", post(batch)) | |
| 52 | + .route("/{owner}/{repo}/info/lfs/verify", post(verify)) | |
| 53 | + .route("/{owner}/{repo}/info/lfs/locks/verify", post(locks_verify)) | |
| 54 | + .route("/{owner}/{repo}/info/lfs/locks", get(locks_list).post(locks_create)) | |
| 55 | +} | |
| 56 | + | |
| 57 | +/// Bearer token handed out by `git-lfs-authenticate` over SSH, and attached | |
| 58 | +/// to verify actions so git-lfs never has to ask for credentials again. | |
| 59 | +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] | |
| 60 | +pub struct LfsToken { | |
| 61 | + pub uid: i64, | |
| 62 | + pub repo: i64, | |
| 63 | + /// "upload" or "download". | |
| 64 | + pub op: String, | |
| 65 | + /// Set only on verify tokens: the value the batch's presigned uploads | |
| 66 | + /// stamp into the object's metadata. See `verify`. | |
| 67 | + #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 68 | + pub nonce: Option<String>, | |
| 69 | +} | |
| 70 | + | |
| 71 | +pub fn mint_token(state: &AppState, user_id: i64, repo_id: i64, op: &str) -> String { | |
| 72 | + let claims = LfsToken { uid: user_id, repo: repo_id, op: op.to_string(), nonce: None }; | |
| 73 | + signed::sign(&state.config.secret_key, TOKEN_PURPOSE, &claims, TOKEN_TTL_SECS) | |
| 74 | +} | |
| 75 | + | |
| 76 | +fn mint_verify_token(state: &AppState, user_id: i64, repo_id: i64, nonce: &str) -> String { | |
| 77 | + let claims = LfsToken { uid: user_id, repo: repo_id, op: "upload".into(), nonce: Some(nonce.to_string()) }; | |
| 78 | + signed::sign(&state.config.secret_key, TOKEN_PURPOSE, &claims, TOKEN_TTL_SECS) | |
| 79 | +} | |
| 80 | + | |
| 81 | +/// Object metadata key (x-amz-meta-irongit-upload) proving which batch | |
| 82 | +/// uploaded the bytes. | |
| 83 | +const UPLOAD_META: &str = "irongit-upload"; | |
| 84 | + | |
| 85 | +/// LFS oids are lowercase hex SHA-256. | |
| 86 | +pub fn valid_oid(oid: &str) -> bool { | |
| 87 | + oid.len() == 64 && oid.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) | |
| 88 | +} | |
| 89 | + | |
| 90 | +/// The oid as the base64 SHA-256 digest R2 checks uploads against. | |
| 91 | +fn oid_checksum(oid: &str) -> Option<String> { | |
| 92 | + let raw = hex::decode(oid).ok()?; | |
| 93 | + Some(base64::engine::general_purpose::STANDARD.encode(raw)) | |
| 94 | +} | |
| 95 | + | |
| 96 | +fn lfs_json(status: StatusCode, body: Value) -> Response { | |
| 97 | + (status, [(header::CONTENT_TYPE, CONTENT_TYPE)], body.to_string()).into_response() | |
| 98 | +} | |
| 99 | + | |
| 100 | +fn lfs_error(status: StatusCode, message: &str) -> Response { | |
| 101 | + let mut response = lfs_json(status, json!({ "message": message })); | |
| 102 | + if status == StatusCode::UNAUTHORIZED { | |
| 103 | + response.headers_mut().insert("LFS-Authenticate", HeaderValue::from_static("Basic realm=\"irongit\"")); | |
| 104 | + response.headers_mut().insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Basic realm=\"irongit\"")); | |
| 105 | + } | |
| 106 | + response | |
| 107 | +} | |
| 108 | + | |
| 109 | +fn internal(error: impl std::fmt::Debug) -> Response { | |
| 110 | + tracing::error!(?error, "lfs error"); | |
| 111 | + lfs_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error") | |
| 112 | +} | |
| 113 | + | |
| 114 | +/// Who is calling and, for SSH-minted tokens, which operation they may do. | |
| 115 | +struct Caller { | |
| 116 | + viewer: Option<Viewer>, | |
| 117 | + /// Some("download") for a download-only token. | |
| 118 | + only: Option<String>, | |
| 119 | + /// Upload nonce carried by a verify token. | |
| 120 | + nonce: Option<String>, | |
| 121 | +} | |
| 122 | + | |
| 123 | +/// An authorized LFS request against a repo. | |
| 124 | +struct Authorized { | |
| 125 | + repo: Repo, | |
| 126 | + viewer: Option<Viewer>, | |
| 127 | + nonce: Option<String>, | |
| 128 | +} | |
| 129 | + | |
| 130 | +async fn caller(state: &AppState, headers: &HeaderMap, repo: Option<&Repo>) -> Result<Caller, Response> { | |
| 131 | + let bearer = headers | |
| 132 | + .get(header::AUTHORIZATION) | |
| 133 | + .and_then(|v| v.to_str().ok()) | |
| 134 | + .and_then(|v| v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))) | |
| 135 | + .map(str::trim) | |
| 136 | + .filter(|t| !t.starts_with(auth::TOKEN_PREFIX)); | |
| 137 | + if let Some(token) = bearer { | |
| 138 | + let Some(claims) = signed::verify::<LfsToken>(&state.config.secret_key, TOKEN_PURPOSE, token) else { | |
| 139 | + return Err(lfs_error(StatusCode::UNAUTHORIZED, "LFS token is invalid or expired")); | |
| 140 | + }; | |
| 141 | + if repo.is_some_and(|r| r.id != claims.repo) { | |
| 142 | + return Err(lfs_error(StatusCode::FORBIDDEN, "LFS token was issued for a different repository")); | |
| 143 | + } | |
| 144 | + let viewer = crate::ssh::viewer_by_id(&state.db, claims.uid).await.map_err(internal)?; | |
| 145 | + let Some(viewer) = viewer else { | |
| 146 | + return Err(lfs_error(StatusCode::UNAUTHORIZED, "LFS token owner no longer exists")); | |
| 147 | + }; | |
| 148 | + return Ok(Caller { viewer: Some(viewer), only: Some(claims.op), nonce: claims.nonce }); | |
| 149 | + } | |
| 150 | + match auth::authenticate_header(&state.db, headers).await.map_err(internal)? { | |
| 151 | + HeaderAuth::Viewer(viewer) => Ok(Caller { viewer: Some(viewer), only: None, nonce: None }), | |
| 152 | + HeaderAuth::Anonymous => Ok(Caller { viewer: None, only: None, nonce: None }), | |
| 153 | + HeaderAuth::Invalid => Err(lfs_error(StatusCode::UNAUTHORIZED, "Invalid credentials. Use a personal access token as the password.")), | |
| 154 | + } | |
| 155 | +} | |
| 156 | + | |
| 157 | +/// Resolves the repo and checks `needed` access, answering 401 whenever | |
| 158 | +/// credentials might help so private repos look like missing ones. | |
| 159 | +async fn authorize(state: &AppState, headers: &HeaderMap, owner: &str, name: &str, operation: &str) -> Result<Authorized, Response> { | |
| 160 | + let repo = Repo::by_path(&state.db, owner, name).await.map_err(internal)?; | |
| 161 | + let caller = caller(state, headers, repo.as_ref()).await?; | |
| 162 | + if let Some(only) = &caller.only { | |
| 163 | + if only != operation { | |
| 164 | + return Err(lfs_error(StatusCode::FORBIDDEN, &format!("this LFS token only allows {only}"))); | |
| 165 | + } | |
| 166 | + } | |
| 167 | + let access = match &repo { | |
| 168 | + Some(repo) => perm::repo_access(&state.db, caller.viewer.as_ref(), repo, Area::Repo).await.map_err(internal)?, | |
| 169 | + None => Access::None, | |
| 170 | + }; | |
| 171 | + let needed = if operation == "upload" { Access::Write } else { Access::Read }; | |
| 172 | + if access >= needed { | |
| 173 | + return Ok(Authorized { repo: repo.expect("access implies repo"), viewer: caller.viewer, nonce: caller.nonce }); | |
| 174 | + } | |
| 175 | + if caller.viewer.is_none() { | |
| 176 | + return Err(lfs_error(StatusCode::UNAUTHORIZED, "Authentication required")); | |
| 177 | + } | |
| 178 | + match (&repo, access.can_read()) { | |
| 179 | + (Some(repo), true) if repo.archived => Err(lfs_error(StatusCode::FORBIDDEN, "This repository is archived and read-only")), | |
| 180 | + (Some(_), true) => Err(lfs_error(StatusCode::FORBIDDEN, "You have read access only; uploading needs write access")), | |
| 181 | + _ => Err(lfs_error(StatusCode::NOT_FOUND, "Repository not found")), | |
| 182 | + } | |
| 183 | +} | |
| 184 | + | |
| 185 | +#[derive(Deserialize)] | |
| 186 | +struct BatchRequest { | |
| 187 | + operation: String, | |
| 188 | + #[serde(default)] | |
| 189 | + transfers: Vec<String>, | |
| 190 | + #[serde(default)] | |
| 191 | + objects: Vec<ObjectSpec>, | |
| 192 | + hash_algo: Option<String>, | |
| 193 | +} | |
| 194 | + | |
| 195 | +#[derive(Deserialize, Clone)] | |
| 196 | +struct ObjectSpec { | |
| 197 | + oid: String, | |
| 198 | + size: i64, | |
| 199 | +} | |
| 200 | + | |
| 201 | +async fn batch(State(state): State<AppState>, Path((owner, name)): Path<(String, String)>, headers: HeaderMap, body: Bytes) -> Response { | |
| 202 | + let Ok(request) = serde_json::from_slice::<BatchRequest>(&body) else { | |
| 203 | + return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "Malformed batch request"); | |
| 204 | + }; | |
| 205 | + if !matches!(request.operation.as_str(), "upload" | "download") { | |
| 206 | + return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "operation must be upload or download"); | |
| 207 | + } | |
| 208 | + if !request.transfers.is_empty() && !request.transfers.iter().any(|t| t == "basic") { | |
| 209 | + return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "Only the basic transfer adapter is supported"); | |
| 210 | + } | |
| 211 | + if request.hash_algo.as_deref().is_some_and(|h| h != "sha256") { | |
| 212 | + return lfs_error(StatusCode::CONFLICT, "Only sha256 is supported"); | |
| 213 | + } | |
| 214 | + if request.objects.len() > MAX_OBJECTS_PER_BATCH { | |
| 215 | + return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "Too many objects in one batch"); | |
| 216 | + } | |
| 217 | + let Authorized { repo, viewer, .. } = match authorize(&state, &headers, &owner, &name, &request.operation).await { | |
| 218 | + Ok(found) => found, | |
| 219 | + Err(response) => return response, | |
| 220 | + }; | |
| 221 | + | |
| 222 | + let oids: Vec<String> = request.objects.iter().filter(|o| valid_oid(&o.oid)).map(|o| o.oid.clone()).collect(); | |
| 223 | + let linked: std::collections::HashMap<String, i64> = match sqlx::query_as::<_, (String, i64)>( | |
| 224 | + "select o.oid, o.size from repo_lfs_objects r join lfs_objects o on o.oid = r.oid where r.repo_id = $1 and r.oid = any($2)", | |
| 225 | + ) | |
| 226 | + .bind(repo.id) | |
| 227 | + .bind(&oids) | |
| 228 | + .fetch_all(&state.db) | |
| 229 | + .await | |
| 230 | + { | |
| 231 | + Ok(rows) => rows.into_iter().collect(), | |
| 232 | + Err(error) => return internal(error), | |
| 233 | + }; | |
| 234 | + | |
| 235 | + let upload = request.operation == "upload"; | |
| 236 | + let max = state.config.limits.max_lfs_object_bytes as i64; | |
| 237 | + let base = format!("{}/{}/{}.git/info/lfs", state.config.base_url(), repo.owner_name, repo.name); | |
| 238 | + // Every upload in this batch stamps `nonce` into the object's metadata, | |
| 239 | + // and the verify token carries it: verify only links an object whose | |
| 240 | + // stored bytes were written by this batch, so calling verify for an oid | |
| 241 | + // someone else uploaded proves nothing. The token also spares git-lfs a | |
| 242 | + // second credential prompt. | |
| 243 | + let nonce = auth::random_token(12); | |
| 244 | + let verify_token = viewer.as_ref().map(|v| mint_verify_token(&state, v.id, repo.id, &nonce)); | |
| 245 | + let mut actions_issued = 0usize; | |
| 246 | + let objects: Vec<Value> = request | |
| 247 | + .objects | |
| 248 | + .iter() | |
| 249 | + .map(|object| { | |
| 250 | + if !valid_oid(&object.oid) || object.size < 0 { | |
| 251 | + return json!({ "oid": object.oid, "size": object.size, "error": { "code": 422, "message": "Invalid object id or size" } }); | |
| 252 | + } | |
| 253 | + let key = keys::lfs(&object.oid); | |
| 254 | + if upload { | |
| 255 | + if object.size > max { | |
| 256 | + return json!({ "oid": object.oid, "size": object.size, "error": { "code": 422, "message": format!("Object is larger than the {} LFS limit", crate::web::ui::bytes(max as u64)) } }); | |
| 257 | + } | |
| 258 | + if linked.contains_key(&object.oid) { | |
| 259 | + // Already stored for this repo: no actions means "skip". | |
| 260 | + return json!({ "oid": object.oid, "size": object.size }); | |
| 261 | + } | |
| 262 | + let checksum = oid_checksum(&object.oid).expect("valid oid is hex"); | |
| 263 | + actions_issued += 1; | |
| 264 | + let mut verify = json!({ "href": format!("{base}/verify"), "expires_in": TOKEN_TTL_SECS }); | |
| 265 | + if let Some(token) = &verify_token { | |
| 266 | + verify["header"] = json!({ "Authorization": format!("Bearer {token}") }); | |
| 267 | + } | |
| 268 | + json!({ | |
| 269 | + "oid": object.oid, | |
| 270 | + "size": object.size, | |
| 271 | + "authenticated": true, | |
| 272 | + "actions": { | |
| 273 | + "upload": { | |
| 274 | + "href": state.storage.presign_put_sha256(&key, URL_TTL, &checksum, &[(UPLOAD_META, &nonce)]).to_string(), | |
| 275 | + "header": { | |
| 276 | + "x-amz-checksum-sha256": checksum, | |
| 277 | + format!("x-amz-meta-{UPLOAD_META}"): nonce, | |
| 278 | + }, | |
| 279 | + "expires_in": TOKEN_TTL_SECS, | |
| 280 | + }, | |
| 281 | + "verify": verify, | |
| 282 | + } | |
| 283 | + }) | |
| 284 | + } else { | |
| 285 | + match linked.get(&object.oid) { | |
| 286 | + Some(size) => { | |
| 287 | + actions_issued += 1; | |
| 288 | + json!({ | |
| 289 | + "oid": object.oid, | |
| 290 | + "size": size, | |
| 291 | + "authenticated": true, | |
| 292 | + "actions": { | |
| 293 | + "download": { | |
| 294 | + "href": state.storage.presign_get(&key, URL_TTL, None).to_string(), | |
| 295 | + "expires_in": TOKEN_TTL_SECS, | |
| 296 | + } | |
| 297 | + } | |
| 298 | + }) | |
| 299 | + } | |
| 300 | + None => json!({ "oid": object.oid, "size": object.size, "error": { "code": 404, "message": "Object does not exist" } }), | |
| 301 | + } | |
| 302 | + } | |
| 303 | + }) | |
| 304 | + .collect(); | |
| 305 | + | |
| 306 | + tracing::info!( | |
| 307 | + repo = %repo.full_name(), | |
| 308 | + user = viewer.as_ref().map(|v| v.name.as_str()).unwrap_or("-"), | |
| 309 | + operation = %request.operation, | |
| 310 | + objects = request.objects.len(), | |
| 311 | + actions = actions_issued, | |
| 312 | + "lfs batch" | |
| 313 | + ); | |
| 314 | + analytics::track( | |
| 315 | + &state, | |
| 316 | + if upload { "lfs_upload_batch" } else { "lfs_download_batch" }, | |
| 317 | + viewer.as_ref().map(|v| v.name.as_str()), | |
| 318 | + &repo.url(), | |
| 319 | + json!({ "objects": request.objects.len(), "transfers": actions_issued }), | |
| 320 | + ); | |
| 321 | + lfs_json(StatusCode::OK, json!({ "transfer": "basic", "objects": objects, "hash_algo": "sha256" })) | |
| 322 | +} | |
| 323 | + | |
| 324 | +/// Called by git-lfs after an upload: confirms the object reached R2 with the | |
| 325 | +/// declared size, then records it and links it to the repo. | |
| 326 | +async fn verify(State(state): State<AppState>, Path((owner, name)): Path<(String, String)>, headers: HeaderMap, body: Bytes) -> Response { | |
| 327 | + let Ok(object) = serde_json::from_slice::<ObjectSpec>(&body) else { | |
| 328 | + return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "Malformed verify request"); | |
| 329 | + }; | |
| 330 | + if !valid_oid(&object.oid) { | |
| 331 | + return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "Invalid object id"); | |
| 332 | + } | |
| 333 | + let Authorized { repo, viewer, nonce } = match authorize(&state, &headers, &owner, &name, "upload").await { | |
| 334 | + Ok(found) => found, | |
| 335 | + Err(response) => return response, | |
| 336 | + }; | |
| 337 | + // Only the token from a batch response carries a nonce; a PAT or an SSH | |
| 338 | + // token alone cannot verify, or anyone could link objects they never sent. | |
| 339 | + let Some(nonce) = nonce else { | |
| 340 | + return lfs_error(StatusCode::FORBIDDEN, "Verify with the token from the batch response"); | |
| 341 | + }; | |
| 342 | + let stored = match state.storage.head_metadata(&keys::lfs(&object.oid)).await { | |
| 343 | + Ok(stored) => stored, | |
| 344 | + Err(error) => return internal(error), | |
| 345 | + }; | |
| 346 | + match stored { | |
| 347 | + None => return lfs_error(StatusCode::NOT_FOUND, "Object was not uploaded"), | |
| 348 | + Some((size, _)) if size as i64 != object.size => { | |
| 349 | + return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, &format!("Object size is {size}, expected {}", object.size)); | |
| 350 | + } | |
| 351 | + Some((_, metadata)) if !metadata.iter().any(|(k, v)| k == UPLOAD_META && *v == nonce) => { | |
| 352 | + tracing::warn!(repo = %repo.full_name(), oid = %object.oid, "lfs verify without a matching upload"); | |
| 353 | + return lfs_error(StatusCode::UNPROCESSABLE_ENTITY, "Object was not uploaded by this batch; upload it again"); | |
| 354 | + } | |
| 355 | + Some(_) => {} | |
| 356 | + } | |
| 357 | + let result = async { | |
| 358 | + let mut tx = state.db.begin().await?; | |
| 359 | + sqlx::query("insert into lfs_objects (oid, size) values ($1, $2) on conflict (oid) do nothing") | |
| 360 | + .bind(&object.oid) | |
| 361 | + .bind(object.size) | |
| 362 | + .execute(&mut *tx) | |
| 363 | + .await?; | |
| 364 | + sqlx::query("insert into repo_lfs_objects (repo_id, oid) values ($1, $2) on conflict do nothing") | |
| 365 | + .bind(repo.id) | |
| 366 | + .bind(&object.oid) | |
| 367 | + .execute(&mut *tx) | |
| 368 | + .await?; | |
| 369 | + tx.commit().await | |
| 370 | + } | |
| 371 | + .await; | |
| 372 | + if let Err(error) = result { | |
| 373 | + return internal(error); | |
| 374 | + } | |
| 375 | + tracing::info!(repo = %repo.full_name(), user = viewer.as_ref().map(|v| v.name.as_str()).unwrap_or("-"), oid = %object.oid, size = object.size, "lfs object verified"); | |
| 376 | + lfs_json(StatusCode::OK, json!({})) | |
| 377 | +} | |
| 378 | + | |
| 379 | +/// File locking is not supported; answering "no locks" keeps pushes quiet. | |
| 380 | +async fn locks_verify(State(state): State<AppState>, Path((owner, name)): Path<(String, String)>, headers: HeaderMap) -> Response { | |
| 381 | + match authorize(&state, &headers, &owner, &name, "upload").await { | |
| 382 | + Ok(_) => lfs_json(StatusCode::OK, json!({ "ours": [], "theirs": [], "next_cursor": "" })), | |
| 383 | + Err(response) => response, | |
| 384 | + } | |
| 385 | +} | |
| 386 | + | |
| 387 | +async fn locks_list(State(state): State<AppState>, Path((owner, name)): Path<(String, String)>, headers: HeaderMap) -> Response { | |
| 388 | + match authorize(&state, &headers, &owner, &name, "download").await { | |
| 389 | + Ok(_) => lfs_json(StatusCode::OK, json!({ "locks": [], "next_cursor": "" })), | |
| 390 | + Err(response) => response, | |
| 391 | + } | |
| 392 | +} | |
| 393 | + | |
| 394 | +async fn locks_create() -> Response { | |
| 395 | + lfs_error(StatusCode::NOT_IMPLEMENTED, "File locking is not supported on irongit") | |
| 396 | +} | |
| 397 | + | |
| 398 | +#[cfg(test)] | |
| 399 | +mod tests { | |
| 400 | + use super::*; | |
| 401 | + | |
| 402 | + #[test] | |
| 403 | + fn oid_validation() { | |
| 404 | + let good = "a".repeat(64); | |
| 405 | + assert!(valid_oid(&good)); | |
| 406 | + assert!(valid_oid("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")); | |
| 407 | + assert!(!valid_oid(&"A".repeat(64))); | |
| 408 | + assert!(!valid_oid(&"a".repeat(63))); | |
| 409 | + assert!(!valid_oid(&"a".repeat(65))); | |
| 410 | + assert!(!valid_oid(&format!("{}g", "a".repeat(63)))); | |
| 411 | + assert!(!valid_oid("../../etc/passwd")); | |
| 412 | + assert!(!valid_oid("")); | |
| 413 | + } | |
| 414 | + | |
| 415 | + #[test] | |
| 416 | + fn checksum_is_base64_digest() { | |
| 417 | + // sha256("") in hex and base64. | |
| 418 | + let oid = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; | |
| 419 | + assert_eq!(oid_checksum(oid).unwrap(), "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="); | |
| 420 | + } | |
| 421 | + | |
| 422 | + #[test] | |
| 423 | + fn token_roundtrip() { | |
| 424 | + let key = [3u8; 32]; | |
| 425 | + let original = LfsToken { uid: 1, repo: 2, op: "upload".into(), nonce: Some("n".into()) }; | |
| 426 | + let token = signed::sign(&key, TOKEN_PURPOSE, &original, 60); | |
| 427 | + let claims: LfsToken = signed::verify(&key, TOKEN_PURPOSE, &token).unwrap(); | |
| 428 | + assert_eq!(claims, original); | |
| 429 | + // Tokens minted over SSH carry no nonce and still parse. | |
| 430 | + let plain = signed::sign(&key, TOKEN_PURPOSE, &serde_json::json!({ "uid": 1, "repo": 2, "op": "download" }), 60); | |
| 431 | + assert_eq!(signed::verify::<LfsToken>(&key, TOKEN_PURPOSE, &plain).unwrap().nonce, None); | |
| 432 | + assert!(signed::verify::<LfsToken>(&key, "registry", &token).is_none()); | |
| 433 | + } | |
| 434 | + | |
| 435 | + /// Confirms R2 enforces x-amz-checksum-sha256 on presigned PUTs, which | |
| 436 | + /// the upload design relies on. Needs .env with R2 credentials: | |
| 437 | + /// `cargo test -p irongit lfs::tests::r2_enforces_checksum -- --ignored` | |
| 438 | + #[tokio::test] | |
| 439 | + #[ignore] | |
| 440 | + async fn r2_enforces_checksum() { | |
| 441 | + // Relative paths in .env (DATA_DIR) are relative to the workspace root. | |
| 442 | + std::env::set_current_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/..")).unwrap(); | |
| 443 | + let _ = dotenvy::dotenv(); | |
| 444 | + let config = crate::config::Config::from_env().unwrap(); | |
| 445 | + let storage = crate::storage::Storage::new(&config.r2).unwrap(); | |
| 446 | + let body = b"irongit lfs checksum test".to_vec(); | |
| 447 | + let oid = auth::sha256_hex(&body); | |
| 448 | + let checksum = oid_checksum(&oid).unwrap(); | |
| 449 | + let key = format!("healthcheck/lfs-{oid}"); | |
| 450 | + let url = storage.presign_put_sha256(&key, Duration::from_secs(60), &checksum, &[(UPLOAD_META, "nonce123")]); | |
| 451 | + let http = reqwest::Client::new(); | |
| 452 | + | |
| 453 | + let meta = format!("x-amz-meta-{UPLOAD_META}"); | |
| 454 | + let wrong = http.put(url.clone()).header("x-amz-checksum-sha256", &checksum).header(&meta, "nonce123").body(b"tampered bytes".to_vec()).send().await.unwrap(); | |
| 455 | + assert!(!wrong.status().is_success(), "R2 accepted a body that does not match the checksum"); | |
| 456 | + assert_eq!(storage.head(&key).await.unwrap(), None); | |
| 457 | + | |
| 458 | + let missing = http.put(url.clone()).header(&meta, "nonce123").body(body.clone()).send().await.unwrap(); | |
| 459 | + assert!(!missing.status().is_success(), "R2 accepted an upload without the signed checksum header"); | |
| 460 | + | |
| 461 | + let other_nonce = http.put(url.clone()).header("x-amz-checksum-sha256", &checksum).header(&meta, "forged").body(body.clone()).send().await.unwrap(); | |
| 462 | + assert!(!other_nonce.status().is_success(), "R2 accepted a different metadata value than was signed"); | |
| 463 | + | |
| 464 | + let right = http.put(url).header("x-amz-checksum-sha256", &checksum).header(&meta, "nonce123").body(body.clone()).send().await.unwrap(); | |
| 465 | + assert!(right.status().is_success(), "matching upload failed: {}", right.status()); | |
| 466 | + let (size, metadata) = storage.head_metadata(&key).await.unwrap().unwrap(); | |
| 467 | + assert_eq!(size, body.len() as u64); | |
| 468 | + assert!(metadata.iter().any(|(k, v)| k == UPLOAD_META && v == "nonce123"), "metadata missing: {metadata:?}"); | |
| 469 | + storage.delete(&key).await.unwrap(); | |
| 470 | + } | |
| 9 | 471 | } |
+576-3backend/src/ssh.rs
| @@ -1,5 +1,578 @@ | ||
| 1 | -//! Built-in SSH server for git. Stub. | |
| 1 | +//! Built-in SSH server for git, so the host's own sshd is never touched. | |
| 2 | +//! | |
| 3 | +//! Public-key auth only: the offered key's SHA256 fingerprint is looked up in | |
| 4 | +//! `ssh_keys`. Any username works (`git@host` by convention). Each session | |
| 5 | +//! channel accepts one of: | |
| 6 | +//! | |
| 7 | +//! git-upload-pack 'owner/repo.git' clone and fetch | |
| 8 | +//! git-receive-pack 'owner/repo.git' push | |
| 9 | +//! git-lfs-authenticate owner/repo.git upload|download | |
| 10 | +//! | |
| 11 | +//! Git commands are spawned through `transport::service_command`, so SSH | |
| 12 | +//! gets the same hooks, size limits and quotas as smart HTTP. | |
| 2 | 13 | |
| 3 | -use crate::state::AppState; | |
| 14 | +use std::{net::SocketAddr, path::Path, process::Stdio, sync::Arc, time::Duration}; | |
| 4 | 15 | |
| 5 | -pub fn spawn(_state: AppState) {} | |
| 16 | +use anyhow::Context; | |
| 17 | +use russh::{ | |
| 18 | + Channel, ChannelMsg, MethodKind, MethodSet, | |
| 19 | + keys::{Algorithm, HashAlg, PrivateKey, PublicKey}, | |
| 20 | + server::{self, Auth, ChannelOpenHandle, Handle, Msg, Server as _, Session}, | |
| 21 | +}; | |
| 22 | +use serde_json::json; | |
| 23 | +use tokio::{io::AsyncWriteExt, net::TcpListener}; | |
| 24 | + | |
| 25 | +use crate::{ | |
| 26 | + analytics, | |
| 27 | + auth::{AuthVia, Scopes, Viewer}, | |
| 28 | + git::Git, | |
| 29 | + lfs, | |
| 30 | + models::Repo, | |
| 31 | + perm::{self, Access, Area}, | |
| 32 | + push, | |
| 33 | + state::AppState, | |
| 34 | + transport::{self, Service, ServiceOptions}, | |
| 35 | +}; | |
| 36 | + | |
| 37 | +/// Starts the SSH server in the background. A bind failure is logged, not | |
| 38 | +/// fatal: the web UI and HTTP git keep working without it. | |
| 39 | +pub fn spawn(state: AppState) { | |
| 40 | + tokio::spawn(async move { | |
| 41 | + if let Err(error) = run(state).await { | |
| 42 | + tracing::error!(error = ?error, "ssh server stopped"); | |
| 43 | + } | |
| 44 | + }); | |
| 45 | +} | |
| 46 | + | |
| 47 | +async fn run(state: AppState) -> anyhow::Result<()> { | |
| 48 | + let key = load_or_create_host_key(&state.config.data_dir.join("ssh_host_ed25519_key"))?; | |
| 49 | + let config = server::Config { | |
| 50 | + server_id: russh::SshId::Standard("SSH-2.0-irongit".into()), | |
| 51 | + methods: MethodSet::from(&[MethodKind::PublicKey][..]), | |
| 52 | + auth_rejection_time: Duration::from_millis(500), | |
| 53 | + auth_rejection_time_initial: Some(Duration::ZERO), | |
| 54 | + inactivity_timeout: Some(Duration::from_secs(600)), | |
| 55 | + keepalive_interval: Some(Duration::from_secs(30)), | |
| 56 | + keepalive_max: 6, | |
| 57 | + keys: vec![key], | |
| 58 | + nodelay: true, | |
| 59 | + ..Default::default() | |
| 60 | + }; | |
| 61 | + let address = format!("{}:{}", state.config.host, state.config.ssh_port); | |
| 62 | + let listener = TcpListener::bind(&address).await.with_context(|| format!("binding ssh on {address}"))?; | |
| 63 | + tracing::info!(address = %listener.local_addr()?, "ssh listening"); | |
| 64 | + let mut server = SshServer { state }; | |
| 65 | + server.run_on_socket(Arc::new(config), &listener).await?; | |
| 66 | + Ok(()) | |
| 67 | +} | |
| 68 | + | |
| 69 | +/// The ed25519 host key, generated on first start and kept in DATA_DIR so | |
| 70 | +/// clients' known_hosts entries stay valid across restarts. | |
| 71 | +fn load_or_create_host_key(path: &Path) -> anyhow::Result<PrivateKey> { | |
| 72 | + if let Ok(text) = std::fs::read_to_string(path) { | |
| 73 | + return PrivateKey::from_openssh(&text).with_context(|| format!("reading ssh host key {}", path.display())); | |
| 74 | + } | |
| 75 | + let key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).map_err(|e| anyhow::anyhow!("generating host key: {e}"))?; | |
| 76 | + let text = key.to_openssh(russh::keys::ssh_key::LineEnding::LF).map_err(|e| anyhow::anyhow!("encoding host key: {e}"))?; | |
| 77 | + { | |
| 78 | + use std::os::unix::fs::OpenOptionsExt; | |
| 79 | + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)?; | |
| 80 | + std::io::Write::write_all(&mut file, text.as_bytes())?; | |
| 81 | + } | |
| 82 | + tracing::info!(path = %path.display(), fingerprint = %key.public_key().fingerprint(HashAlg::Sha256), "generated ssh host key"); | |
| 83 | + Ok(key) | |
| 84 | +} | |
| 85 | + | |
| 86 | +#[derive(Clone)] | |
| 87 | +struct SshServer { | |
| 88 | + state: AppState, | |
| 89 | +} | |
| 90 | + | |
| 91 | +impl server::Server for SshServer { | |
| 92 | + type Handler = SshSession; | |
| 93 | + | |
| 94 | + fn new_client(&mut self, peer: Option<SocketAddr>) -> SshSession { | |
| 95 | + tracing::debug!(peer = ?peer, "ssh connection"); | |
| 96 | + SshSession { state: self.state.clone(), peer, viewer: None } | |
| 97 | + } | |
| 98 | + | |
| 99 | + fn handle_session_error(&mut self, error: russh::Error) { | |
| 100 | + tracing::debug!(%error, "ssh session ended with an error"); | |
| 101 | + } | |
| 102 | +} | |
| 103 | + | |
| 104 | +struct SshSession { | |
| 105 | + state: AppState, | |
| 106 | + peer: Option<SocketAddr>, | |
| 107 | + viewer: Option<Viewer>, | |
| 108 | +} | |
| 109 | + | |
| 110 | +/// The user owning an SSH key, by SHA256 fingerprint. | |
| 111 | +async fn viewer_for_key(state: &AppState, key: &PublicKey) -> anyhow::Result<Option<(Viewer, i64)>> { | |
| 112 | + let fingerprint = key.fingerprint(HashAlg::Sha256).to_string(); | |
| 113 | + let row: Option<(i64, i64, String, bool, Option<String>)> = sqlx::query_as( | |
| 114 | + "select k.id, a.id, a.name::text, u.is_admin, a.avatar_key | |
| 115 | + from ssh_keys k join users u on u.account_id = k.user_id join accounts a on a.id = u.account_id | |
| 116 | + where k.fingerprint = $1 and u.suspended_at is null", | |
| 117 | + ) | |
| 118 | + .bind(&fingerprint) | |
| 119 | + .fetch_optional(&state.db) | |
| 120 | + .await?; | |
| 121 | + Ok(row.map(|(key_id, id, name, is_admin, avatar_key)| { | |
| 122 | + (Viewer { id, name, is_admin, avatar_key, scopes: Scopes::ALL, via: AuthVia::Session }, key_id) | |
| 123 | + })) | |
| 124 | +} | |
| 125 | + | |
| 126 | +/// A signed-in user loaded by id (LFS tokens minted over SSH carry the id). | |
| 127 | +pub async fn viewer_by_id(db: &sqlx::PgPool, user_id: i64) -> anyhow::Result<Option<Viewer>> { | |
| 128 | + let row: Option<(i64, String, bool, Option<String>)> = sqlx::query_as( | |
| 129 | + "select a.id, a.name::text, u.is_admin, a.avatar_key | |
| 130 | + from users u join accounts a on a.id = u.account_id | |
| 131 | + where u.account_id = $1 and u.suspended_at is null", | |
| 132 | + ) | |
| 133 | + .bind(user_id) | |
| 134 | + .fetch_optional(db) | |
| 135 | + .await?; | |
| 136 | + Ok(row.map(|(id, name, is_admin, avatar_key)| Viewer { id, name, is_admin, avatar_key, scopes: Scopes::ALL, via: AuthVia::Session })) | |
| 137 | +} | |
| 138 | + | |
| 139 | +impl server::Handler for SshSession { | |
| 140 | + type Error = russh::Error; | |
| 141 | + | |
| 142 | + /// Cheap pre-check so unknown keys are refused before any signing work. | |
| 143 | + async fn auth_publickey_offered(&mut self, _user: &str, key: &PublicKey) -> Result<Auth, Self::Error> { | |
| 144 | + match viewer_for_key(&self.state, key).await { | |
| 145 | + Ok(Some(_)) => Ok(Auth::Accept), | |
| 146 | + Ok(None) => { | |
| 147 | + tracing::info!(peer = ?self.peer, fingerprint = %key.fingerprint(HashAlg::Sha256), "ssh key offered but not registered"); | |
| 148 | + Ok(Auth::reject()) | |
| 149 | + } | |
| 150 | + Err(error) => { | |
| 151 | + tracing::error!(error = ?error, "ssh key lookup failed"); | |
| 152 | + Ok(Auth::reject()) | |
| 153 | + } | |
| 154 | + } | |
| 155 | + } | |
| 156 | + | |
| 157 | + async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Result<Auth, Self::Error> { | |
| 158 | + let fingerprint = key.fingerprint(HashAlg::Sha256).to_string(); | |
| 159 | + match viewer_for_key(&self.state, key).await { | |
| 160 | + Ok(Some((viewer, key_id))) => { | |
| 161 | + tracing::info!(peer = ?self.peer, ssh_user = user, user = %viewer.name, %fingerprint, "ssh auth accepted"); | |
| 162 | + let _ = sqlx::query("update ssh_keys set last_used_at = now() where id = $1").bind(key_id).execute(&self.state.db).await; | |
| 163 | + self.viewer = Some(viewer); | |
| 164 | + Ok(Auth::Accept) | |
| 165 | + } | |
| 166 | + Ok(None) => { | |
| 167 | + tracing::info!(peer = ?self.peer, ssh_user = user, %fingerprint, "ssh auth rejected: unknown key"); | |
| 168 | + Ok(Auth::reject()) | |
| 169 | + } | |
| 170 | + Err(error) => { | |
| 171 | + tracing::error!(error = ?error, "ssh key lookup failed"); | |
| 172 | + Ok(Auth::reject()) | |
| 173 | + } | |
| 174 | + } | |
| 175 | + } | |
| 176 | + | |
| 177 | + async fn channel_open_session(&mut self, channel: Channel<Msg>, reply: ChannelOpenHandle, session: &mut Session) -> Result<(), Self::Error> { | |
| 178 | + let Some(viewer) = self.viewer.clone() else { | |
| 179 | + return Ok(()); // dropping the reply rejects the channel | |
| 180 | + }; | |
| 181 | + reply.accept().await; | |
| 182 | + let state = self.state.clone(); | |
| 183 | + let handle = session.handle(); | |
| 184 | + let peer = self.peer; | |
| 185 | + tokio::spawn(async move { | |
| 186 | + serve_channel(state, viewer, channel, handle, peer).await; | |
| 187 | + }); | |
| 188 | + Ok(()) | |
| 189 | + } | |
| 190 | +} | |
| 191 | + | |
| 192 | +// --------------------------------------------------------------------------- | |
| 193 | +// Channels | |
| 194 | + | |
| 195 | +/// Drives one session channel: collects env, then runs the exec request. | |
| 196 | +async fn serve_channel(state: AppState, viewer: Viewer, channel: Channel<Msg>, handle: Handle, peer: Option<SocketAddr>) { | |
| 197 | + let id = channel.id(); | |
| 198 | + let (mut reader, writer) = channel.split(); | |
| 199 | + let mut protocol: Option<String> = None; | |
| 200 | + while let Some(message) = reader.wait().await { | |
| 201 | + match message { | |
| 202 | + ChannelMsg::SetEnv { variable_name, variable_value, want_reply } => { | |
| 203 | + if variable_name == "GIT_PROTOCOL" && variable_value.len() < 200 { | |
| 204 | + protocol = Some(variable_value); | |
| 205 | + } | |
| 206 | + if want_reply { | |
| 207 | + let _ = handle.channel_success(id).await; | |
| 208 | + } | |
| 209 | + } | |
| 210 | + ChannelMsg::RequestPty { want_reply, .. } => { | |
| 211 | + if want_reply { | |
| 212 | + let _ = handle.channel_success(id).await; | |
| 213 | + } | |
| 214 | + } | |
| 215 | + ChannelMsg::RequestShell { want_reply } => { | |
| 216 | + if want_reply { | |
| 217 | + let _ = handle.channel_success(id).await; | |
| 218 | + } | |
| 219 | + tracing::info!(user = %viewer.name, peer = ?peer, "ssh shell requested; refused"); | |
| 220 | + let message = format!( | |
| 221 | + "Hi {}! You've successfully authenticated, but irongit does not provide shell access.\r\n", | |
| 222 | + viewer.name | |
| 223 | + ); | |
| 224 | + finish(&writer, Some(message.as_bytes()), None, 1).await; | |
| 225 | + return; | |
| 226 | + } | |
| 227 | + ChannelMsg::Exec { want_reply, command } => { | |
| 228 | + if want_reply { | |
| 229 | + let _ = handle.channel_success(id).await; | |
| 230 | + } | |
| 231 | + let command = String::from_utf8_lossy(&command).into_owned(); | |
| 232 | + tracing::info!(user = %viewer.name, peer = ?peer, %command, "ssh exec"); | |
| 233 | + let status = match parse_command(&command) { | |
| 234 | + Ok(parsed) => run_command(&state, &viewer, parsed, protocol.as_deref(), &mut reader, &writer).await, | |
| 235 | + Err(message) => { | |
| 236 | + let _ = writer.make_writer_ext(Some(1)).write_all(format!("irongit: {message}\n").as_bytes()).await; | |
| 237 | + 1 | |
| 238 | + } | |
| 239 | + }; | |
| 240 | + finish(&writer, None, None, status).await; | |
| 241 | + return; | |
| 242 | + } | |
| 243 | + ChannelMsg::RequestSubsystem { want_reply, name } => { | |
| 244 | + tracing::info!(user = %viewer.name, %name, "ssh subsystem refused"); | |
| 245 | + if want_reply { | |
| 246 | + let _ = handle.channel_failure(id).await; | |
| 247 | + } | |
| 248 | + } | |
| 249 | + ChannelMsg::Eof | ChannelMsg::Close => break, | |
| 250 | + _ => {} | |
| 251 | + } | |
| 252 | + } | |
| 253 | + let _ = writer.close().await; | |
| 254 | +} | |
| 255 | + | |
| 256 | +/// Writes any final output, the exit status, then EOF and close. | |
| 257 | +async fn finish(writer: &russh::ChannelWriteHalf<Msg>, stdout: Option<&[u8]>, stderr: Option<&[u8]>, status: u32) { | |
| 258 | + if let Some(data) = stdout { | |
| 259 | + let _ = writer.make_writer().write_all(data).await; | |
| 260 | + } | |
| 261 | + if let Some(data) = stderr { | |
| 262 | + let _ = writer.make_writer_ext(Some(1)).write_all(data).await; | |
| 263 | + } | |
| 264 | + let _ = writer.exit_status(status).await; | |
| 265 | + let _ = writer.eof().await; | |
| 266 | + let _ = writer.close().await; | |
| 267 | +} | |
| 268 | + | |
| 269 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 270 | +pub enum LfsOperation { | |
| 271 | + Upload, | |
| 272 | + Download, | |
| 273 | +} | |
| 274 | + | |
| 275 | +impl LfsOperation { | |
| 276 | + pub fn as_str(&self) -> &'static str { | |
| 277 | + match self { | |
| 278 | + Self::Upload => "upload", | |
| 279 | + Self::Download => "download", | |
| 280 | + } | |
| 281 | + } | |
| 282 | +} | |
| 283 | + | |
| 284 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 285 | +pub enum SshCommand { | |
| 286 | + Git { service: Service, owner: String, repo: String }, | |
| 287 | + LfsAuthenticate { owner: String, repo: String, operation: LfsOperation }, | |
| 288 | +} | |
| 289 | + | |
| 290 | +/// Splits a command line the way a minimal shell would: whitespace separates | |
| 291 | +/// words, single and double quotes group them. Backslashes are literal. | |
| 292 | +fn shell_words(line: &str) -> Result<Vec<String>, String> { | |
| 293 | + let mut words = Vec::new(); | |
| 294 | + let mut current = String::new(); | |
| 295 | + let mut in_word = false; | |
| 296 | + let mut quote: Option<char> = None; | |
| 297 | + for c in line.chars() { | |
| 298 | + match quote { | |
| 299 | + Some(q) if c == q => quote = None, | |
| 300 | + Some(_) => current.push(c), | |
| 301 | + None if c == '\'' || c == '"' => { | |
| 302 | + quote = Some(c); | |
| 303 | + in_word = true; | |
| 304 | + } | |
| 305 | + None if c.is_whitespace() => { | |
| 306 | + if in_word { | |
| 307 | + words.push(std::mem::take(&mut current)); | |
| 308 | + in_word = false; | |
| 309 | + } | |
| 310 | + } | |
| 311 | + None => { | |
| 312 | + current.push(c); | |
| 313 | + in_word = true; | |
| 314 | + } | |
| 315 | + } | |
| 316 | + } | |
| 317 | + if quote.is_some() { | |
| 318 | + return Err("unterminated quote in command".into()); | |
| 319 | + } | |
| 320 | + if in_word { | |
| 321 | + words.push(current); | |
| 322 | + } | |
| 323 | + Ok(words) | |
| 324 | +} | |
| 325 | + | |
| 326 | +/// "owner/repo", "/owner/repo.git", "~/owner/repo/" -> ("owner", "repo"). | |
| 327 | +fn parse_repo_path(path: &str) -> Result<(String, String), String> { | |
| 328 | + let trimmed = path.trim_start_matches('~').trim_matches('/'); | |
| 329 | + let trimmed = trimmed.strip_suffix(".git").unwrap_or(trimmed); | |
| 330 | + let mut parts = trimmed.split('/'); | |
| 331 | + match (parts.next(), parts.next(), parts.next()) { | |
| 332 | + (Some(owner), Some(repo), None) | |
| 333 | + if !owner.is_empty() | |
| 334 | + && !repo.is_empty() | |
| 335 | + && owner.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') | |
| 336 | + && repo.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) | |
| 337 | + && !repo.starts_with('.') => | |
| 338 | + { | |
| 339 | + Ok((owner.to_ascii_lowercase(), repo.to_string())) | |
| 340 | + } | |
| 341 | + _ => Err(format!("'{path}' is not a repository path; use owner/repo.git")), | |
| 342 | + } | |
| 343 | +} | |
| 344 | + | |
| 345 | +pub fn parse_command(line: &str) -> Result<SshCommand, String> { | |
| 346 | + let words = shell_words(line.trim())?; | |
| 347 | + let mut words = words.iter().map(String::as_str); | |
| 348 | + let first = words.next().ok_or("no command given; irongit only serves git over SSH")?; | |
| 349 | + // `git upload-pack <path>` is accepted as well as `git-upload-pack <path>`. | |
| 350 | + let program = if first == "git" { | |
| 351 | + match words.next() { | |
| 352 | + Some(sub) => format!("git-{sub}"), | |
| 353 | + None => return Err("no git command given".into()), | |
| 354 | + } | |
| 355 | + } else { | |
| 356 | + first.to_string() | |
| 357 | + }; | |
| 358 | + match program.as_str() { | |
| 359 | + "git-upload-pack" | "git-receive-pack" => { | |
| 360 | + let path = words.next().ok_or("missing repository path")?; | |
| 361 | + if words.next().is_some() { | |
| 362 | + return Err("unexpected arguments".into()); | |
| 363 | + } | |
| 364 | + let (owner, repo) = parse_repo_path(path)?; | |
| 365 | + let service = Service::from_name(&program).expect("matched above"); | |
| 366 | + Ok(SshCommand::Git { service, owner, repo }) | |
| 367 | + } | |
| 368 | + "git-lfs-authenticate" => { | |
| 369 | + let path = words.next().ok_or("missing repository path")?; | |
| 370 | + let operation = match words.next() { | |
| 371 | + Some("upload") => LfsOperation::Upload, | |
| 372 | + Some("download") => LfsOperation::Download, | |
| 373 | + _ => return Err("git-lfs-authenticate needs 'upload' or 'download'".into()), | |
| 374 | + }; | |
| 375 | + let (owner, repo) = parse_repo_path(path)?; | |
| 376 | + Ok(SshCommand::LfsAuthenticate { owner, repo, operation }) | |
| 377 | + } | |
| 378 | + "git-upload-archive" => Err("git archive over SSH is not supported; download archives from the web UI".into()), | |
| 379 | + // git-lfs tries this first and falls back to git-lfs-authenticate. | |
| 380 | + "git-lfs-transfer" => Err("git-lfs-transfer is not supported; git-lfs falls back to git-lfs-authenticate".into()), | |
| 381 | + other => Err(format!("'{other}' is not allowed; irongit only serves git over SSH")), | |
| 382 | + } | |
| 383 | +} | |
| 384 | + | |
| 385 | +/// Runs a parsed command and returns its exit status. | |
| 386 | +async fn run_command( | |
| 387 | + state: &AppState, | |
| 388 | + viewer: &Viewer, | |
| 389 | + command: SshCommand, | |
| 390 | + protocol: Option<&str>, | |
| 391 | + reader: &mut russh::ChannelReadHalf, | |
| 392 | + writer: &russh::ChannelWriteHalf<Msg>, | |
| 393 | +) -> u32 { | |
| 394 | + let mut stderr = writer.make_writer_ext(Some(1)); | |
| 395 | + let (owner, name) = match &command { | |
| 396 | + SshCommand::Git { owner, repo, .. } | SshCommand::LfsAuthenticate { owner, repo, .. } => (owner.clone(), repo.clone()), | |
| 397 | + }; | |
| 398 | + let repo = match Repo::by_path(&state.db, &owner, &name).await { | |
| 399 | + Ok(repo) => repo, | |
| 400 | + Err(error) => { | |
| 401 | + tracing::error!(error = ?error, "ssh repo lookup failed"); | |
| 402 | + let _ = stderr.write_all(b"irongit: internal error\n").await; | |
| 403 | + return 1; | |
| 404 | + } | |
| 405 | + }; | |
| 406 | + let access = match &repo { | |
| 407 | + Some(repo) => perm::repo_access(&state.db, Some(viewer), repo, Area::Repo).await.unwrap_or(Access::None), | |
| 408 | + None => Access::None, | |
| 409 | + }; | |
| 410 | + let Some(repo) = repo.filter(|_| access.can_read()) else { | |
| 411 | + let _ = stderr.write_all(format!("irongit: repository '{owner}/{name}' not found\n").as_bytes()).await; | |
| 412 | + return 1; | |
| 413 | + }; | |
| 414 | + | |
| 415 | + match command { | |
| 416 | + SshCommand::LfsAuthenticate { operation, .. } => { | |
| 417 | + if operation == LfsOperation::Upload && !access.can_write() { | |
| 418 | + let message = if repo.archived { "this repository is archived and read-only" } else { "you have read access only" }; | |
| 419 | + let _ = stderr.write_all(format!("irongit: {message}\n").as_bytes()).await; | |
| 420 | + return 1; | |
| 421 | + } | |
| 422 | + let token = lfs::mint_token(state, viewer.id, repo.id, operation.as_str()); | |
| 423 | + let body = json!({ | |
| 424 | + "href": format!("{}/{}/{}.git/info/lfs", state.config.base_url(), repo.owner_name, repo.name), | |
| 425 | + "header": { "Authorization": format!("Bearer {token}") }, | |
| 426 | + "expires_in": lfs::TOKEN_TTL_SECS, | |
| 427 | + }); | |
| 428 | + tracing::info!(user = %viewer.name, repo = %repo.full_name(), operation = operation.as_str(), "lfs token issued over ssh"); | |
| 429 | + let _ = writer.make_writer().write_all(body.to_string().as_bytes()).await; | |
| 430 | + 0 | |
| 431 | + } | |
| 432 | + SshCommand::Git { service, .. } => { | |
| 433 | + if service == Service::ReceivePack && !access.can_write() { | |
| 434 | + let message = if repo.archived { "this repository is archived and read-only" } else { "you have read access only; pushing needs write access" }; | |
| 435 | + let _ = stderr.write_all(format!("irongit: {message}\n").as_bytes()).await; | |
| 436 | + return 1; | |
| 437 | + } | |
| 438 | + run_git(state, viewer, &repo, service, protocol, reader, writer).await | |
| 439 | + } | |
| 440 | + } | |
| 441 | +} | |
| 442 | + | |
| 443 | +async fn run_git( | |
| 444 | + state: &AppState, | |
| 445 | + viewer: &Viewer, | |
| 446 | + repo: &Repo, | |
| 447 | + service: Service, | |
| 448 | + protocol: Option<&str>, | |
| 449 | + reader: &mut russh::ChannelReadHalf, | |
| 450 | + writer: &russh::ChannelWriteHalf<Msg>, | |
| 451 | +) -> u32 { | |
| 452 | + let before = if service == Service::ReceivePack { | |
| 453 | + match Git::new(repo.disk_path(&state.config)).ref_snapshot().await { | |
| 454 | + Ok(refs) => Some(refs), | |
| 455 | + Err(error) => { | |
| 456 | + tracing::error!(error = ?error, repo = %repo.full_name(), "ref snapshot failed"); | |
| 457 | + return 1; | |
| 458 | + } | |
| 459 | + } | |
| 460 | + } else { | |
| 461 | + None | |
| 462 | + }; | |
| 463 | + let options = ServiceOptions { stateless_rpc: false, advertise_refs: false, protocol }; | |
| 464 | + let spawned = transport::service_command(state, repo, service, Some(viewer), options) | |
| 465 | + .await | |
| 466 | + .and_then(|mut command| Ok(command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?)); | |
| 467 | + let mut child = match spawned { | |
| 468 | + Ok(child) => child, | |
| 469 | + Err(error) => { | |
| 470 | + tracing::error!(error = ?error, repo = %repo.full_name(), "spawning git failed"); | |
| 471 | + let _ = writer.make_writer_ext(Some(1)).write_all(b"irongit: internal error\n").await; | |
| 472 | + return 1; | |
| 473 | + } | |
| 474 | + }; | |
| 475 | + | |
| 476 | + let mut stdout = child.stdout.take().expect("piped"); | |
| 477 | + let mut stderr = child.stderr.take().expect("piped"); | |
| 478 | + let mut out_writer = writer.make_writer(); | |
| 479 | + let mut err_writer = writer.make_writer_ext(Some(1)); | |
| 480 | + let pump_out = tokio::spawn(async move { | |
| 481 | + let result = tokio::io::copy(&mut stdout, &mut out_writer).await; | |
| 482 | + let _ = out_writer.flush().await; | |
| 483 | + result | |
| 484 | + }); | |
| 485 | + let pump_err = tokio::spawn(async move { | |
| 486 | + let result = tokio::io::copy(&mut stderr, &mut err_writer).await; | |
| 487 | + let _ = err_writer.flush().await; | |
| 488 | + result | |
| 489 | + }); | |
| 490 | + | |
| 491 | + // Client -> git stdin until EOF, while git runs. | |
| 492 | + let mut stdin = child.stdin.take(); | |
| 493 | + let status = loop { | |
| 494 | + tokio::select! { | |
| 495 | + message = reader.wait(), if stdin.is_some() => match message { | |
| 496 | + Some(ChannelMsg::Data { data }) => { | |
| 497 | + if let Some(pipe) = stdin.as_mut() { | |
| 498 | + if pipe.write_all(&data).await.is_err() { | |
| 499 | + stdin = None; | |
| 500 | + } | |
| 501 | + } | |
| 502 | + } | |
| 503 | + Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => { | |
| 504 | + if let Some(mut pipe) = stdin.take() { | |
| 505 | + let _ = pipe.shutdown().await; | |
| 506 | + } | |
| 507 | + } | |
| 508 | + Some(_) => {} | |
| 509 | + }, | |
| 510 | + status = child.wait() => break status, | |
| 511 | + } | |
| 512 | + }; | |
| 513 | + let _ = pump_out.await; | |
| 514 | + let _ = pump_err.await; | |
| 515 | + | |
| 516 | + let code = match status { | |
| 517 | + Ok(status) => status.code().unwrap_or(1).clamp(0, 255) as u32, | |
| 518 | + Err(error) => { | |
| 519 | + tracing::error!(%error, repo = %repo.full_name(), "waiting for git failed"); | |
| 520 | + 1 | |
| 521 | + } | |
| 522 | + }; | |
| 523 | + if code == 0 { | |
| 524 | + match before { | |
| 525 | + Some(before) => push::spawn_after_push(state.clone(), repo.clone(), Some(viewer.clone()), before, "ssh"), | |
| 526 | + None => analytics::track(state, "git_fetch", Some(&viewer.name), &repo.url(), json!({ "via": "ssh", "visibility": repo.visibility })), | |
| 527 | + } | |
| 528 | + } else { | |
| 529 | + tracing::info!(repo = %repo.full_name(), service = service.name(), code, "git over ssh exited unsuccessfully"); | |
| 530 | + } | |
| 531 | + code | |
| 532 | +} | |
| 533 | + | |
| 534 | +#[cfg(test)] | |
| 535 | +mod tests { | |
| 536 | + use super::*; | |
| 537 | + | |
| 538 | + fn git(service: Service, owner: &str, repo: &str) -> SshCommand { | |
| 539 | + SshCommand::Git { service, owner: owner.into(), repo: repo.into() } | |
| 540 | + } | |
| 541 | + | |
| 542 | + #[test] | |
| 543 | + fn parses_git_commands() { | |
| 544 | + assert_eq!(parse_command("git-upload-pack 'alice/proj.git'"), Ok(git(Service::UploadPack, "alice", "proj"))); | |
| 545 | + assert_eq!(parse_command("git-receive-pack '/alice/proj.git'"), Ok(git(Service::ReceivePack, "alice", "proj"))); | |
| 546 | + assert_eq!(parse_command("git-upload-pack alice/proj"), Ok(git(Service::UploadPack, "alice", "proj"))); | |
| 547 | + assert_eq!(parse_command("git upload-pack \"/alice/proj/\""), Ok(git(Service::UploadPack, "alice", "proj"))); | |
| 548 | + assert_eq!(parse_command("git-upload-pack '~/alice/my.proj.git'"), Ok(git(Service::UploadPack, "alice", "my.proj"))); | |
| 549 | + } | |
| 550 | + | |
| 551 | + #[test] | |
| 552 | + fn parses_lfs_authenticate() { | |
| 553 | + assert_eq!( | |
| 554 | + parse_command("git-lfs-authenticate 'alice/proj.git' upload"), | |
| 555 | + Ok(SshCommand::LfsAuthenticate { owner: "alice".into(), repo: "proj".into(), operation: LfsOperation::Upload }) | |
| 556 | + ); | |
| 557 | + assert_eq!( | |
| 558 | + parse_command("git-lfs-authenticate alice/proj download"), | |
| 559 | + Ok(SshCommand::LfsAuthenticate { owner: "alice".into(), repo: "proj".into(), operation: LfsOperation::Download }) | |
| 560 | + ); | |
| 561 | + assert!(parse_command("git-lfs-authenticate alice/proj delete").is_err()); | |
| 562 | + } | |
| 563 | + | |
| 564 | + #[test] | |
| 565 | + fn rejects_everything_else() { | |
| 566 | + assert!(parse_command("").is_err()); | |
| 567 | + assert!(parse_command("ls -la").is_err()); | |
| 568 | + assert!(parse_command("git-upload-pack").is_err()); | |
| 569 | + assert!(parse_command("git-upload-pack 'alice'").is_err()); | |
| 570 | + assert!(parse_command("git-upload-pack 'a/b/c'").is_err()); | |
| 571 | + assert!(parse_command("git-upload-pack '../../etc/passwd'").is_err()); | |
| 572 | + assert!(parse_command("git-upload-pack 'alice/proj' extra").is_err()); | |
| 573 | + assert!(parse_command("git-upload-pack 'alice/proj").is_err()); | |
| 574 | + assert!(parse_command("git-upload-pack '--upload-pack=x/y'").is_err()); | |
| 575 | + assert!(parse_command("git-upload-archive 'alice/proj'").is_err()); | |
| 576 | + assert!(parse_command("git-upload-pack 'alice/.hidden'").is_err()); | |
| 577 | + } | |
| 578 | +} |
+40-0backend/src/storage.rs
| @@ -68,6 +68,46 @@ impl Storage { | ||
| 68 | 68 | self.bucket.put_object(Some(&self.credentials), key).sign(ttl) |
| 69 | 69 | } |
| 70 | 70 | |
| 71 | + /// A presigned PUT that only succeeds when the body's SHA-256 matches. | |
| 72 | + /// The client must send `x-amz-checksum-sha256: <sha256_base64>` and each | |
| 73 | + /// `x-amz-meta-<name>: <value>` in `metadata` exactly; R2 rejects any other | |
| 74 | + /// content, so a content-addressed key can never be overwritten with | |
| 75 | + /// different bytes. | |
| 76 | + pub fn presign_put_sha256(&self, key: &str, ttl: Duration, sha256_base64: &str, metadata: &[(&str, &str)]) -> Url { | |
| 77 | + let mut action = self.bucket.put_object(Some(&self.credentials), key); | |
| 78 | + action.headers_mut().insert("x-amz-checksum-sha256", sha256_base64.to_string()); | |
| 79 | + for (name, value) in metadata { | |
| 80 | + action.headers_mut().insert(format!("x-amz-meta-{name}"), value.to_string()); | |
| 81 | + } | |
| 82 | + action.sign(ttl) | |
| 83 | + } | |
| 84 | + | |
| 85 | + /// Size and user metadata (`x-amz-meta-*`, names without the prefix) of | |
| 86 | + /// an object, or None when it does not exist. | |
| 87 | + pub async fn head_metadata(&self, key: &str) -> anyhow::Result<Option<(u64, Vec<(String, String)>)>> { | |
| 88 | + let url = self.bucket.head_object(Some(&self.credentials), key).sign(SELF_TTL); | |
| 89 | + let response = self.http.head(url).send().await?; | |
| 90 | + match response.status() { | |
| 91 | + StatusCode::NOT_FOUND => Ok(None), | |
| 92 | + status if status.is_success() => { | |
| 93 | + let headers = response.headers(); | |
| 94 | + let size = headers | |
| 95 | + .get(reqwest::header::CONTENT_LENGTH) | |
| 96 | + .and_then(|v| v.to_str().ok()?.parse().ok()) | |
| 97 | + .unwrap_or(0); | |
| 98 | + let metadata = headers | |
| 99 | + .iter() | |
| 100 | + .filter_map(|(name, value)| { | |
| 101 | + let name = name.as_str().strip_prefix("x-amz-meta-")?; | |
| 102 | + Some((name.to_string(), value.to_str().ok()?.to_string())) | |
| 103 | + }) | |
| 104 | + .collect(); | |
| 105 | + Ok(Some((size, metadata))) | |
| 106 | + } | |
| 107 | + status => bail!("R2 HEAD {key} failed: {status}"), | |
| 108 | + } | |
| 109 | + } | |
| 110 | + | |
| 71 | 111 | /// Size of an object, or None when it does not exist. |
| 72 | 112 | pub async fn head(&self, key: &str) -> anyhow::Result<Option<u64>> { |
| 73 | 113 | let url = self.bucket.head_object(Some(&self.credentials), key).sign(SELF_TTL); |
+144-0frontend/src/pages/docs/git.astro
| @@ -0,0 +1,144 @@ | ||
| 1 | +--- | |
| 2 | +import Layout from "../../layouts/Layout.astro"; | |
| 3 | + | |
| 4 | +const sections = [ | |
| 5 | + ["https", "Clone over HTTPS"], | |
| 6 | + ["ssh", "Clone over SSH"], | |
| 7 | + ["limits", "File size limit"], | |
| 8 | + ["lfs", "Large files with Git LFS"], | |
| 9 | + ["quotas", "Storage quotas"], | |
| 10 | + ["trouble", "Troubleshooting"], | |
| 11 | +]; | |
| 12 | +--- | |
| 13 | + | |
| 14 | +<Layout | |
| 15 | + title="Git over HTTPS and SSH · irongit docs" | |
| 16 | + description="Clone and push with personal access tokens or SSH keys, store large files with Git LFS, and understand size limits and quotas." | |
| 17 | +> | |
| 18 | + <div class="grid gap-6 md:grid-cols-[180px_1fr]"> | |
| 19 | + <nav class="text-[13px] md:sticky md:top-4 md:self-start"> | |
| 20 | + <a href="/docs" class="text-ink-dim">Docs</a> | |
| 21 | + <p class="mt-2 mb-1 text-xs font-semibold text-ink-faint uppercase">On this page</p> | |
| 22 | + <ul class="space-y-1"> | |
| 23 | + {sections.map(([id, label]) => <li><a href={`#${id}`} class="text-ink-dim hover:text-ink">{label}</a></li>)} | |
| 24 | + </ul> | |
| 25 | + </nav> | |
| 26 | + | |
| 27 | + <article class="markdown max-w-[760px]"> | |
| 28 | + <h1>Git over HTTPS and SSH</h1> | |
| 29 | + <p> | |
| 30 | + Every repository can be cloned and pushed over HTTPS or SSH with any git client. Both paths enforce the same | |
| 31 | + permissions, file size limit and storage quota. | |
| 32 | + </p> | |
| 33 | + | |
| 34 | + <h2 id="https">Clone over HTTPS</h2> | |
| 35 | + <p> | |
| 36 | + HTTPS uses a <strong>personal access token</strong> as the password; account passwords are never accepted by git. | |
| 37 | + The <a href="/docs/cli">ig CLI</a> sets this up for you: | |
| 38 | + </p> | |
| 39 | + <pre><code>ig login | |
| 40 | +ig repo clone owner/repo</code></pre> | |
| 41 | + <p> | |
| 42 | + <code>ig login</code> stores a token and registers <code>ig</code> as git's credential helper for this host, so plain | |
| 43 | + <code>git clone</code>, <code>git pull</code> and <code>git push</code> work without prompts. | |
| 44 | + </p> | |
| 45 | + <p>Without the CLI, create a token under <a href="/settings/tokens">Settings, Tokens</a> and use it when git asks:</p> | |
| 46 | + <pre><code>git clone <span class="ig-origin">https://this-site</span>/owner/repo.git | |
| 47 | +Username: your-username | |
| 48 | +Password: igp_... (the token)</code></pre> | |
| 49 | + <p> | |
| 50 | + Tokens need the <code>repo</code> scope for git. Store them with a credential helper (for example | |
| 51 | + <code>git config --global credential.helper store</code>) rather than in the remote URL. | |
| 52 | + </p> | |
| 53 | + | |
| 54 | + <h2 id="ssh">Clone over SSH</h2> | |
| 55 | + <p> | |
| 56 | + irongit runs its own SSH server for git, separate from the machine's login SSH. It listens on port | |
| 57 | + <strong>2222</strong> unless the administrator changed it, and only accepts public keys. Any username works; | |
| 58 | + <code>git</code> is the convention. | |
| 59 | + </p> | |
| 60 | + <ol> | |
| 61 | + <li> | |
| 62 | + Create a key if you do not have one: <code>ssh-keygen -t ed25519 -C "you@example.com"</code> | |
| 63 | + </li> | |
| 64 | + <li> | |
| 65 | + Add the <strong>public</strong> half (<code>~/.ssh/id_ed25519.pub</code>) under | |
| 66 | + <a href="/settings/keys">Settings, SSH keys</a>, or run <code>ig ssh-key add ~/.ssh/id_ed25519.pub</code>. | |
| 67 | + </li> | |
| 68 | + <li>Clone with an <code>ssh://</code> URL that carries the port:</li> | |
| 69 | + </ol> | |
| 70 | + <pre><code>git clone ssh://git@<span class="ig-host">this-site</span>:2222/owner/repo.git</code></pre> | |
| 71 | + <p>To use the short <code>host:owner/repo</code> form, add a host entry to <code>~/.ssh/config</code>:</p> | |
| 72 | + <pre><code>Host <span class="ig-host">this-site</span> | |
| 73 | + Port 2222 | |
| 74 | + User git | |
| 75 | + IdentityFile ~/.ssh/id_ed25519 | |
| 76 | + IdentitiesOnly yes</code></pre> | |
| 77 | + <pre><code>git clone <span class="ig-host">this-site</span>:owner/repo.git</code></pre> | |
| 78 | + <p>Check that your key is recognised:</p> | |
| 79 | + <pre><code>ssh -T -p 2222 git@<span class="ig-host">this-site</span> | |
| 80 | +Hi you! You've successfully authenticated, but irongit does not provide shell access.</code></pre> | |
| 81 | + | |
| 82 | + <h2 id="limits">File size limit</h2> | |
| 83 | + <p> | |
| 84 | + A push is refused if it adds any single file larger than <strong>50 MB</strong> (the default; administrators can | |
| 85 | + change it). The rejection lists each oversized file: | |
| 86 | + </p> | |
| 87 | + <pre><code>remote: irongit: push rejected: files larger than 50 MB must use Git LFS. | |
| 88 | +remote: irongit: assets/video.mp4 (212 MB)</code></pre> | |
| 89 | + <p> | |
| 90 | + The check covers every commit in the push, not just the last one, so deleting the file in a later commit is not | |
| 91 | + enough. Move the file to LFS and rewrite the commits that added it (next section). | |
| 92 | + </p> | |
| 93 | + | |
| 94 | + <h2 id="lfs">Large files with Git LFS</h2> | |
| 95 | + <p> | |
| 96 | + Git LFS keeps large files out of the repository: git stores a small pointer and the file itself goes to object | |
| 97 | + storage. Uploads and downloads go straight between your machine and storage, so large files are fast and never | |
| 98 | + count against the git file size limit. Individual LFS objects can be up to <strong>2 GB</strong> by default. | |
| 99 | + </p> | |
| 100 | + <p>Install <a href="https://git-lfs.com">git-lfs</a> once, then in a repository:</p> | |
| 101 | + <pre><code>git lfs install | |
| 102 | +git lfs track "*.psd" "*.mp4" "models/**" | |
| 103 | +git add .gitattributes | |
| 104 | +git add design.psd | |
| 105 | +git commit -m "Add design files" | |
| 106 | +git push</code></pre> | |
| 107 | + <p>LFS works over both HTTPS and SSH remotes with the same credentials as git. To move files that are already in history into LFS:</p> | |
| 108 | + <pre><code>git lfs migrate import --include="*.mp4" --everything | |
| 109 | +git push --force-with-lease</code></pre> | |
| 110 | + <p> | |
| 111 | + Rewriting history changes commit ids, so coordinate with anyone else working on the branch. Clones fetch LFS files | |
| 112 | + automatically; <code>GIT_LFS_SKIP_SMUDGE=1 git clone ...</code> skips them and <code>git lfs pull</code> fetches them later. | |
| 113 | + File locking (<code>git lfs lock</code>) is not supported. | |
| 114 | + </p> | |
| 115 | + | |
| 116 | + <h2 id="quotas">Storage quotas</h2> | |
| 117 | + <p> | |
| 118 | + Each account (personal or organization) has a storage quota for git data across all its repositories, | |
| 119 | + <strong>5 GB</strong> by default. A push that would exceed it is refused with the space remaining. Repository sizes | |
| 120 | + are shown on each repository and in your settings. LFS objects and container images are stored separately and do | |
| 121 | + not count against the git quota. | |
| 122 | + </p> | |
| 123 | + | |
| 124 | + <h2 id="trouble">Troubleshooting</h2> | |
| 125 | + <table> | |
| 126 | + <thead><tr><th>Message</th><th>Meaning</th></tr></thead> | |
| 127 | + <tbody> | |
| 128 | + <tr><td><code>Authentication failed</code> (HTTPS)</td><td>Use a personal access token as the password, not your account password. Check it has the <code>repo</code> scope and has not expired.</td></tr> | |
| 129 | + <tr><td><code>Permission denied (publickey)</code></td><td>The key you offered is not registered. Add its <code>.pub</code> file in settings and make sure ssh uses it (<code>IdentityFile</code>, <code>IdentitiesOnly yes</code>).</td></tr> | |
| 130 | + <tr><td><code>repository 'owner/repo' not found</code></td><td>The repository does not exist or you do not have access. Private repositories look missing to people without access.</td></tr> | |
| 131 | + <tr><td><code>you have read access only</code></td><td>Ask a repository admin for write access, or push to your own fork.</td></tr> | |
| 132 | + <tr><td><code>this repository is archived</code></td><td>Archived repositories are read-only until an admin unarchives them.</td></tr> | |
| 133 | + <tr><td><code>files larger than 50 MB must use Git LFS</code></td><td>See <a href="#lfs">Git LFS</a>; rewrite the commits that added the files.</td></tr> | |
| 134 | + <tr><td>Connection refused on port 22</td><td>The git SSH server uses port 2222. Use an <code>ssh://</code> URL with the port or an <code>~/.ssh/config</code> entry.</td></tr> | |
| 135 | + </tbody> | |
| 136 | + </table> | |
| 137 | + </article> | |
| 138 | + </div> | |
| 139 | +</Layout> | |
| 140 | + | |
| 141 | +<script> | |
| 142 | + document.querySelectorAll(".ig-host").forEach((el) => (el.textContent = location.hostname)); | |
| 143 | + document.querySelectorAll(".ig-origin").forEach((el) => (el.textContent = location.origin)); | |
| 144 | +</script> |
+51-0scripts/e2e/backup.sh
| @@ -0,0 +1,51 @@ | ||
| 1 | +#!/bin/bash | |
| 2 | +# Runs one real backup to R2 (via the ignored cargo test), downloads the | |
| 3 | +# bundles and the Postgres dump, and restores every bundle with git clone, | |
| 4 | +# comparing refs against the live repositories. Uses .env, so it backs up | |
| 5 | +# that database and data/repos into that bucket (and prunes old runs there). | |
| 6 | +set -uo pipefail | |
| 7 | + | |
| 8 | +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" | |
| 9 | +E2E="$ROOT/data/e2e" | |
| 10 | +mkdir -p "$E2E/work" | |
| 11 | +DB="$(sed -n 's/^DATABASE_URL=//p' "$ROOT/.env")" | |
| 12 | +RESTORE="$E2E/work/restore" | |
| 13 | +rm -rf "$RESTORE" | |
| 14 | +export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null | |
| 15 | +export PATH="$E2E/bin:$PATH" | |
| 16 | + | |
| 17 | +cd "$ROOT" | |
| 18 | +E2E_RESTORE_DIR="$RESTORE" cargo test -q -p irongit backup::tests::backup_now -- --ignored --nocapture > "$E2E/work/backup-test.log" 2>&1 | |
| 19 | +rc=$? | |
| 20 | +grep -E 'SUMMARY|RUN:|BUNDLE|STAMPS|test result|panicked' "$E2E/work/backup-test.log" | cut -c1-300 | |
| 21 | +[ "$rc" -eq 0 ] || { echo "FAIL backup test (see $E2E/work/backup-test.log)"; exit 1; } | |
| 22 | + | |
| 23 | +PASS=0 | |
| 24 | +FAIL=0 | |
| 25 | +for bundle in "$RESTORE"/*.bundle; do | |
| 26 | + base=$(basename "$bundle" .bundle) | |
| 27 | + owner=${base%%-*} | |
| 28 | + name=${base#*-} | |
| 29 | + id=$(psql "$DB" -tAc "select r.id from repos r join accounts a on a.id = r.owner_id where a.name = '$owner' and r.name = '$name'") | |
| 30 | + live=$(git --git-dir="$ROOT/data/repos/$id.git" for-each-ref --format='%(refname) %(objectname)' refs/heads refs/tags | sort) | |
| 31 | + git clone -q --mirror "$bundle" "$RESTORE/$base.git" 2>"$RESTORE/$base.err" | |
| 32 | + restored=$(git --git-dir="$RESTORE/$base.git" for-each-ref --format='%(refname) %(objectname)' refs/heads refs/tags | sort) | |
| 33 | + if [ -n "$live" ] && [ "$live" = "$restored" ] && git --git-dir="$RESTORE/$base.git" fsck --no-dangling --no-progress >/dev/null 2>&1; then | |
| 34 | + echo "PASS restore $owner/$name ($(printf '%s\n' "$restored" | wc -l) refs, fsck clean)" | |
| 35 | + PASS=$((PASS + 1)) | |
| 36 | + else | |
| 37 | + echo "FAIL restore $owner/$name" | |
| 38 | + FAIL=$((FAIL + 1)) | |
| 39 | + fi | |
| 40 | +done | |
| 41 | +if gunzip -t "$RESTORE/postgres.sql.gz" && [ "$(gunzip -c "$RESTORE/postgres.sql.gz" | grep -c 'CREATE TABLE public.repos ')" = 1 ]; then | |
| 42 | + echo "PASS postgres dump is valid gzip with the schema" | |
| 43 | + PASS=$((PASS + 1)) | |
| 44 | +else | |
| 45 | + echo "FAIL postgres dump" | |
| 46 | + FAIL=$((FAIL + 1)) | |
| 47 | +fi | |
| 48 | +echo "latest runs:" | |
| 49 | +psql "$DB" -c "select id, status, repo_count, bytes, error, finished_at - started_at as took from backup_runs order by id desc limit 3" | |
| 50 | +echo "passed $PASS, failed $FAIL" | |
| 51 | +[ "$FAIL" -eq 0 ] |
+22-0scripts/e2e/fetch-lfs.py
| @@ -0,0 +1,22 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Downloads the git-lfs linux-amd64 release binary into data/e2e/bin (tests only).""" | |
| 3 | +import io | |
| 4 | +import json | |
| 5 | +import os | |
| 6 | +import tarfile | |
| 7 | +import urllib.request | |
| 8 | + | |
| 9 | +here = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "data", "e2e") | |
| 10 | +release = json.load(urllib.request.urlopen("https://api.github.com/repos/git-lfs/git-lfs/releases/latest")) | |
| 11 | +url = next(a["browser_download_url"] for a in release["assets"] | |
| 12 | + if a["name"].startswith("git-lfs-linux-amd64-") and a["name"].endswith(".tar.gz")) | |
| 13 | +print(url) | |
| 14 | +data = urllib.request.urlopen(url).read() | |
| 15 | +os.makedirs(os.path.join(here, "bin"), exist_ok=True) | |
| 16 | +with tarfile.open(fileobj=io.BytesIO(data)) as tar: | |
| 17 | + member = next(m for m in tar.getmembers() if m.name.endswith("/git-lfs") or m.name == "git-lfs") | |
| 18 | + target = os.path.join(here, "bin", "git-lfs") | |
| 19 | + with open(target, "wb") as out: | |
| 20 | + out.write(tar.extractfile(member).read()) | |
| 21 | + os.chmod(target, 0o755) | |
| 22 | +print("ok", target) |
+186-0scripts/e2e/ssh-lfs.sh
| @@ -0,0 +1,186 @@ | ||
| 1 | +#!/bin/bash | |
| 2 | +# End-to-end test of SSH git, LFS (HTTP and SSH) and LFS access control | |
| 3 | +# against a running server. Point it at a throwaway database: it creates and | |
| 4 | +# deletes the users 'alice' and 'bob'. Uses a throwaway HOME, so the real | |
| 5 | +# ~/.ssh and ~/.gitconfig are never touched. | |
| 6 | +# | |
| 7 | +# python3 scripts/e2e/fetch-lfs.py # once: git-lfs into data/e2e/bin | |
| 8 | +# E2E_HTTP=http://localhost:7883 E2E_SSH_PORT=2283 scripts/e2e/ssh-lfs.sh | |
| 9 | +# | |
| 10 | +# Reads DATABASE_URL from .env and initializes bare repos under data/repos, | |
| 11 | +# so run it from the checkout whose server is under test. | |
| 12 | +set -uo pipefail | |
| 13 | + | |
| 14 | +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" | |
| 15 | +E2E="$ROOT/data/e2e" | |
| 16 | +DB="$(sed -n 's/^DATABASE_URL=//p' "$ROOT/.env")" | |
| 17 | +HTTP="${E2E_HTTP:-http://localhost:7883}" | |
| 18 | +SSHP="${E2E_SSH_PORT:-2283}" | |
| 19 | +HOSTPORT="${HTTP#http://}" | |
| 20 | +W="$E2E/work" | |
| 21 | +rm -rf "$W" | |
| 22 | +mkdir -p "$W/home" | |
| 23 | +export HOME="$W/home" | |
| 24 | +export PATH="$E2E/bin:$PATH" | |
| 25 | +unset SSH_AUTH_SOCK | |
| 26 | +export GIT_CONFIG_NOSYSTEM=1 GIT_TERMINAL_PROMPT=0 | |
| 27 | +git config --global user.name Alice | |
| 28 | +git config --global user.email alice@example.com | |
| 29 | +git config --global init.defaultBranch main | |
| 30 | +git config --global credential.helper "store --file=$W/creds" | |
| 31 | +git lfs install --skip-repo >/dev/null | |
| 32 | + | |
| 33 | +PASS=0 | |
| 34 | +FAIL=0 | |
| 35 | +ok() { echo "PASS $1"; PASS=$((PASS + 1)); } | |
| 36 | +bad() { echo "FAIL $1"; FAIL=$((FAIL + 1)); } | |
| 37 | +expect() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (expected '$3', got '$2')"; fi; } | |
| 38 | +contains() { if printf "%s" "$2" | grep -qi -- "$3"; then ok "$1"; else bad "$1 (missing '$3' in: $(printf '%s' "$2" | head -c 300))"; fi; } | |
| 39 | +sql() { psql "$DB" -tAc "$1"; } | |
| 40 | + | |
| 41 | +# --- Users, keys, tokens, repos --------------------------------------------- | |
| 42 | +for who in alice bob stranger; do ssh-keygen -q -t ed25519 -N '' -C "$who" -f "$W/${who}_key"; done | |
| 43 | +fp() { ssh-keygen -lf "$1.pub" -E sha256 | awk '{print $2}'; } | |
| 44 | +TA=igp_e2eAlice00000000000000000000000000000000 | |
| 45 | +TB=igp_e2eBob0000000000000000000000000000000000 | |
| 46 | +hash() { printf %s "$1" | sha256sum | cut -d' ' -f1; } | |
| 47 | +psql "$DB" -q -v ON_ERROR_STOP=1 <<SQL | |
| 48 | +delete from accounts where name in ('alice', 'bob'); | |
| 49 | +insert into accounts (kind, name) values ('user', 'alice'), ('user', 'bob'); | |
| 50 | +insert into users (account_id) select id from accounts where name in ('alice', 'bob'); | |
| 51 | +insert into emails (user_id, email, is_primary, verified_at) select id, name || '@example.com', true, now() from accounts where name in ('alice', 'bob'); | |
| 52 | +insert into access_tokens (user_id, name, token_hash, token_prefix) select id, 'e2e', '$(hash $TA)', 'igp_e2eA' from accounts where name = 'alice'; | |
| 53 | +insert into access_tokens (user_id, name, token_hash, token_prefix) select id, 'e2e', '$(hash $TB)', 'igp_e2eB' from accounts where name = 'bob'; | |
| 54 | +insert into ssh_keys (user_id, title, public_key, fingerprint) select id, 'e2e', '$(cut -d' ' -f1,2 "$W/alice_key.pub")', '$(fp "$W/alice_key")' from accounts where name = 'alice'; | |
| 55 | +insert into ssh_keys (user_id, title, public_key, fingerprint) select id, 'e2e', '$(cut -d' ' -f1,2 "$W/bob_key.pub")', '$(fp "$W/bob_key")' from accounts where name = 'bob'; | |
| 56 | +insert into repos (owner_id, name, visibility) select id, unnest(array['ssh-repo', 'lfs-http', 'lfs-ssh']), 'private' from accounts where name = 'alice'; | |
| 57 | +insert into repos (owner_id, name, visibility) select id, 'bob-repo', 'private' from accounts where name = 'bob'; | |
| 58 | +insert into repo_collaborators (repo_id, user_id, permission) | |
| 59 | + select r.id, b.id, 'read' from repos r join accounts a on a.id = r.owner_id, accounts b where a.name = 'alice' and r.name = 'ssh-repo' and b.name = 'bob'; | |
| 60 | +SQL | |
| 61 | +repo_id() { sql "select r.id from repos r join accounts a on a.id = r.owner_id where a.name = '$1' and r.name = '$2'"; } | |
| 62 | +for spec in alice/ssh-repo alice/lfs-http alice/lfs-ssh bob/bob-repo; do | |
| 63 | + id=$(repo_id "${spec%/*}" "${spec#*/}") | |
| 64 | + git init --bare -q --initial-branch=main "$ROOT/data/repos/$id.git" | |
| 65 | + rm -rf "$ROOT/data/repos/$id.git/hooks" | |
| 66 | +done | |
| 67 | +printf 'http://alice:%s@%s\n' "$TA" "$HOSTPORT" > "$W/creds" | |
| 68 | + | |
| 69 | +SSH_OPTS="-o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR" | |
| 70 | +as_alice() { GIT_SSH_COMMAND="ssh -i $W/alice_key $SSH_OPTS" "$@"; } | |
| 71 | +as_bob() { GIT_SSH_COMMAND="ssh -i $W/bob_key $SSH_OPTS" "$@"; } | |
| 72 | + | |
| 73 | +# --- 1. Git over SSH ---------------------------------------------------------- | |
| 74 | +echo "== git over ssh" | |
| 75 | +mkdir "$W/src1" && cd "$W/src1" && git init -q | |
| 76 | +echo hello > README.md && git add . && git commit -qm "first" | |
| 77 | +echo two > two.txt && git add . && git commit -qm "second" | |
| 78 | +out=$(as_alice git push ssh://git@localhost:$SSHP/alice/ssh-repo.git main 2>&1); rc=$? | |
| 79 | +expect "push over ssh succeeds" "$rc" 0 | |
| 80 | +out=$(as_alice git clone -q ssh://git@localhost:$SSHP/alice/ssh-repo.git "$W/clone1" 2>&1); rc=$? | |
| 81 | +expect "clone over ssh succeeds" "$rc" 0 | |
| 82 | +expect "clone has both commits" "$(git -C "$W/clone1" rev-list --count HEAD 2>/dev/null)" 2 | |
| 83 | +out=$(GIT_SSH_COMMAND="ssh -i $W/alice_key $SSH_OPTS -o SendEnv=GIT_PROTOCOL" git -c protocol.version=0 clone -q ssh://git@localhost:$SSHP/alice/ssh-repo "$W/clone1b" 2>&1); rc=$? | |
| 84 | +expect "clone over ssh with protocol v0 and no .git suffix" "$rc" 0 | |
| 85 | +sleep 2 | |
| 86 | +RID=$(repo_id alice ssh-repo) | |
| 87 | +expect "push event recorded via ssh" "$(sql "select count(*) from push_events where repo_id = $RID and via = 'ssh'")" 1 | |
| 88 | +expect "contributions recorded for ssh push" "$(sql "select count(*) from contribution_commits where repo_id = $RID")" 2 | |
| 89 | +expect "repo marked non-empty with size" "$(sql "select (not is_empty and size_bytes > 0)::text from repos where id = $RID")" true | |
| 90 | + | |
| 91 | +out=$(as_bob git clone -q ssh://git@localhost:$SSHP/alice/ssh-repo.git "$W/clone-bob" 2>&1); rc=$? | |
| 92 | +expect "read collaborator can clone over ssh" "$rc" 0 | |
| 93 | +cd "$W/clone-bob" && echo bob > bob.txt && git add . && git -c user.email=bob@example.com commit -qm "bob" | |
| 94 | +out=$(as_bob git push origin main 2>&1); rc=$? | |
| 95 | +[ "$rc" -ne 0 ] && ok "read collaborator cannot push over ssh" || bad "read collaborator pushed" | |
| 96 | +contains "push refusal explains read access" "$out" "read access only" | |
| 97 | +out=$(as_bob git clone -q ssh://git@localhost:$SSHP/alice/lfs-http.git "$W/clone-bob2" 2>&1); rc=$? | |
| 98 | +[ "$rc" -ne 0 ] && ok "no-access user cannot clone private repo" || bad "no-access user cloned" | |
| 99 | +contains "no-access clone says not found" "$out" "not found" | |
| 100 | +out=$(GIT_SSH_COMMAND="ssh -i $W/stranger_key $SSH_OPTS" git clone -q ssh://git@localhost:$SSHP/alice/ssh-repo.git "$W/clone-x" 2>&1); rc=$? | |
| 101 | +[ "$rc" -ne 0 ] && ok "unknown key is refused" || bad "unknown key accepted" | |
| 102 | +contains "unknown key gets permission denied" "$out" "Permission denied" | |
| 103 | +out=$(ssh -i "$W/alice_key" $SSH_OPTS -p $SSHP -T git@localhost 2>&1); rc=$? | |
| 104 | +expect "shell request exits 1" "$rc" 1 | |
| 105 | +contains "shell request greets the user" "$out" "Hi alice" | |
| 106 | +out=$(ssh -i "$W/alice_key" $SSH_OPTS -p $SSHP git@localhost "cat /etc/passwd" 2>&1); rc=$? | |
| 107 | +[ "$rc" -ne 0 ] && ok "arbitrary command refused" || bad "arbitrary command ran" | |
| 108 | +contains "arbitrary command message" "$out" "not allowed" | |
| 109 | + | |
| 110 | +# --- 2. LFS over HTTP --------------------------------------------------------- | |
| 111 | +echo "== lfs over http" | |
| 112 | +mkdir "$W/src2" && cd "$W/src2" && git init -q | |
| 113 | +git lfs track "*.bin" >/dev/null | |
| 114 | +head -c 62914560 /dev/urandom > big.bin | |
| 115 | +BIG_SHA=$(sha256sum big.bin | cut -d' ' -f1) | |
| 116 | +git add .gitattributes big.bin && git commit -qm "big file" | |
| 117 | +git remote add origin $HTTP/alice/lfs-http.git | |
| 118 | +out=$(git push origin main 2>&1); rc=$? | |
| 119 | +expect "lfs push over http succeeds" "$rc" 0 | |
| 120 | +LID=$(repo_id alice lfs-http) | |
| 121 | +expect "lfs object linked to repo" "$(sql "select count(*) from repo_lfs_objects where repo_id = $LID and oid = '$BIG_SHA'")" 1 | |
| 122 | +expect "lfs object recorded with size" "$(sql "select size from lfs_objects where oid = '$BIG_SHA'")" 62914560 | |
| 123 | +out=$(git clone -q $HTTP/alice/lfs-http.git "$W/clone2" 2>&1); rc=$? | |
| 124 | +expect "fresh http clone succeeds" "$rc" 0 | |
| 125 | +expect "fresh http clone has real lfs content" "$(sha256sum "$W/clone2/big.bin" 2>/dev/null | cut -d' ' -f1)" "$BIG_SHA" | |
| 126 | +batch() { curl -s -o "$W/batch.json" -w '%{http_code}' -X POST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' "$@"; } | |
| 127 | +code=$(batch -u "alice:$TA" -d "{\"operation\":\"download\",\"objects\":[{\"oid\":\"$BIG_SHA\",\"size\":62914560}]}" $HTTP/alice/lfs-http.git/info/lfs/objects/batch) | |
| 128 | +href=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["objects"][0]["actions"]["download"]["href"])' "$W/batch.json") | |
| 129 | +contains "download href points at R2 lfs/ key" "$href" "/lfs/${BIG_SHA:0:2}/${BIG_SHA:2:2}/$BIG_SHA" | |
| 130 | +expect "R2 object has the full size" "$(curl -s -o /dev/null -w '%{size_download}' "$href")" 62914560 | |
| 131 | + | |
| 132 | +# --- 3. LFS over SSH (git-lfs-authenticate) ---------------------------------- | |
| 133 | +echo "== lfs over ssh" | |
| 134 | +mkdir "$W/src3" && cd "$W/src3" && git init -q | |
| 135 | +git lfs track "*.bin" >/dev/null | |
| 136 | +cp "$W/src2/big.bin" big.bin | |
| 137 | +head -c 5242880 /dev/urandom > small.bin | |
| 138 | +SMALL_SHA=$(sha256sum small.bin | cut -d' ' -f1) | |
| 139 | +git add .gitattributes big.bin small.bin && git commit -qm "lfs over ssh" | |
| 140 | +git remote add origin ssh://git@localhost:$SSHP/alice/lfs-ssh.git | |
| 141 | +out=$(as_alice git push origin main 2>&1); rc=$? | |
| 142 | +expect "lfs push over ssh succeeds" "$rc" 0 | |
| 143 | +SID=$(repo_id alice lfs-ssh) | |
| 144 | +expect "both objects linked to the ssh repo" "$(sql "select count(*) from repo_lfs_objects where repo_id = $SID")" 2 | |
| 145 | +out=$(as_alice git clone -q ssh://git@localhost:$SSHP/alice/lfs-ssh.git "$W/clone3" 2>&1); rc=$? | |
| 146 | +expect "fresh ssh clone succeeds" "$rc" 0 | |
| 147 | +expect "ssh clone big file content" "$(sha256sum "$W/clone3/big.bin" 2>/dev/null | cut -d' ' -f1)" "$BIG_SHA" | |
| 148 | +expect "ssh clone small file content" "$(sha256sum "$W/clone3/small.bin" 2>/dev/null | cut -d' ' -f1)" "$SMALL_SHA" | |
| 149 | +auth=$(ssh -i "$W/alice_key" $SSH_OPTS -p $SSHP git@localhost git-lfs-authenticate alice/lfs-http.git download 2>&1) | |
| 150 | +contains "git-lfs-authenticate returns an href" "$auth" "\"href\":\"$HTTP/alice/lfs-http.git/info/lfs\"" | |
| 151 | +DL_TOKEN=$(printf '%s' "$auth" | python3 -c 'import json,sys; print(json.load(sys.stdin)["header"]["Authorization"])') | |
| 152 | +code=$(batch -H "Authorization: $DL_TOKEN" -d "{\"operation\":\"upload\",\"objects\":[{\"oid\":\"$SMALL_SHA\",\"size\":5242880}]}" $HTTP/alice/lfs-http.git/info/lfs/objects/batch) | |
| 153 | +expect "download-only ssh token cannot upload" "$code" 403 | |
| 154 | +code=$(batch -H "Authorization: $DL_TOKEN" -d "{\"operation\":\"download\",\"objects\":[{\"oid\":\"$BIG_SHA\",\"size\":62914560}]}" $HTTP/alice/lfs-ssh.git/info/lfs/objects/batch) | |
| 155 | +expect "ssh token for one repo is refused on another" "$code" 403 | |
| 156 | +out=$(ssh -i "$W/bob_key" $SSH_OPTS -p $SSHP git@localhost git-lfs-authenticate alice/lfs-http.git download 2>&1); rc=$? | |
| 157 | +[ "$rc" -ne 0 ] && ok "no-access user gets no lfs token" || bad "no-access user got lfs token: $out" | |
| 158 | + | |
| 159 | +# --- 4. LFS access control ---------------------------------------------------- | |
| 160 | +echo "== lfs access control" | |
| 161 | +code=$(batch -d "{\"operation\":\"download\",\"objects\":[{\"oid\":\"$BIG_SHA\",\"size\":62914560}]}" -D "$W/anon.headers" $HTTP/alice/lfs-http.git/info/lfs/objects/batch) | |
| 162 | +expect "anonymous batch on private repo is 401" "$code" 401 | |
| 163 | +contains "401 carries LFS-Authenticate" "$(cat "$W/anon.headers")" "LFS-Authenticate: Basic" | |
| 164 | +code=$(batch -u "bob:$TB" -d "{\"operation\":\"download\",\"objects\":[{\"oid\":\"$BIG_SHA\",\"size\":62914560}]}" $HTTP/alice/lfs-http.git/info/lfs/objects/batch) | |
| 165 | +expect "other user gets 404 on private repo" "$code" 404 | |
| 166 | +code=$(batch -u "bob:$TB" -d "{\"operation\":\"download\",\"objects\":[{\"oid\":\"$BIG_SHA\",\"size\":62914560}]}" $HTTP/bob/bob-repo.git/info/lfs/objects/batch) | |
| 167 | +expect "batch on own repo answers 200" "$code" 200 | |
| 168 | +contains "foreign oid is not downloadable from own repo" "$(cat "$W/batch.json")" '"code":404' | |
| 169 | +code=$(batch -u "bob:$TB" -d "{\"operation\":\"upload\",\"objects\":[{\"oid\":\"$BIG_SHA\",\"size\":62914560}]}" $HTTP/bob/bob-repo.git/info/lfs/objects/batch) | |
| 170 | +expect "upload batch for a stored oid answers 200" "$code" 200 | |
| 171 | +contains "stored oid still requires an upload" "$(cat "$W/batch.json")" '"upload"' | |
| 172 | +VERIFY_AUTH=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["objects"][0]["actions"]["verify"]["header"]["Authorization"])' "$W/batch.json") | |
| 173 | +code=$(curl -s -o "$W/verify.json" -w '%{http_code}' -X POST -H "Authorization: $VERIFY_AUTH" -H 'Content-Type: application/vnd.git-lfs+json' -d "{\"oid\":\"$BIG_SHA\",\"size\":62914560}" $HTTP/bob/bob-repo.git/info/lfs/verify) | |
| 174 | +expect "verify without uploading is rejected" "$code" 422 | |
| 175 | +code=$(curl -s -o "$W/verify.json" -w '%{http_code}' -X POST -u "bob:$TB" -H 'Content-Type: application/vnd.git-lfs+json' -d "{\"oid\":\"$BIG_SHA\",\"size\":62914560}" $HTTP/bob/bob-repo.git/info/lfs/verify) | |
| 176 | +expect "verify with a plain token is rejected" "$code" 403 | |
| 177 | +BID=$(repo_id bob bob-repo) | |
| 178 | +expect "foreign object never linked to bob's repo" "$(sql "select count(*) from repo_lfs_objects where repo_id = $BID")" 0 | |
| 179 | +code=$(batch -u "alice:$TA" -d '{"operation":"download","objects":[{"oid":"../../etc/passwd","size":1}]}' $HTTP/alice/lfs-http.git/info/lfs/objects/batch) | |
| 180 | +contains "invalid oid rejected per object" "$(cat "$W/batch.json")" '"code":422' | |
| 181 | +code=$(curl -s -o /dev/null -w '%{http_code}' -X POST -u "alice:$TA" -H 'Content-Type: application/vnd.git-lfs+json' -d '{}' $HTTP/alice/lfs-http.git/info/lfs/locks/verify) | |
| 182 | +expect "locks/verify answers 200" "$code" 200 | |
| 183 | + | |
| 184 | +echo | |
| 185 | +echo "passed $PASS, failed $FAIL" | |
| 186 | +[ "$FAIL" -eq 0 ] |
+22-0scripts/e2e/stop-server.py
| @@ -0,0 +1,22 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""List (or with --kill, stop) debug servers built from this checkout, leaving | |
| 3 | +servers from other checkouts and worktrees alone.""" | |
| 4 | +import os | |
| 5 | +import signal | |
| 6 | +import sys | |
| 7 | + | |
| 8 | +worktree = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) # checkout root | |
| 9 | +kill = "--kill" in sys.argv | |
| 10 | +for pid in os.listdir("/proc"): | |
| 11 | + if not pid.isdigit(): | |
| 12 | + continue | |
| 13 | + try: | |
| 14 | + exe = os.readlink(f"/proc/{pid}/exe").removesuffix(" (deleted)") | |
| 15 | + cwd = os.readlink(f"/proc/{pid}/cwd") | |
| 16 | + except OSError: | |
| 17 | + continue | |
| 18 | + if exe.startswith(worktree + "/target/") and exe.endswith("/debug/irongit"): | |
| 19 | + print(pid, exe, cwd) | |
| 20 | + if kill: | |
| 21 | + os.kill(int(pid), signal.SIGTERM) | |
| 22 | + print("killed", pid) |