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.7 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 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}