anvilsign in

collin/anvil

main / crates / anvil-core / src / repos.rs
1//! Repository records: creation (DB row + on-disk bare repo) and lookup.
2
3use std::path::Path;
4
5use crate::{
6 error::{
7 Error,
8 Result,
9 },
10 models::{
11 Repository,
12 User,
13 },
14 storage,
15};
16
17/// A repository joined with its owner's username, for listing pages.
18pub struct RepoWithOwner {
19 pub owner_id: i64,
20 pub owner: String,
21 pub name: String,
22 pub description: String,
23 pub is_private: bool,
24 pub default_branch: String,
25 pub primary_language: String,
26 pub preview_image_hash: String,
27}
28
29/// Create a repository owned by `owner`: insert the database row and initialize
30/// the bare repository on disk. If the on-disk init fails, the row is rolled
31/// back (deleted) so no orphan remains.
32pub async fn create(
33 db: &toasty::Db,
34 repositories_dir: &Path,
35 owner: &User,
36 name: &str,
37 description: &str,
38 is_private: bool,
39) -> Result<Repository> {
40 validate_name(name)?;
41 if find(db, owner.id, name).await?.is_some() {
42 return Err(Error::AlreadyExists(format!(
43 "repository {}/{name}",
44 owner.username
45 )));
46 }
47
48 let mut conn = db.clone();
49 let repo = toasty::create!(Repository {
50 owner_id: owner.id,
51 name: name,
52 description: description,
53 is_private: is_private,
54 default_branch: "main",
55 created_at: crate::now(),
56 mirror_url: "",
57 preview_image_hash: "",
58 primary_language: "",
59 languages_json: "",
60 })
61 .exec(&mut conn)
62 .await?;
63
64 // Create the bare repo on disk; on failure, remove the row we just wrote.
65 if let Err(e) = storage::create_bare(
66 repositories_dir,
67 &owner.username,
68 name,
69 &repo.default_branch,
70 ) {
71 let _ = repo.delete().exec(&mut conn).await;
72 return Err(e);
73 }
74 Ok(repo)
75}
76
77/// Push-to-create: create `owner_username/name` on first push, when the
78/// authenticated pusher is allowed to — the pusher *is* the owner, or is an
79/// admin. Created repositories are private (flip in settings afterwards).
80/// `Ok(None)` means the policy said no; callers fall back to not-found so
81/// nothing about the namespace leaks.
82pub async fn create_on_push(
83 db: &toasty::Db,
84 repositories_dir: &Path,
85 owner_username: &str,
86 name: &str,
87 pusher: &User,
88) -> Result<Option<Repository>> {
89 let Some(owner) = crate::users::find_by_username(db, owner_username).await? else {
90 return Ok(None);
91 };
92 if pusher.id != owner.id && !pusher.is_admin {
93 return Ok(None);
94 }
95 let repo = create(db, repositories_dir, &owner, name, "", true).await?;
96 tracing::info!(
97 "push-to-create: {}/{name} (by {})",
98 owner.username,
99 pusher.username
100 );
101 Ok(Some(repo))
102}
103
104/// Delete a repository: every row that hangs off it, then its bytes on disk.
105///
106/// This is the one destructive operation in the forge, so the order is chosen
107/// for what a crash halfway through leaves behind. The bare repository is moved
108/// aside first (see [`storage::stage_removal`]), the rows go next, and the
109/// actual `remove_dir_all` calls come last and only log on failure. Any
110/// interruption therefore leaves unreferenced bytes rather than rows pointing
111/// at a repository that isn't there.
112///
113/// Refuses while the repository has a live agent session: those containers have
114/// a workspace seeded from the repository, and pulling the directory out from
115/// under a running supervisor is a worse failure than asking the owner to stop
116/// it. In-flight CI is not a blocker — a runner reporting a result for a run
117/// that no longer exists is a no-op by construction ([`ci::finish`] and friends
118/// return early on a missing row) — but the run's lease is released so the
119/// dispatcher stops accounting for it.
120pub async fn delete(app: &crate::App, owner: &User, repo: &Repository) -> Result<()> {
121 let live = crate::agent::live_for_repo(&app.db, repo.id).await?;
122 if !live.is_empty() {
123 let ids: Vec<String> = live.iter().map(|s| format!("#{}", s.id)).collect();
124 return Err(Error::Invalid(format!(
125 "{}/{} has {} running agent {} ({}) — stop {} first",
126 owner.username,
127 repo.name,
128 live.len(),
129 if live.len() == 1 {
130 "session"
131 } else {
132 "sessions"
133 },
134 ids.join(", "),
135 if live.len() == 1 { "it" } else { "them" },
136 )));
137 }
138
139 let staged = storage::stage_removal(
140 &app.config.repositories_dir(),
141 &owner.username,
142 &repo.name,
143 repo.id,
144 )?;
145
146 for run_id in crate::ci::delete_for_repo(&app.db, repo.id).await? {
147 app.jobs.release(run_id);
148 }
149 crate::issues::delete_for_repo(&app.db, repo.id).await?;
150 crate::attachments::delete_for_repo(&app.db, repo.id).await?;
151 crate::secrets::delete_all(&app.db, repo.id).await?;
152 app.vault.lock(repo.id);
153 let sessions = crate::agent::delete_for_repo(&app.db, repo.id).await?;
154
155 let mut conn = app.db.clone();
156 let Some(row) = Repository::filter(Repository::fields().id().eq(repo.id))
157 .first()
158 .exec(&mut conn)
159 .await?
160 else {
161 return Err(Error::NotFound(format!("repository id {}", repo.id)));
162 };
163 let mut conn = app.db.clone();
164 row.delete().exec(&mut conn).await?;
165
166 if let Some(staged) = staged {
167 storage::remove_tree(&staged);
168 }
169 storage::remove_tree(&app.config.artifacts_dir().join(repo.id.to_string()));
170 storage::remove_tree(&app.config.attachments_dir().join(repo.id.to_string()));
171 let sessions_dir = app.config.sessions_dir();
172 for id in sessions {
173 let transcript = storage::session_transcript_path(&sessions_dir, id);
174 if transcript.exists()
175 && let Err(e) = std::fs::remove_file(&transcript)
176 {
177 tracing::warn!("could not remove {}: {e}", transcript.display());
178 }
179 }
180
181 tracing::info!("deleted repository {}/{}", owner.username, repo.name);
182 Ok(())
183}
184
185/// Find a repository by its id.
186pub async fn find_by_id(db: &toasty::Db, id: i64) -> Result<Option<Repository>> {
187 let mut db = db.clone();
188 let repo = Repository::filter(Repository::fields().id().eq(id))
189 .first()
190 .exec(&mut db)
191 .await?;
192 Ok(repo)
193}
194
195/// Update a repository's description, visibility, and push-mirror URL.
196pub async fn update_settings(
197 db: &toasty::Db,
198 repo_id: i64,
199 description: &str,
200 is_private: bool,
201 mirror_url: &str,
202) -> Result<()> {
203 let mut conn = db.clone();
204 let Some(mut repo) = Repository::filter(Repository::fields().id().eq(repo_id))
205 .first()
206 .exec(&mut conn)
207 .await?
208 else {
209 return Err(Error::NotFound(format!("repository id {repo_id}")));
210 };
211 let mut conn = db.clone();
212 repo.update()
213 .description(description)
214 .is_private(is_private)
215 .mirror_url(mirror_url.trim())
216 .exec(&mut conn)
217 .await?;
218 Ok(())
219}
220
221/// Update a repository's detected languages and primary language.
222pub async fn update_languages(
223 db: &toasty::Db,
224 repo_id: i64,
225 primary_language: &str,
226 languages_json: &str,
227) -> Result<()> {
228 let mut conn = db.clone();
229 let Some(mut repo) = Repository::filter(Repository::fields().id().eq(repo_id))
230 .first()
231 .exec(&mut conn)
232 .await?
233 else {
234 return Err(Error::NotFound(format!("repository id {repo_id}")));
235 };
236 let mut conn = db.clone();
237 repo.update()
238 .primary_language(primary_language)
239 .languages_json(languages_json)
240 .exec(&mut conn)
241 .await?;
242 Ok(())
243}
244
245/// Update a repository's preview image hash.
246pub async fn update_preview_image(db: &toasty::Db, repo_id: i64, hash: &str) -> Result<()> {
247 let mut conn = db.clone();
248 let Some(mut repo) = Repository::filter(Repository::fields().id().eq(repo_id))
249 .first()
250 .exec(&mut conn)
251 .await?
252 else {
253 return Err(Error::NotFound(format!("repository id {repo_id}")));
254 };
255 let mut conn = db.clone();
256 repo.update()
257 .preview_image_hash(hash)
258 .exec(&mut conn)
259 .await?;
260 Ok(())
261}
262
263/// Find a repository by owner id and name.
264pub async fn find(db: &toasty::Db, owner_id: i64, name: &str) -> Result<Option<Repository>> {
265 let mut db = db.clone();
266 let repo = Repository::filter(Repository::fields().owner_id().eq(owner_id))
267 .filter(Repository::fields().name().eq(name))
268 .first()
269 .exec(&mut db)
270 .await?;
271 Ok(repo)
272}
273
274/// List all repositories owned by `owner_id`.
275pub async fn list_by_owner(db: &toasty::Db, owner_id: i64) -> Result<Vec<Repository>> {
276 let mut db = db.clone();
277 let repos = Repository::filter(Repository::fields().owner_id().eq(owner_id))
278 .exec(&mut db)
279 .await?;
280 Ok(repos)
281}
282
283/// List all repositories with their owner usernames, ordered by owner then name.
284pub async fn list_all_with_owner(db: &toasty::Db) -> Result<Vec<RepoWithOwner>> {
285 let mut conn = db.clone();
286 let users = User::all().exec(&mut conn).await?;
287 let owner_name: std::collections::HashMap<i64, String> =
288 users.into_iter().map(|u| (u.id, u.username)).collect();
289
290 let mut conn = db.clone();
291 let repos = Repository::all().exec(&mut conn).await?;
292 let mut out: Vec<RepoWithOwner> = repos
293 .into_iter()
294 .map(|r| RepoWithOwner {
295 owner: owner_name.get(&r.owner_id).cloned().unwrap_or_default(),
296 owner_id: r.owner_id,
297 name: r.name,
298 description: r.description,
299 is_private: r.is_private,
300 default_branch: r.default_branch,
301 primary_language: r.primary_language,
302 preview_image_hash: r.preview_image_hash,
303 })
304 .collect();
305 out.sort_by(|a, b| (&a.owner, &a.name).cmp(&(&b.owner, &b.name)));
306 Ok(out)
307}
308
309/// Reject names that are empty or contain path separators / traversal.
310fn validate_name(name: &str) -> Result<()> {
311 if name.is_empty() {
312 return Err(Error::Invalid("repository name must not be empty".into()));
313 }
314 if name.contains('/') || name.contains('\\') || name.contains("..") {
315 return Err(Error::Invalid(format!("invalid repository name: {name:?}")));
316 }
317 Ok(())
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 /// Push-to-create only fires for the namespace owner or an admin, and the
325 /// repos it creates are private.
326 #[tokio::test]
327 async fn create_on_push_policy() {
328 let dir = tempfile::tempdir().unwrap();
329 let db = crate::db::connect(dir.path().join("t.db")).await.unwrap();
330 let repos_dir = dir.path().join("repos");
331
332 let alice = crate::users::create(&db, "alice", "", "pw-alice-1", false)
333 .await
334 .unwrap();
335 let bob = crate::users::create(&db, "bob", "", "pw-bob-1", false)
336 .await
337 .unwrap();
338 let root = crate::users::create(&db, "root", "", "pw-root-1", true)
339 .await
340 .unwrap();
341
342 // A stranger can't create into someone else's namespace…
343 let denied = create_on_push(&db, &repos_dir, "alice", "proj", &bob)
344 .await
345 .unwrap();
346 assert!(denied.is_none());
347 // …nor into a namespace with no such user.
348 let nobody = create_on_push(&db, &repos_dir, "ghost", "proj", &bob)
349 .await
350 .unwrap();
351 assert!(nobody.is_none());
352
353 // The owner can, and gets a private repo on disk.
354 let repo = create_on_push(&db, &repos_dir, "alice", "proj", &alice)
355 .await
356 .unwrap()
357 .expect("owner may push-create");
358 assert!(repo.is_private);
359 assert!(storage::repo_path(&repos_dir, "alice", "proj").exists());
360
361 // An admin can create into any namespace.
362 let by_admin = create_on_push(&db, &repos_dir, "bob", "tool", &root)
363 .await
364 .unwrap();
365 assert!(by_admin.is_some());
366
367 // Re-creating an existing repo is an error (callers never reach this:
368 // they only call after a failed lookup).
369 assert!(
370 create_on_push(&db, &repos_dir, "alice", "proj", &alice)
371 .await
372 .is_err()
373 );
374 }
375
376 /// An app on a temp data dir, plus one user.
377 async fn app_with_user(dir: &Path) -> (crate::App, User) {
378 let config = crate::Config {
379 data_dir: dir.to_path_buf(),
380 ..Default::default()
381 };
382 let app = crate::App::bootstrap(config).await.unwrap();
383 let user = crate::users::create(&app.db, "alice", "", "pw-alice-1", false)
384 .await
385 .unwrap();
386 (app, user)
387 }
388
389 /// Deleting a repository takes every row that hangs off it and its bytes on
390 /// disk, and frees the name for re-use.
391 #[tokio::test]
392 async fn delete_cascades_and_frees_the_name() {
393 let dir = tempfile::tempdir().unwrap();
394 let (app, alice) = app_with_user(dir.path()).await;
395 let repos_dir = app.config.repositories_dir();
396 let repo = create(&app.db, &repos_dir, &alice, "proj", "a repo", false)
397 .await
398 .unwrap();
399 let other = create(&app.db, &repos_dir, &alice, "keep", "", false)
400 .await
401 .unwrap();
402
403 // Hang one of everything off the repository, on disk as well as in the
404 // database, so the cascade has something to miss.
405 let run = crate::ci::enqueue(&app.db, repo.id, "c0ffee", "main")
406 .await
407 .unwrap();
408 crate::ci::add_artifact(
409 &app.db, run.id, repo.id, "c0ffee", "site", 3, true, true, "{}",
410 )
411 .await
412 .unwrap();
413 let mut issue = crate::issues::create(&app.db, repo.id, alice.id, "a bug", "")
414 .await
415 .unwrap();
416 crate::issues::comment(&app.db, &mut issue, alice.id, "me too")
417 .await
418 .unwrap();
419 crate::attachments::add(&app.db, repo.id, "abc123", "image/png", 3, alice.id)
420 .await
421 .unwrap();
422 let session = crate::agent::create(
423 &app.db,
424 repo.id,
425 alice.id,
426 crate::agent::kind::INTERACTIVE,
427 "main",
428 "c0ffee",
429 "",
430 "img",
431 "",
432 )
433 .await
434 .unwrap();
435 crate::agent::finish(&app.db, session.id, crate::agent::status::EXITED, 0, "")
436 .await
437 .unwrap();
438
439 let artifact_dir = storage::artifact_commit_dir(&app.config.artifacts_dir(), repo.id, "c0");
440 std::fs::create_dir_all(&artifact_dir).unwrap();
441 std::fs::write(artifact_dir.join("index.html"), "hi").unwrap();
442 let attachment = storage::attachment_path(&app.config.attachments_dir(), repo.id, "abc123");
443 std::fs::create_dir_all(attachment.parent().unwrap()).unwrap();
444 std::fs::write(&attachment, "png").unwrap();
445 let transcript = storage::session_transcript_path(&app.config.sessions_dir(), session.id);
446 std::fs::create_dir_all(app.config.sessions_dir()).unwrap();
447 std::fs::write(&transcript, "$ ").unwrap();
448
449 delete(&app, &alice, &repo).await.unwrap();
450
451 assert!(find(&app.db, alice.id, "proj").await.unwrap().is_none());
452 assert!(!storage::repo_path(&repos_dir, "alice", "proj").exists());
453 assert!(
454 crate::ci::list_by_repo(&app.db, repo.id, 10)
455 .await
456 .unwrap()
457 .is_empty()
458 );
459 assert!(
460 crate::ci::artifacts_for_repo(&app.db, repo.id)
461 .await
462 .unwrap()
463 .is_empty()
464 );
465 assert!(
466 crate::issues::list(&app.db, repo.id, crate::issues::state::OPEN)
467 .await
468 .unwrap()
469 .is_empty()
470 );
471 assert!(
472 crate::issues::comments(&app.db, issue.id)
473 .await
474 .unwrap()
475 .is_empty()
476 );
477 assert!(
478 crate::attachments::find(&app.db, repo.id, "abc123")
479 .await
480 .unwrap()
481 .is_none()
482 );
483 assert!(
484 crate::agent::list_by_repo(&app.db, repo.id, 10)
485 .await
486 .unwrap()
487 .is_empty()
488 );
489 assert!(!artifact_dir.exists());
490 assert!(!attachment.exists());
491 assert!(!transcript.exists());
492
493 // The neighbouring repository is untouched, and the freed name can be
494 // used again — the on-disk directory really is gone, not just orphaned.
495 assert!(find(&app.db, alice.id, "keep").await.unwrap().is_some());
496 assert!(storage::repo_path(&repos_dir, "alice", &other.name).exists());
497 create(&app.db, &repos_dir, &alice, "proj", "", true)
498 .await
499 .unwrap();
500 }
501
502 /// A repository with a session that still claims a container is not
503 /// deletable: the containers would outlive their workspace.
504 #[tokio::test]
505 async fn delete_refuses_while_an_agent_session_is_live() {
506 let dir = tempfile::tempdir().unwrap();
507 let (app, alice) = app_with_user(dir.path()).await;
508 let repo = create(
509 &app.db,
510 &app.config.repositories_dir(),
511 &alice,
512 "proj",
513 "",
514 false,
515 )
516 .await
517 .unwrap();
518 let session = crate::agent::create(
519 &app.db,
520 repo.id,
521 alice.id,
522 crate::agent::kind::INTERACTIVE,
523 "main",
524 "c0ffee",
525 "",
526 "img",
527 "",
528 )
529 .await
530 .unwrap();
531
532 let err = delete(&app, &alice, &repo).await.unwrap_err().to_string();
533 assert!(err.contains(&format!("#{}", session.id)), "{err}");
534 assert!(find(&app.db, alice.id, "proj").await.unwrap().is_some());
535 assert!(storage::repo_path(&app.config.repositories_dir(), "alice", "proj").exists());
536
537 // Once it is closed out, the delete goes through.
538 crate::agent::finish(&app.db, session.id, crate::agent::status::REAPED, 0, "")
539 .await
540 .unwrap();
541 delete(&app, &alice, &repo).await.unwrap();
542 assert!(find(&app.db, alice.id, "proj").await.unwrap().is_none());
543 }
544}