Skip to main content

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