anvilsign in

collin/anvil

main / vendor / gitserver-core / src / dynamic_registry.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Copyright (c) 2026 WJQSERVER
6
7use std::{
8 path::{
9 Path,
10 PathBuf,
11 },
12 sync::{
13 Arc,
14 RwLock,
15 },
16};
17
18use crate::{
19 discovery::{
20 RepoInfo,
21 RepoStore,
22 },
23 error::{
24 Error,
25 Result,
26 },
27};
28
29pub trait RepoResolver: Send + Sync {
30 fn resolve(&self, relative: &str) -> Result<RepoInfo>;
31 fn list(&self) -> Result<Vec<RepoInfo>>;
32}
33
34pub trait MutableRepoRegistry: RepoResolver {
35 fn register(&self, repo: RepoInfo) -> Result<()>;
36 fn unregister(&self, relative: &str) -> Result<()>;
37}
38
39#[derive(Clone, Default)]
40pub struct DynamicRepoRegistry {
41 repos: Arc<RwLock<Vec<RepoInfo>>>,
42}
43
44impl DynamicRepoRegistry {
45 pub fn new() -> Self {
46 Self::default()
47 }
48
49 pub fn from_repos(repos: Vec<RepoInfo>) -> Result<Self> {
50 let registry = Self::new();
51 for repo in repos {
52 registry.register(repo)?;
53 }
54 Ok(registry)
55 }
56}
57
58impl RepoResolver for RepoStore {
59 fn resolve(&self, relative: &str) -> Result<RepoInfo> {
60 RepoStore::resolve(self, relative).cloned()
61 }
62
63 fn list(&self) -> Result<Vec<RepoInfo>> {
64 Ok(RepoStore::list(self).to_vec())
65 }
66}
67
68impl RepoResolver for DynamicRepoRegistry {
69 fn resolve(&self, relative: &str) -> Result<RepoInfo> {
70 let normalized = normalize_relative_repo_path(relative)?;
71 self.repos
72 .read()
73 .expect("dynamic repo registry poisoned")
74 .iter()
75 .find(|repo| repo.relative_path == normalized)
76 .cloned()
77 .ok_or_else(|| Error::RepoNotFound(relative.to_string()))
78 }
79
80 fn list(&self) -> Result<Vec<RepoInfo>> {
81 Ok(self
82 .repos
83 .read()
84 .expect("dynamic repo registry poisoned")
85 .clone())
86 }
87}
88
89impl MutableRepoRegistry for DynamicRepoRegistry {
90 fn register(&self, repo: RepoInfo) -> Result<()> {
91 let relative_path = normalize_relative_repo_path(&repo.relative_path)?;
92 let repo_path = repo.absolute_path.canonicalize()?;
93 let opened = gix::open(&repo_path)?;
94 if !opened.is_bare() {
95 return Err(Error::Protocol(format!(
96 "registered path is not a bare repository: {}",
97 repo_path.display()
98 )));
99 }
100
101 let mut repos = self.repos.write().expect("dynamic repo registry poisoned");
102 if repos
103 .iter()
104 .any(|existing| existing.relative_path == relative_path)
105 {
106 return Err(Error::Protocol(format!(
107 "repository already registered: {}",
108 relative_path
109 )));
110 }
111
112 let mut repo = repo;
113 repo.relative_path = relative_path;
114 repo.absolute_path = repo_path;
115 repos.push(repo);
116 repos.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
117 Ok(())
118 }
119
120 fn unregister(&self, relative: &str) -> Result<()> {
121 let normalized = normalize_relative_repo_path(relative)?;
122 let mut repos = self.repos.write().expect("dynamic repo registry poisoned");
123 let original_len = repos.len();
124 repos.retain(|repo| repo.relative_path != normalized);
125 if repos.len() == original_len {
126 return Err(Error::RepoNotFound(relative.to_string()));
127 }
128 Ok(())
129 }
130}
131
132fn normalize_relative_repo_path(relative: &str) -> Result<String> {
133 let path = Path::new(relative);
134 if path.is_absolute() {
135 return Err(Error::PathTraversal(relative.to_string().into()));
136 }
137
138 let mut normalized = PathBuf::new();
139 for component in path.components() {
140 match component {
141 std::path::Component::Normal(part) => normalized.push(part),
142 std::path::Component::CurDir => {}
143 _ => return Err(Error::PathTraversal(relative.to_string().into())),
144 }
145 }
146
147 if normalized.as_os_str().is_empty() {
148 return Err(Error::RepoNotFound(relative.to_string()));
149 }
150
151 Ok(normalized.to_string_lossy().into_owned())
152}
153
154#[cfg(test)]
155mod tests {
156 use std::{
157 path::Path,
158 process::Command,
159 };
160
161 use tempfile::TempDir;
162
163 use super::*;
164
165 fn create_bare_repo(path: &Path) {
166 Command::new("git")
167 .args(["init", "--bare", path.to_str().unwrap()])
168 .output()
169 .expect("git init --bare failed");
170 }
171
172 #[test]
173 fn dynamic_registry_registers_and_unregisters() {
174 let dir = TempDir::new().unwrap();
175 let repo_path = dir.path().join("alpha.git");
176 create_bare_repo(&repo_path);
177
178 let registry = DynamicRepoRegistry::new();
179 registry
180 .register(RepoInfo {
181 name: "alpha.git".into(),
182 relative_path: "alpha.git".into(),
183 absolute_path: repo_path.clone(),
184 description: None,
185 })
186 .unwrap();
187
188 assert_eq!(registry.list().unwrap().len(), 1);
189 assert_eq!(
190 registry.resolve("alpha.git").unwrap().absolute_path,
191 repo_path.canonicalize().unwrap()
192 );
193
194 registry.unregister("alpha.git").unwrap();
195 assert!(matches!(
196 registry.resolve("alpha.git"),
197 Err(Error::RepoNotFound(_))
198 ));
199 }
200
201 #[test]
202 fn dynamic_registry_resolve_and_unregister_normalize_paths() {
203 let dir = TempDir::new().unwrap();
204 let repo_path = dir.path().join("alpha.git");
205 create_bare_repo(&repo_path);
206
207 let registry = DynamicRepoRegistry::new();
208 registry
209 .register(RepoInfo {
210 name: "alpha.git".into(),
211 relative_path: "alpha.git".into(),
212 absolute_path: repo_path,
213 description: None,
214 })
215 .unwrap();
216
217 assert!(registry.resolve("./alpha.git").is_ok());
218 registry.unregister("./alpha.git").unwrap();
219 assert!(matches!(
220 registry.resolve("alpha.git"),
221 Err(Error::RepoNotFound(_))
222 ));
223 }
224
225 #[test]
226 fn dynamic_registry_rejects_duplicate_registration() {
227 let dir = TempDir::new().unwrap();
228 let repo_path = dir.path().join("alpha.git");
229 create_bare_repo(&repo_path);
230
231 let registry = DynamicRepoRegistry::new();
232 let repo = RepoInfo {
233 name: "alpha.git".into(),
234 relative_path: "alpha.git".into(),
235 absolute_path: repo_path,
236 description: None,
237 };
238
239 registry.register(repo.clone()).unwrap();
240 let err = registry.register(repo).unwrap_err();
241 assert!(matches!(err, Error::Protocol(_)));
242 }
243
244 #[test]
245 fn dynamic_registry_rejects_parent_relative_paths() {
246 let dir = TempDir::new().unwrap();
247 let repo_path = dir.path().join("alpha.git");
248 create_bare_repo(&repo_path);
249
250 let registry = DynamicRepoRegistry::new();
251 let err = registry
252 .register(RepoInfo {
253 name: "alpha.git".into(),
254 relative_path: "./team/../alpha.git".into(),
255 absolute_path: repo_path,
256 description: None,
257 })
258 .unwrap_err();
259
260 assert!(matches!(err, Error::PathTraversal(_)));
261 }
262
263 #[test]
264 fn dynamic_registry_rejects_absolute_relative_path() {
265 let dir = TempDir::new().unwrap();
266 let repo_path = dir.path().join("alpha.git");
267 create_bare_repo(&repo_path);
268
269 let registry = DynamicRepoRegistry::new();
270 let err = registry
271 .register(RepoInfo {
272 name: "alpha.git".into(),
273 relative_path: "/tmp/alpha.git".into(),
274 absolute_path: repo_path,
275 description: None,
276 })
277 .unwrap_err();
278
279 assert!(matches!(err, Error::PathTraversal(_)));
280 }
281}