zuka
zuka/src/core/repo.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/core/repo.rs
RSrepo.rs9.1 KBDownload
1// Repository lifecycle, shared by every facade.
2//
3// This module exists because REST and MCP each had their own copy of create and
4// delete, and they had already drifted: the MCP copy had no rollback (so a failed
5// `git init` wedged the name with a permanent `Creating` record), did not support
6// protected refs or import, and left run records behind on delete — which meant
7// re-creating a name resurrected the previous repository's CI history.
8//
9// Facades now translate their wire format into `CreateSpec` and call in here.
10
11use crate::account::token::now_secs;
12use crate::account::Identity;
13use crate::error::{Error, Result};
14use crate::git::validate::{self, Name};
15use crate::git::{self, exec};
16use crate::http::AppState;
17use crate::store::{RepoRecord, RepoState};
18use serde::Serialize;
19use std::sync::Arc;
20
21/// What a caller may specify when creating a repository.
22#[derive(Debug, Default)]
23pub struct CreateSpec {
24 pub name: String,
25 pub default_branch: Option<String>,
26 pub description: Option<String>,
27 pub protected_refs: Vec<String>,
28 pub import_url: Option<String>,
29}
30
31/// The wire representation of a repository. One struct, so REST and MCP cannot
32/// disagree about which fields exist.
33#[derive(Debug, Serialize)]
34pub struct RepoView {
35 pub account: String,
36 pub name: String,
37 pub display_name: String,
38 pub default_branch: String,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub description: Option<String>,
41 pub created_at: u64,
42 pub protected_refs: Vec<String>,
43 pub clone_url_http: String,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub clone_url_ssh: Option<String>,
46}
47
48impl RepoView {
49 /// Build a view. `protected_refs` costs a subprocess, so list endpoints pass
50 /// `None` rather than paying it per row.
51 pub fn of(record: RepoRecord, state: &AppState, protected_refs: Vec<String>) -> Self {
52 RepoView {
53 clone_url_http: format!(
54 "{}/{}/{}.git",
55 state.config.public_host(),
56 record.account,
57 record.name
58 ),
59 clone_url_ssh: state
60 .config
61 .public_ssh_base()
62 .map(|base| format!("{base}/{}/{}.git", record.account, record.name)),
63 account: record.account,
64 name: record.name,
65 display_name: record.display_name,
66 default_branch: record.default_branch,
67 description: record.description,
68 created_at: record.created_at,
69 protected_refs,
70 }
71 }
72
73 pub fn to_value(&self) -> serde_json::Value {
74 serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
75 }
76}
77
78/// Create a repository, optionally seeded from a remote.
79///
80/// A `Creating` marker is written before anything touches disk and flipped to
81/// `Ready` only once the repository is usable, so a crash leaves a state `fsck` can
82/// resolve rather than a name that is simultaneously 404 and 409.
83pub async fn create(
84 state: &Arc<AppState>,
85 identity: &Identity,
86 spec: CreateSpec,
87) -> Result<RepoView> {
88 let repo = validate::name(&spec.name)?;
89 identity.require_write(&identity.account, &repo)?;
90 state.ensure_space()?;
91 state.check_repo_quota(&identity.account)?;
92
93 let default_branch = spec
94 .default_branch
95 .unwrap_or_else(|| "refs/heads/main".to_string());
96 validate::ref_name(&default_branch)?;
97 if !default_branch.starts_with("refs/heads/") {
98 return Err(Error::invalid(
99 "invalid-ref",
100 "default_branch must be under refs/heads/",
101 ));
102 }
103 for name in &spec.protected_refs {
104 validate::ref_name(name)?;
105 }
106
107 let account = identity.account.clone();
108
109 match state.meta.get_repo(&account, &repo)? {
110 Some(existing) if existing.is_ready() => {
111 return Err(Error::conflict(
112 "repo-exists",
113 format!("{} already exists", existing.display_name),
114 ));
115 }
116 // A leftover marker is a previous crash. Reclaim rather than wedge the name.
117 Some(_) => eprintln!("[repo] reclaiming a half-created {account}/{repo}"),
118 None => {}
119 }
120
121 let mut record = RepoRecord {
122 account: account.as_str().to_string(),
123 name: repo.as_str().to_string(),
124 display_name: spec.name.clone(),
125 default_branch: default_branch.clone(),
126 description: spec.description,
127 created_at: now_secs(),
128 state: RepoState::Creating,
129 };
130 state.meta.put_repo(&record)?;
131
132 if let Err(e) = materialise(state, &account, &repo, &default_branch, spec.import_url).await {
133 // Any partial directory is moved aside, not left to be served as ready.
134 let _ = state.git.soft_delete(&account, &repo);
135 let _ = state.meta.delete_repo(&account, &repo);
136 return Err(e);
137 }
138
139 // An import brings its own HEAD, so the record must reflect what arrived.
140 if let Some(head) = state.git.head_ref(&account, &repo) {
141 record.default_branch = head;
142 }
143
144 if !spec.protected_refs.is_empty() {
145 let path = state.git.repo_path(&account, &repo)?;
146 let refs = spec.protected_refs.clone();
147 exec::blocking(move || git::store::set_protected_refs(&path, &refs)).await?;
148 }
149
150 record.state = RepoState::Ready;
151 if let Err(e) = state.meta.put_repo(&record) {
152 let _ = state.git.soft_delete(&account, &repo);
153 return Err(e);
154 }
155
156 let protected = spec.protected_refs;
157 Ok(RepoView::of(record, state, protected))
158}
159
160/// Put a repository on disk, either fresh or cloned from a remote.
161async fn materialise(
162 state: &Arc<AppState>,
163 account: &Name,
164 repo: &Name,
165 default_branch: &str,
166 import_url: Option<String>,
167) -> Result<()> {
168 if state.git.exists(account, repo)? {
169 return Ok(());
170 }
171
172 match import_url {
173 None => {
174 let store = state.git.clone();
175 let (a, r, branch) = (account.clone(), repo.clone(), default_branch.to_string());
176 exec::blocking(move || store.create(&a, &r, &branch).map(|_| ())).await
177 }
178 Some(url) => import(state, account, repo, &url).await,
179 }
180}
181
182/// Seed a new repository from a remote.
183///
184/// This is the only place the service fetches a caller-supplied URL, so it is the
185/// only place SSRF is possible: `git clone` would otherwise happily read the cloud
186/// metadata endpoint on our behalf.
187async fn import(state: &Arc<AppState>, account: &Name, repo: &Name, url: &str) -> Result<()> {
188 if !state.config.import_enabled {
189 return Err(Error::conflict(
190 "import-disabled",
191 "importing is not enabled on this server",
192 ));
193 }
194
195 let source = git::import::parse(url)?;
196 git::import::check_resolves_publicly(&source, state.config.import_allow_private)?;
197
198 let path = state.git.repo_path(account, repo)?;
199 let parent = path.parent().expect("repo path has a parent");
200 std::fs::create_dir_all(parent)
201 .map_err(|e| Error::Internal(anyhow::Error::from(e).context("create account dir")))?;
202
203 let timeout = state.config.import_timeout;
204 let max_bytes = state.config.max_pack_bytes;
205 let target = path.clone();
206 exec::blocking(move || git::import::fetch(&source, &target, timeout, max_bytes)).await?;
207
208 // An imported repository is untrusted input and must end up indistinguishable
209 // from one we created: same hooks, same receive limits, same maintenance.
210 state.git.adopt(account, repo)
211}
212
213/// Delete a repository and everything derived from it.
214///
215/// Disk first, then metadata, then runs: a crash between them leaves a state `fsck`
216/// reports, whereas the reverse leaves an unreachable repository and a 500.
217pub fn delete(state: &AppState, identity: &Identity, account: &Name, repo: &Name) -> Result<()> {
218 identity.require_repo_admin(account, repo)?;
219
220 if state.meta.get_ready_repo(account, repo)?.is_none() {
221 return Err(Error::NotFound("repository"));
222 }
223
224 state.git.soft_delete(account, repo)?;
225 state.meta.delete_repo(account, repo)?;
226 // Without this, re-creating the name resurrects the old repository's CI history.
227 state.runs.remove_repo(account, repo);
228 Ok(())
229}
230
231/// Read one repository, including the fields that cost a subprocess.
232pub fn get(state: &AppState, identity: &Identity, account: &Name, repo: &Name) -> Result<RepoView> {
233 let (record, path) = state.open_repo(identity, account, repo, crate::core::Access::Read)?;
234 Ok(RepoView::of(
235 record,
236 state,
237 git::store::protected_refs(&path),
238 ))
239}
240
241/// List an account's repositories.
242///
243/// `protected_refs` is omitted here rather than spawning `git config` once per row:
244/// a list projection should not cost a subprocess per item.
245pub fn list(state: &AppState, identity: &Identity) -> Result<Vec<RepoView>> {
246 Ok(state
247 .meta
248 .list_repos(&identity.account)?
249 .into_iter()
250 .filter(RepoRecord::is_ready)
251 .filter(
252 |r| match (validate::name(&r.account), validate::name(&r.name)) {
253 (Ok(a), Ok(n)) => identity.require_read(&a, &n).is_ok(),
254 _ => false,
255 },
256 )
257 .map(|r| RepoView::of(r, state, Vec::new()))
258 .collect())
259}