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