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
| 1 | // Local metadata store: one JSON file per repository under `$DATA_DIR/meta/`. |
| 2 | // |
| 3 | // Keys are canonical `Name`s, so the metadata namespace and the filesystem |
| 4 | // namespace agree on a case-insensitive disk. Writes go to a temp file, are |
| 5 | // fsynced, and are renamed into place — without the fsync the atomicity the |
| 6 | // rename buys is filesystem-dependent. |
| 7 | |
| 8 | use crate::error::{Error, Result}; |
| 9 | use crate::git::validate::Name; |
| 10 | use crate::store::RepoRecord; |
| 11 | use anyhow::Context; |
| 12 | use std::path::{Path, PathBuf}; |
| 13 | |
| 14 | #[derive(Debug, Clone)] |
| 15 | pub struct FsStore { |
| 16 | root: PathBuf, |
| 17 | } |
| 18 | |
| 19 | impl FsStore { |
| 20 | pub fn open(root: impl Into<PathBuf>) -> Result<Self> { |
| 21 | let root = root.into(); |
| 22 | std::fs::create_dir_all(&root) |
| 23 | .with_context(|| format!("create metadata dir {}", root.display()))?; |
| 24 | Ok(FsStore { root }) |
| 25 | } |
| 26 | |
| 27 | fn record_path(&self, account: &Name, repo: &Name) -> PathBuf { |
| 28 | self.root |
| 29 | .join(account.as_str()) |
| 30 | .join(format!("{}.json", repo.as_str())) |
| 31 | } |
| 32 | |
| 33 | pub fn get_repo(&self, account: &Name, repo: &Name) -> Result<Option<RepoRecord>> { |
| 34 | let path = self.record_path(account, repo); |
| 35 | match std::fs::read(&path) { |
| 36 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), |
| 37 | Err(e) => Err(Error::Internal( |
| 38 | anyhow::Error::from(e).context(format!("read {}", path.display())), |
| 39 | )), |
| 40 | Ok(bytes) => { |
| 41 | let record = serde_json::from_slice(&bytes) |
| 42 | .with_context(|| format!("parse {}", path.display()))?; |
| 43 | Ok(Some(record)) |
| 44 | } |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | /// Fetch a repository that is fully created. A `Creating` record reads as absent. |
| 49 | pub fn get_ready_repo(&self, account: &Name, repo: &Name) -> Result<Option<RepoRecord>> { |
| 50 | Ok(self.get_repo(account, repo)?.filter(RepoRecord::is_ready)) |
| 51 | } |
| 52 | |
| 53 | pub fn put_repo(&self, record: &RepoRecord) -> Result<()> { |
| 54 | // Through the validated builder, not a raw join: the record carries plain |
| 55 | // `String`s, so nothing in the type system stops one holding `../../etc`. |
| 56 | let path = self.record_path( |
| 57 | &crate::git::validate::name(&record.account)?, |
| 58 | &crate::git::validate::name(&record.name)?, |
| 59 | ); |
| 60 | let dir = path.parent().expect("record path always has a parent"); |
| 61 | std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; |
| 62 | |
| 63 | let body = serde_json::to_vec_pretty(record).context("serialize repo record")?; |
| 64 | write_atomic(&path, &body) |
| 65 | } |
| 66 | |
| 67 | pub fn delete_repo(&self, account: &Name, repo: &Name) -> Result<bool> { |
| 68 | let path = self.record_path(account, repo); |
| 69 | match std::fs::remove_file(&path) { |
| 70 | Ok(()) => Ok(true), |
| 71 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), |
| 72 | Err(e) => Err(Error::Internal( |
| 73 | anyhow::Error::from(e).context(format!("remove {}", path.display())), |
| 74 | )), |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | /// List an account's repositories. |
| 79 | /// |
| 80 | /// A record that fails to parse is skipped and logged rather than failing the |
| 81 | /// whole call: one corrupt file must not make `GET /v1/repos` permanently 500. |
| 82 | pub fn list_repos(&self, account: &Name) -> Result<Vec<RepoRecord>> { |
| 83 | let dir = self.root.join(account.as_str()); |
| 84 | let entries = match std::fs::read_dir(&dir) { |
| 85 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), |
| 86 | Err(e) => { |
| 87 | return Err(Error::Internal( |
| 88 | anyhow::Error::from(e).context(format!("read dir {}", dir.display())), |
| 89 | )) |
| 90 | } |
| 91 | Ok(entries) => entries, |
| 92 | }; |
| 93 | |
| 94 | let mut records = Vec::new(); |
| 95 | for entry in entries.flatten() { |
| 96 | let path = entry.path(); |
| 97 | if path.extension().and_then(|e| e.to_str()) != Some("json") { |
| 98 | continue; |
| 99 | } |
| 100 | match std::fs::read(&path).map(|b| serde_json::from_slice::<RepoRecord>(&b)) { |
| 101 | Ok(Ok(record)) => records.push(record), |
| 102 | Ok(Err(e)) => eprintln!("[meta] skipping unreadable {}: {e}", path.display()), |
| 103 | Err(e) => eprintln!("[meta] skipping unreadable {}: {e}", path.display()), |
| 104 | } |
| 105 | } |
| 106 | records.sort_by(|a, b| a.name.cmp(&b.name)); |
| 107 | Ok(records) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /// Write via temp file, fsync, rename — so a reader never sees a partial file and |
| 112 | /// the rename's atomicity actually holds after a crash. |
| 113 | fn write_atomic(path: &Path, body: &[u8]) -> Result<()> { |
| 114 | use std::io::Write; |
| 115 | |
| 116 | let temp = path.with_extension("json.tmp"); |
| 117 | { |
| 118 | let mut file = |
| 119 | std::fs::File::create(&temp).with_context(|| format!("create {}", temp.display()))?; |
| 120 | file.write_all(body) |
| 121 | .with_context(|| format!("write {}", temp.display()))?; |
| 122 | file.sync_all() |
| 123 | .with_context(|| format!("fsync {}", temp.display()))?; |
| 124 | } |
| 125 | std::fs::rename(&temp, path).with_context(|| format!("rename into {}", path.display()))?; |
| 126 | |
| 127 | if let Some(dir) = path.parent() { |
| 128 | if let Ok(handle) = std::fs::File::open(dir) { |
| 129 | let _ = handle.sync_all(); |
| 130 | } |
| 131 | } |
| 132 | Ok(()) |
| 133 | } |
| 134 | |
| 135 | #[cfg(test)] |
| 136 | mod tests { |
| 137 | use super::*; |
| 138 | use crate::git::validate::name; |
| 139 | use crate::store::RepoState; |
| 140 | |
| 141 | fn record(account: &str, repo: &str) -> RepoRecord { |
| 142 | RepoRecord { |
| 143 | account: name(account).unwrap().as_str().into(), |
| 144 | name: name(repo).unwrap().as_str().into(), |
| 145 | display_name: repo.into(), |
| 146 | default_branch: "refs/heads/main".into(), |
| 147 | description: None, |
| 148 | created_at: 0, |
| 149 | state: RepoState::Ready, |
| 150 | visibility: Default::default(), |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | fn store() -> (tempfile::TempDir, FsStore) { |
| 155 | let dir = tempfile::tempdir().unwrap(); |
| 156 | let store = FsStore::open(dir.path()).unwrap(); |
| 157 | (dir, store) |
| 158 | } |
| 159 | |
| 160 | #[test] |
| 161 | fn round_trips_a_repo_record() { |
| 162 | let (_dir, store) = store(); |
| 163 | let (alice, site) = (name("alice").unwrap(), name("site").unwrap()); |
| 164 | |
| 165 | assert!(store.get_repo(&alice, &site).unwrap().is_none()); |
| 166 | store.put_repo(&record("alice", "site")).unwrap(); |
| 167 | assert_eq!(store.get_repo(&alice, &site).unwrap().unwrap().name, "site"); |
| 168 | } |
| 169 | |
| 170 | #[test] |
| 171 | fn a_case_variant_addresses_the_same_record() { |
| 172 | let (_dir, store) = store(); |
| 173 | store.put_repo(&record("alice", "Site")).unwrap(); |
| 174 | |
| 175 | let found = store |
| 176 | .get_repo(&name("Alice").unwrap(), &name("SITE").unwrap()) |
| 177 | .unwrap(); |
| 178 | assert!( |
| 179 | found.is_some(), |
| 180 | "a case variant must resolve to the same record, not a second one" |
| 181 | ); |
| 182 | assert_eq!( |
| 183 | found.unwrap().display_name, |
| 184 | "Site", |
| 185 | "display name is preserved" |
| 186 | ); |
| 187 | } |
| 188 | |
| 189 | #[test] |
| 190 | fn a_creating_record_reads_as_absent_until_it_is_ready() { |
| 191 | let (_dir, store) = store(); |
| 192 | let mut partial = record("alice", "site"); |
| 193 | partial.state = RepoState::Creating; |
| 194 | store.put_repo(&partial).unwrap(); |
| 195 | |
| 196 | let (alice, site) = (name("alice").unwrap(), name("site").unwrap()); |
| 197 | assert!(store.get_repo(&alice, &site).unwrap().is_some()); |
| 198 | assert!( |
| 199 | store.get_ready_repo(&alice, &site).unwrap().is_none(), |
| 200 | "a half-created repo must not serve" |
| 201 | ); |
| 202 | } |
| 203 | |
| 204 | #[test] |
| 205 | fn delete_reports_whether_a_record_was_present() { |
| 206 | let (_dir, store) = store(); |
| 207 | let (alice, site) = (name("alice").unwrap(), name("site").unwrap()); |
| 208 | store.put_repo(&record("alice", "site")).unwrap(); |
| 209 | |
| 210 | assert!(store.delete_repo(&alice, &site).unwrap()); |
| 211 | assert!(!store.delete_repo(&alice, &site).unwrap()); |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn lists_repos_for_one_account_only_and_in_name_order() { |
| 216 | let (_dir, store) = store(); |
| 217 | store.put_repo(&record("alice", "zeta")).unwrap(); |
| 218 | store.put_repo(&record("alice", "alpha")).unwrap(); |
| 219 | store.put_repo(&record("bob", "other")).unwrap(); |
| 220 | |
| 221 | let names: Vec<String> = store |
| 222 | .list_repos(&name("alice").unwrap()) |
| 223 | .unwrap() |
| 224 | .into_iter() |
| 225 | .map(|r| r.name) |
| 226 | .collect(); |
| 227 | assert_eq!(names, vec!["alpha", "zeta"]); |
| 228 | } |
| 229 | |
| 230 | #[test] |
| 231 | fn one_corrupt_record_does_not_fail_the_whole_listing() { |
| 232 | let (dir, store) = store(); |
| 233 | store.put_repo(&record("alice", "good")).unwrap(); |
| 234 | std::fs::write(dir.path().join("alice").join("bad.json"), b"{not json").unwrap(); |
| 235 | |
| 236 | let listed = store.list_repos(&name("alice").unwrap()).unwrap(); |
| 237 | assert_eq!( |
| 238 | listed.len(), |
| 239 | 1, |
| 240 | "a corrupt file must not make listing permanently fail" |
| 241 | ); |
| 242 | } |
| 243 | } |