zuka
zuka/src/store/fs.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/store/fs.rs
RSfs.rs8.6 KBDownload
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
8use crate::error::{Error, Result};
9use crate::git::validate::Name;
10use crate::store::RepoRecord;
11use anyhow::Context;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone)]
15pub struct FsStore {
16 root: PathBuf,
17}
18
19impl 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.
113fn 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)]
136mod 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 }
151 }
152
153 fn store() -> (tempfile::TempDir, FsStore) {
154 let dir = tempfile::tempdir().unwrap();
155 let store = FsStore::open(dir.path()).unwrap();
156 (dir, store)
157 }
158
159 #[test]
160 fn round_trips_a_repo_record() {
161 let (_dir, store) = store();
162 let (alice, site) = (name("alice").unwrap(), name("site").unwrap());
163
164 assert!(store.get_repo(&alice, &site).unwrap().is_none());
165 store.put_repo(&record("alice", "site")).unwrap();
166 assert_eq!(store.get_repo(&alice, &site).unwrap().unwrap().name, "site");
167 }
168
169 #[test]
170 fn a_case_variant_addresses_the_same_record() {
171 let (_dir, store) = store();
172 store.put_repo(&record("alice", "Site")).unwrap();
173
174 let found = store
175 .get_repo(&name("Alice").unwrap(), &name("SITE").unwrap())
176 .unwrap();
177 assert!(
178 found.is_some(),
179 "a case variant must resolve to the same record, not a second one"
180 );
181 assert_eq!(
182 found.unwrap().display_name,
183 "Site",
184 "display name is preserved"
185 );
186 }
187
188 #[test]
189 fn a_creating_record_reads_as_absent_until_it_is_ready() {
190 let (_dir, store) = store();
191 let mut partial = record("alice", "site");
192 partial.state = RepoState::Creating;
193 store.put_repo(&partial).unwrap();
194
195 let (alice, site) = (name("alice").unwrap(), name("site").unwrap());
196 assert!(store.get_repo(&alice, &site).unwrap().is_some());
197 assert!(
198 store.get_ready_repo(&alice, &site).unwrap().is_none(),
199 "a half-created repo must not serve"
200 );
201 }
202
203 #[test]
204 fn delete_reports_whether_a_record_was_present() {
205 let (_dir, store) = store();
206 let (alice, site) = (name("alice").unwrap(), name("site").unwrap());
207 store.put_repo(&record("alice", "site")).unwrap();
208
209 assert!(store.delete_repo(&alice, &site).unwrap());
210 assert!(!store.delete_repo(&alice, &site).unwrap());
211 }
212
213 #[test]
214 fn lists_repos_for_one_account_only_and_in_name_order() {
215 let (_dir, store) = store();
216 store.put_repo(&record("alice", "zeta")).unwrap();
217 store.put_repo(&record("alice", "alpha")).unwrap();
218 store.put_repo(&record("bob", "other")).unwrap();
219
220 let names: Vec<String> = store
221 .list_repos(&name("alice").unwrap())
222 .unwrap()
223 .into_iter()
224 .map(|r| r.name)
225 .collect();
226 assert_eq!(names, vec!["alpha", "zeta"]);
227 }
228
229 #[test]
230 fn one_corrupt_record_does_not_fail_the_whole_listing() {
231 let (dir, store) = store();
232 store.put_repo(&record("alice", "good")).unwrap();
233 std::fs::write(dir.path().join("alice").join("bad.json"), b"{not json").unwrap();
234
235 let listed = store.list_repos(&name("alice").unwrap()).unwrap();
236 assert_eq!(
237 listed.len(),
238 1,
239 "a corrupt file must not make listing permanently fail"
240 );
241 }
242}