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