anvilsign in

collin/anvil

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