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#[derive(Debug, Clone)]
15pub struct RegisteredWorkspace {
16 pub record: WorkspaceRecord,
18 checkout_paths: BTreeMap<String, PathBuf>,
19}
20
21impl RegisteredWorkspace {
22 #[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#[derive(Debug, Error)]
31pub enum RegistryError {
32 #[error("workspace manifest path `{0}` has no parent directory")]
34 ManifestParentMissing(PathBuf),
35 #[error("failed to canonicalize `{path}`: {source}")]
37 Canonicalize {
38 path: PathBuf,
40 source: std::io::Error,
42 },
43 #[error(
45 "repository `{alias}` resolves outside allowed roots: `{path}`; \
46 add an explicit `allowedRoots` entry"
47 )]
48 OutsideAllowedRoots {
49 alias: String,
51 path: PathBuf,
53 },
54}
55
56pub 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#[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}