Skip to main content

code_system_graph_core/
registry.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use code_system_graph_model::{
6    CheckoutId, NativePath, NativePathEncoding, RepoId, RepositoryRecord, WorkspaceId, WorkspaceRecord, stable_id, stable_id_bytes
7};
8use thiserror::Error;
9use url::Url;
10
11use crate::WorkspaceManifest;
12
13/// Validated workspace record plus native paths used by scanners.
14#[derive(Debug, Clone)]
15pub struct RegisteredWorkspace {
16    /// Serializable registry record suitable for persistence.
17    pub record: WorkspaceRecord,
18    checkout_paths: BTreeMap<String, PathBuf>,
19}
20
21impl RegisteredWorkspace {
22    /// Returns the canonical checkout path for a registered alias.
23    #[must_use]
24    pub fn checkout_path(&self, alias: &str) -> Option<&Path> {
25        self.checkout_paths.get(alias).map(PathBuf::as_path)
26    }
27}
28
29/// Error returned while resolving a workspace registry.
30#[derive(Debug, Error)]
31pub enum RegistryError {
32    /// The manifest path has no parent directory.
33    #[error("workspace manifest path `{0}` has no parent directory")]
34    ManifestParentMissing(PathBuf),
35    /// A configured path could not be canonicalized.
36    #[error("failed to canonicalize `{path}`: {source}")]
37    Canonicalize {
38        /// Path supplied or derived from the manifest.
39        path: PathBuf,
40        /// Underlying filesystem error.
41        source: std::io::Error,
42    },
43    /// A repository resolves outside every configured root.
44    #[error(
45        "repository `{alias}` resolves outside allowed roots: `{path}`; \
46         add an explicit `allowedRoots` entry"
47    )]
48    OutsideAllowedRoots {
49        /// Repository alias.
50        alias: String,
51        /// Canonical path rejected by policy.
52        path: PathBuf,
53    },
54}
55
56/// Resolves canonical repository and linked-worktree identities without modifying Git state.
57///
58/// The manifest directory is always an allowed root. Additional roots must be declared through
59/// `allowedRoots`. Repository IDs prefer a credential-free normalized remote and otherwise use
60/// the Git common directory or canonical checkout path. Checkout IDs always include the concrete
61/// worktree path.
62///
63/// # Errors
64///
65/// Returns [`RegistryError`] when configured paths do not exist, cannot be canonicalized, or
66/// escape the root allowlist.
67pub fn register_workspace(
68    config_path: &Path,
69    manifest_source: &str,
70    manifest: &WorkspaceManifest,
71) -> Result<RegisteredWorkspace, RegistryError> {
72    let canonical_config = canonicalize(config_path)?;
73    let config_directory = canonical_config
74        .parent()
75        .ok_or_else(|| RegistryError::ManifestParentMissing(config_path.to_path_buf()))?;
76    let config_directory = config_directory.to_path_buf();
77    let mut allowed_roots = vec![config_directory.clone()];
78    for configured_root in &manifest.allowed_roots {
79        let root = resolve_path(&config_directory, Path::new(configured_root));
80        allowed_roots.push(canonicalize(&root)?);
81    }
82    allowed_roots.sort();
83    allowed_roots.dedup();
84
85    let mut records = Vec::with_capacity(manifest.repos.len());
86    let mut checkout_paths = BTreeMap::new();
87    for (alias, repository) in &manifest.repos {
88        let configured_path = resolve_path(&config_directory, Path::new(&repository.path));
89        let configured_path = canonicalize(&configured_path)?;
90        let git_root = configured_path
91            .join(".git")
92            .exists()
93            .then(|| git_path(&configured_path, &["rev-parse", "--show-toplevel"]))
94            .flatten()
95            .and_then(|path| canonicalize(&path).ok())
96            .filter(|path| path == &configured_path);
97        let is_git_repository = git_root.is_some();
98        let checkout_path = git_root.unwrap_or(configured_path);
99        ensure_allowed(alias, &checkout_path, &allowed_roots)?;
100
101        let git_common_dir = is_git_repository
102            .then(|| git_common_directory(&checkout_path))
103            .flatten();
104        if let Some(common_dir) = &git_common_dir {
105            ensure_allowed(alias, common_dir, &allowed_roots)?;
106        }
107        let normalized_remote = is_git_repository
108            .then(|| git_text(&checkout_path, &["remote", "get-url", "origin"]))
109            .flatten()
110            .map(|remote| normalize_remote(&remote));
111        let head_commit = is_git_repository
112            .then(|| git_text(&checkout_path, &["rev-parse", "HEAD"]))
113            .flatten();
114        let working_tree_dirty = is_git_repository
115            && git_output(
116                &checkout_path,
117                &["status", "--porcelain", "--untracked-files=normal"],
118            )
119            .is_some_and(|output| !output.is_empty());
120        let native_checkout = encode_native_path(&checkout_path);
121        let native_common = git_common_dir.as_deref().map(encode_native_path);
122        let repository_key = normalized_remote.as_ref().map_or_else(
123            || {
124                native_common.as_ref().map_or_else(
125                    || format!("path:{}", native_path_fingerprint(&native_checkout)),
126                    |common| format!("git:{}", native_path_fingerprint(common)),
127                )
128            },
129            |remote| format!("remote:{remote}"),
130        );
131        let repo_id = RepoId::new(stable_id("repo", &repository_key));
132        let checkout_id = CheckoutId::new(stable_id(
133            "checkout",
134            &format!(
135                "{}:{}",
136                repo_id.as_str(),
137                native_path_fingerprint(&native_checkout)
138            ),
139        ));
140        let is_linked_worktree = checkout_path.join(".git").is_file();
141
142        records.push(RepositoryRecord {
143            id: repo_id,
144            checkout_id,
145            alias: alias.clone(),
146            canonical_path: native_checkout,
147            git_common_dir: native_common,
148            normalized_remote,
149            head_commit,
150            is_linked_worktree,
151            working_tree_dirty,
152        });
153        checkout_paths.insert(alias.clone(), checkout_path);
154    }
155    records.sort_by(|left, right| left.alias.cmp(&right.alias));
156
157    let config_native = encode_native_path(&config_directory);
158    let workspace_id = WorkspaceId::new(stable_id(
159        "workspace",
160        &format!(
161            "{}:{}",
162            manifest.name,
163            native_path_fingerprint(&config_native)
164        ),
165    ));
166    Ok(RegisteredWorkspace {
167        record: WorkspaceRecord {
168            id: workspace_id,
169            name: manifest.name.clone(),
170            manifest_hash: stable_id("manifest", manifest_source),
171            config_path: Some(encode_native_path(&canonical_config)),
172            repositories: records,
173        },
174        checkout_paths,
175    })
176}
177
178/// Encodes a native path without requiring UTF-8.
179#[must_use]
180pub fn encode_native_path(path: &Path) -> NativePath {
181    NativePath {
182        encoding: native_path_encoding(),
183        bytes: native_path_bytes(path),
184        display: path.to_string_lossy().into_owned(),
185    }
186}
187
188fn canonicalize(path: &Path) -> Result<PathBuf, RegistryError> {
189    std::fs::canonicalize(path).map_err(|source| RegistryError::Canonicalize {
190        path: path.to_path_buf(),
191        source,
192    })
193}
194
195fn ensure_allowed(
196    alias: &str,
197    path: &Path,
198    allowed_roots: &[PathBuf],
199) -> Result<(), RegistryError> {
200    if allowed_roots.iter().any(|root| path.starts_with(root)) {
201        return Ok(());
202    }
203    Err(RegistryError::OutsideAllowedRoots {
204        alias: alias.to_owned(),
205        path: path.to_path_buf(),
206    })
207}
208
209fn resolve_path(base: &Path, path: &Path) -> PathBuf {
210    if path.is_absolute() {
211        path.to_path_buf()
212    } else {
213        base.join(path)
214    }
215}
216
217fn git_common_directory(checkout_path: &Path) -> Option<PathBuf> {
218    git_path(
219        checkout_path,
220        &["rev-parse", "--path-format=absolute", "--git-common-dir"],
221    )
222    .or_else(|| {
223        git_path(checkout_path, &["rev-parse", "--git-common-dir"])
224            .map(|path| resolve_path(checkout_path, &path))
225    })
226    .and_then(|path| std::fs::canonicalize(path).ok())
227}
228
229fn git_path(checkout_path: &Path, arguments: &[&str]) -> Option<PathBuf> {
230    git_output(checkout_path, arguments).map(path_from_git_output)
231}
232
233fn git_text(checkout_path: &Path, arguments: &[&str]) -> Option<String> {
234    git_output(checkout_path, arguments)
235        .map(|bytes| String::from_utf8_lossy(&bytes).trim().to_owned())
236        .filter(|value| !value.is_empty())
237}
238
239fn git_output(checkout_path: &Path, arguments: &[&str]) -> Option<Vec<u8>> {
240    let output = Command::new("git")
241        .arg("-C")
242        .arg(checkout_path)
243        .args(arguments)
244        .env("GIT_OPTIONAL_LOCKS", "0")
245        .output()
246        .ok()?;
247    output.status.success().then_some(trim_ascii(output.stdout))
248}
249
250fn trim_ascii(mut bytes: Vec<u8>) -> Vec<u8> {
251    while bytes.last().is_some_and(u8::is_ascii_whitespace) {
252        bytes.pop();
253    }
254    bytes
255}
256
257fn normalize_remote(remote: &str) -> String {
258    let trimmed = remote.trim();
259    if let Some((authority, path)) = scp_remote_parts(trimmed) {
260        return format!(
261            "{}/{}",
262            authority.to_ascii_lowercase(),
263            clean_remote_path(path)
264        );
265    }
266    if let Ok(url) = Url::parse(trimmed) {
267        if let Some(host) = url.host_str() {
268            return format!(
269                "{}/{}",
270                host.to_ascii_lowercase(),
271                clean_remote_path(url.path())
272            );
273        }
274        return format!("{}:{}", url.scheme(), clean_remote_path(url.path()));
275    }
276    clean_remote_path(trimmed)
277}
278
279fn scp_remote_parts(remote: &str) -> Option<(&str, &str)> {
280    if remote.contains("://") {
281        return None;
282    }
283    let (authority, path) = remote.split_once(':')?;
284    let host = authority
285        .rsplit_once('@')
286        .map_or(authority, |(_, host)| host);
287    (!host.is_empty() && !path.is_empty()).then_some((host, path))
288}
289
290fn clean_remote_path(path: &str) -> String {
291    path.trim()
292        .trim_matches('/')
293        .trim_end_matches(".git")
294        .replace('\\', "/")
295}
296
297fn native_path_fingerprint(path: &NativePath) -> String {
298    let namespace = match path.encoding {
299        NativePathEncoding::UnixBytes => "path-unix",
300        NativePathEncoding::WindowsWide => "path-windows",
301        NativePathEncoding::Utf8 => "path-utf8",
302    };
303    stable_id_bytes(namespace, &path.bytes)
304}
305
306#[cfg(unix)]
307fn native_path_encoding() -> NativePathEncoding {
308    NativePathEncoding::UnixBytes
309}
310
311#[cfg(windows)]
312fn native_path_encoding() -> NativePathEncoding {
313    NativePathEncoding::WindowsWide
314}
315
316#[cfg(not(any(unix, windows)))]
317fn native_path_encoding() -> NativePathEncoding {
318    NativePathEncoding::Utf8
319}
320
321#[cfg(unix)]
322fn native_path_bytes(path: &Path) -> Vec<u8> {
323    use std::os::unix::ffi::OsStrExt;
324
325    path.as_os_str().as_bytes().to_vec()
326}
327
328#[cfg(windows)]
329fn native_path_bytes(path: &Path) -> Vec<u8> {
330    use std::os::windows::ffi::OsStrExt;
331
332    path.as_os_str()
333        .encode_wide()
334        .flat_map(u16::to_le_bytes)
335        .collect()
336}
337
338#[cfg(not(any(unix, windows)))]
339fn native_path_bytes(path: &Path) -> Vec<u8> {
340    path.to_string_lossy().as_bytes().to_vec()
341}
342
343#[cfg(unix)]
344fn path_from_git_output(bytes: Vec<u8>) -> PathBuf {
345    use std::os::unix::ffi::OsStringExt;
346
347    PathBuf::from(std::ffi::OsString::from_vec(bytes))
348}
349
350#[cfg(not(unix))]
351fn path_from_git_output(bytes: Vec<u8>) -> PathBuf {
352    PathBuf::from(String::from_utf8_lossy(&bytes).into_owned())
353}
354
355#[cfg(test)]
356mod tests {
357    use std::fs;
358    use std::path::Path;
359    use std::process::Command;
360
361    use super::{normalize_remote, register_workspace};
362    use crate::parse_manifest;
363
364    #[test]
365    fn normalize_remote_should_remove_credentials_protocol_and_git_suffix() {
366        let https = normalize_remote("https://token@example.com/team/api.git");
367        let ssh = normalize_remote("git@example.com:team/api.git");
368
369        assert_eq!(https, ssh);
370    }
371
372    #[cfg(unix)]
373    #[test]
374    fn encode_native_path_should_preserve_non_utf8_bytes() {
375        use std::ffi::OsString;
376        use std::os::unix::ffi::OsStringExt;
377
378        let path =
379            std::path::PathBuf::from(OsString::from_vec(vec![b'/', b't', b'm', b'p', b'/', 0xff]));
380        let encoded = super::encode_native_path(&path);
381
382        assert_eq!(encoded.bytes, vec![b'/', b't', b'm', b'p', b'/', 0xff]);
383    }
384
385    #[test]
386    fn register_workspace_should_reject_symlink_escape() -> Result<(), Box<dyn std::error::Error>> {
387        let temporary = tempfile::tempdir()?;
388        let workspace = temporary.path().join("workspace");
389        let outside = temporary.path().join("outside");
390        fs::create_dir_all(&workspace)?;
391        fs::create_dir_all(&outside)?;
392        #[cfg(unix)]
393        std::os::unix::fs::symlink(&outside, workspace.join("escaped"))?;
394        #[cfg(windows)]
395        std::os::windows::fs::symlink_dir(&outside, workspace.join("escaped"))?;
396        let manifest_path = workspace.join("code-system-graph.yaml");
397        fs::write(&manifest_path, "")?;
398        let source = "version: 1\nname: test\nrepos:\n  escaped:\n    path: escaped\n";
399        let manifest = parse_manifest(source)?;
400        let result = register_workspace(&manifest_path, source, &manifest);
401
402        assert!(matches!(
403            result,
404            Err(super::RegistryError::OutsideAllowedRoots { .. })
405        ));
406        Ok(())
407    }
408
409    #[test]
410    fn register_workspace_should_share_repo_id_across_linked_worktrees()
411    -> Result<(), Box<dyn std::error::Error>> {
412        let temporary = tempfile::tempdir()?;
413        let repository = temporary.path().join("repository");
414        let linked = temporary.path().join("linked");
415        fs::create_dir_all(&repository)?;
416        git(&repository, &["init"])?;
417        fs::write(repository.join("README.md"), "fixture")?;
418        git(&repository, &["add", "README.md"])?;
419        git(
420            &repository,
421            &[
422                "-c",
423                "user.name=Code System Graph Test",
424                "-c",
425                "user.email=code-system-graph@example.invalid",
426                "commit",
427                "-m",
428                "fixture",
429            ],
430        )?;
431        git(
432            &repository,
433            &[
434                "worktree",
435                "add",
436                "-b",
437                "linked-fixture",
438                linked.to_string_lossy().as_ref(),
439            ],
440        )?;
441        let manifest_path = temporary.path().join("code-system-graph.yaml");
442        let source = "version: 1\nname: test\nrepos:\n  main:\n    path: repository\n  linked:\n    path: linked\n";
443        fs::write(&manifest_path, source)?;
444        let manifest = parse_manifest(source)?;
445        let registry = register_workspace(&manifest_path, source, &manifest)?;
446        let main = registry
447            .record
448            .repositories
449            .iter()
450            .find(|record| record.alias == "main")
451            .ok_or_else(|| std::io::Error::other("main record missing"))?;
452        let worktree = registry
453            .record
454            .repositories
455            .iter()
456            .find(|record| record.alias == "linked")
457            .ok_or_else(|| std::io::Error::other("linked record missing"))?;
458
459        assert_eq!(
460            (
461                main.id.clone(),
462                main.checkout_id == worktree.checkout_id,
463                worktree.id.clone(),
464                worktree.is_linked_worktree,
465            ),
466            (worktree.id.clone(), false, main.id.clone(), true)
467        );
468        Ok(())
469    }
470
471    fn git(repository: &Path, arguments: &[&str]) -> Result<(), Box<dyn std::error::Error>> {
472        let output = Command::new("git")
473            .arg("-C")
474            .arg(repository)
475            .args(arguments)
476            .output()?;
477        if output.status.success() {
478            return Ok(());
479        }
480        Err(std::io::Error::other(String::from_utf8_lossy(&output.stderr)).into())
481    }
482}