1use super::types::{RepoLocation, RepoMapping};
2use crate::config::validation::{
3 canonical_reference_instance_key, canonical_reference_key,
4 normalize_encoded_ref_key_for_identity,
5};
6use crate::git::ref_key::encode_ref_key;
7use crate::repo_identity::{
8 RepoIdentity, RepoIdentityKey, parse_url_and_subpath as identity_parse_url_and_subpath,
9};
10use crate::utils::locks::FileLock;
11use crate::utils::paths::{self, sanitize_dir_name};
12use anyhow::{Context, Result, bail};
13use atomicwrites::{AllowOverwrite, AtomicFile};
14use std::io::Write;
15use std::path::{Component, Path, PathBuf};
16
17const REFERENCE_MAPPING_MARKER: &str = "#thoughts-ref=";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum UrlResolutionKind {
22 Exact,
24 CanonicalFallback,
26}
27
28#[derive(Debug, Clone)]
30pub struct ResolvedUrl {
31 pub matched_url: String,
33 pub resolution: UrlResolutionKind,
35 pub location: RepoLocation,
37}
38
39pub struct RepoMappingManager {
40 mapping_path: PathBuf,
41}
42
43impl RepoMappingManager {
44 pub fn new() -> Result<Self> {
45 let mapping_path = paths::get_repo_mapping_path()?;
46 Ok(Self { mapping_path })
47 }
48
49 fn lock_path(&self) -> PathBuf {
51 let name = self
52 .mapping_path
53 .file_name()
54 .unwrap_or_default()
55 .to_string_lossy();
56 self.mapping_path.with_file_name(format!("{name}.lock"))
57 }
58
59 pub fn load(&self) -> Result<RepoMapping> {
60 if !self.mapping_path.exists() {
61 let default = RepoMapping::default();
63 self.save(&default)?;
64 return Ok(default);
65 }
66
67 let contents = std::fs::read_to_string(&self.mapping_path)
68 .context("Failed to read repository mapping file")?;
69 let mapping: RepoMapping =
70 serde_json::from_str(&contents).context("Failed to parse repository mapping")?;
71 Ok(mapping)
72 }
73
74 pub fn save(&self, mapping: &RepoMapping) -> Result<()> {
75 if let Some(parent) = self.mapping_path.parent() {
77 paths::ensure_dir(parent)?;
78 }
79
80 let json = serde_json::to_string_pretty(mapping)?;
82 let af = AtomicFile::new(&self.mapping_path, AllowOverwrite);
83 af.write(|f| f.write_all(json.as_bytes()))?;
84
85 Ok(())
86 }
87
88 pub fn load_locked(&self) -> Result<(RepoMapping, FileLock)> {
96 let lock = FileLock::lock_exclusive(self.lock_path())?;
97 let mapping = self.load()?;
98 Ok((mapping, lock))
99 }
100
101 pub fn resolve_url_with_details(
105 &self,
106 url: &str,
107 ) -> Result<Option<(ResolvedUrl, Option<String>)>> {
108 let mapping = self.load()?; let (base_url, subpath) = parse_url_and_subpath(url);
110
111 if let Some(loc) = mapping.mappings.get(&base_url) {
113 return Ok(Some((
114 ResolvedUrl {
115 matched_url: base_url,
116 resolution: UrlResolutionKind::Exact,
117 location: loc.clone(),
118 },
119 subpath,
120 )));
121 }
122
123 let target_key = match RepoIdentity::parse(&base_url) {
125 Ok(id) => id.canonical_key(),
126 Err(_) => return Ok(None),
127 };
128
129 let mut matches: Vec<(String, RepoLocation)> = mapping
130 .mappings
131 .iter()
132 .filter_map(|(k, v)| {
133 let (k_base, _) = parse_url_and_subpath(k);
134 let key = RepoIdentity::parse(&k_base).ok()?.canonical_key();
135 (key == target_key).then(|| (k.clone(), v.clone()))
136 })
137 .collect();
138
139 matches.sort_by(|a, b| a.0.cmp(&b.0));
141
142 if let Some((matched_url, location)) = matches.into_iter().next() {
143 return Ok(Some((
144 ResolvedUrl {
145 matched_url,
146 resolution: UrlResolutionKind::CanonicalFallback,
147 location,
148 },
149 subpath,
150 )));
151 }
152
153 Ok(None)
154 }
155
156 pub fn resolve_url(&self, url: &str) -> Result<Option<PathBuf>> {
161 if let Some((resolved, subpath)) = self.resolve_url_with_details(url)? {
162 let mut p = resolved.location.path.clone();
163 if let Some(ref sub) = subpath {
164 validate_subpath(sub)?;
165 p = p.join(sub);
166 }
167 return Ok(Some(p));
168 }
169 Ok(None)
170 }
171
172 pub fn resolve_reference_url(
173 &self,
174 url: &str,
175 ref_name: Option<&str>,
176 ) -> Result<Option<PathBuf>> {
177 let mapping = self.load()?;
178 let (_, subpath) = parse_url_and_subpath(url);
179
180 let matches = Self::matching_reference_storage_keys(&mapping, url, ref_name)?;
181
182 if let Some(stored_key) = matches.first()
183 && let Some(location) = mapping.mappings.get(stored_key)
184 {
185 let mut p = location.path.clone();
186 if let Some(ref sub) = subpath {
187 validate_subpath(sub)?;
188 p = p.join(sub);
189 }
190 return Ok(Some(p));
191 }
192
193 Ok(None)
194 }
195
196 pub fn add_mapping(&mut self, url: String, path: PathBuf, auto_managed: bool) -> Result<()> {
202 let _lock = FileLock::lock_exclusive(self.lock_path())?;
203 let mut mapping = self.load()?; if !path.exists() {
207 bail!("Path does not exist: {}", path.display());
208 }
209
210 if !path.is_dir() {
211 bail!("Path is not a directory: {}", path.display());
212 }
213
214 let (base_url, _) = parse_url_and_subpath(&url);
215 let new_key = RepoIdentity::parse(&base_url)?.canonical_key();
216
217 let matching_urls: Vec<String> = mapping
219 .mappings
220 .keys()
221 .filter_map(|k| {
222 let (k_base, _) = parse_url_and_subpath(k);
223 let key = RepoIdentity::parse(&k_base).ok()?.canonical_key();
224 (key == new_key).then(|| k.clone())
225 })
226 .collect();
227
228 let preserved_last_sync = matching_urls
230 .iter()
231 .filter_map(|k| mapping.mappings.get(k).and_then(|loc| loc.last_sync))
232 .max();
233
234 for k in matching_urls {
236 mapping.mappings.remove(&k);
237 }
238
239 mapping.mappings.insert(
241 base_url,
242 RepoLocation {
243 path,
244 auto_managed,
245 last_sync: preserved_last_sync,
246 },
247 );
248
249 self.save(&mapping)?;
250 Ok(())
251 }
252
253 pub fn add_reference_mapping(
254 &mut self,
255 url: String,
256 ref_name: Option<&str>,
257 path: PathBuf,
258 auto_managed: bool,
259 ) -> Result<()> {
260 let _lock = FileLock::lock_exclusive(self.lock_path())?;
261 let mut mapping = self.load()?;
262
263 if !path.exists() {
264 bail!("Path does not exist: {}", path.display());
265 }
266
267 if !path.is_dir() {
268 bail!("Path is not a directory: {}", path.display());
269 }
270
271 let storage_key = reference_mapping_storage_key(&url, ref_name)?;
272 let matching_urls = Self::matching_reference_storage_keys(&mapping, &url, ref_name)?;
273
274 let preserved_last_sync = matching_urls
275 .iter()
276 .filter_map(|k| mapping.mappings.get(k).and_then(|loc| loc.last_sync))
277 .max();
278
279 for k in matching_urls {
280 mapping.mappings.remove(&k);
281 }
282
283 mapping.mappings.insert(
284 storage_key,
285 RepoLocation {
286 path,
287 auto_managed,
288 last_sync: preserved_last_sync,
289 },
290 );
291
292 self.save(&mapping)?;
293 Ok(())
294 }
295
296 #[allow(dead_code)]
298 pub fn remove_mapping(&mut self, url: &str) -> Result<()> {
300 let _lock = FileLock::lock_exclusive(self.lock_path())?;
301 let mut mapping = self.load()?;
302 mapping.mappings.remove(url);
303 self.save(&mapping)?;
304 Ok(())
305 }
306
307 pub fn is_auto_managed(&self, url: &str) -> Result<bool> {
309 let mapping = self.load()?;
310 Ok(mapping
311 .mappings
312 .get(url)
313 .map(|loc| loc.auto_managed)
314 .unwrap_or(false))
315 }
316
317 pub fn is_reference_auto_managed(&self, url: &str, ref_name: Option<&str>) -> Result<bool> {
318 let mapping = self.load()?;
319 let keys = Self::matching_reference_storage_keys(&mapping, url, ref_name)?;
320 Ok(keys
321 .first()
322 .and_then(|key| mapping.mappings.get(key))
323 .map(|loc| loc.auto_managed)
324 .unwrap_or(false))
325 }
326
327 pub fn get_default_clone_path(url: &str) -> Result<PathBuf> {
331 let home = dirs::home_dir()
332 .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
333
334 let (base_url, _sub) = parse_url_and_subpath(url);
335 let id = RepoIdentity::parse(&base_url)?;
336 let key = id.canonical_key(); let mut p = home
339 .join(".thoughts")
340 .join("clones")
341 .join(sanitize_dir_name(&key.host));
342 for seg in key.org_path.split('/') {
343 if !seg.is_empty() {
344 p = p.join(sanitize_dir_name(seg));
345 }
346 }
347 p = p.join(sanitize_dir_name(&key.repo));
348 Ok(p)
349 }
350
351 pub fn get_default_reference_clone_path(url: &str, ref_name: Option<&str>) -> Result<PathBuf> {
352 let mut path = Self::get_default_clone_path(url)?;
353 if let Some(ref_name) = ref_name {
354 let ref_key = encode_ref_key(ref_name)?;
355 let repo_dir = path
356 .file_name()
357 .ok_or_else(|| anyhow::anyhow!("Default clone path had no repository segment"))?
358 .to_string_lossy()
359 .to_string();
360 path.set_file_name(format!("{}@{}", sanitize_dir_name(&repo_dir), ref_key));
361 }
362 Ok(path)
363 }
364
365 pub fn update_sync_time(&mut self, url: &str) -> Result<()> {
370 let _lock = FileLock::lock_exclusive(self.lock_path())?;
371 let mut mapping = self.load()?;
372 let now = chrono::Utc::now();
373
374 let (base_url, _) = parse_url_and_subpath(url);
376 if let Some(loc) = mapping.mappings.get_mut(&base_url) {
377 loc.last_sync = Some(now);
378 self.save(&mapping)?;
379 return Ok(());
380 }
381
382 let target_key = RepoIdentity::parse(&base_url)?.canonical_key();
385
386 let matched_key: Option<String> = mapping
390 .mappings
391 .keys()
392 .filter_map(|k| {
393 let (k_base, _) = parse_url_and_subpath(k);
394 let key = RepoIdentity::parse(&k_base).ok()?.canonical_key();
395 (key == target_key).then(|| k.clone())
396 })
397 .next();
398
399 if let Some(key) = matched_key
400 && let Some(loc) = mapping.mappings.get_mut(&key)
401 {
402 loc.last_sync = Some(now);
403 self.save(&mapping)?;
404 }
405
406 Ok(())
407 }
408
409 pub fn update_reference_sync_time(&mut self, url: &str, ref_name: Option<&str>) -> Result<()> {
410 let _lock = FileLock::lock_exclusive(self.lock_path())?;
411 let mut mapping = self.load()?;
412 let now = chrono::Utc::now();
413
414 let keys = Self::matching_reference_storage_keys(&mapping, url, ref_name)?;
415 if let Some(key) = keys.first()
416 && let Some(loc) = mapping.mappings.get_mut(key)
417 {
418 loc.last_sync = Some(now);
419 self.save(&mapping)?;
420 }
421
422 Ok(())
423 }
424
425 pub fn get_canonical_key(url: &str) -> Option<RepoIdentityKey> {
427 let (base, _) = parse_url_and_subpath(url);
428 RepoIdentity::parse(&base).ok().map(|id| id.canonical_key())
429 }
430
431 fn matching_reference_storage_keys(
432 mapping: &RepoMapping,
433 url: &str,
434 ref_name: Option<&str>,
435 ) -> Result<Vec<String>> {
436 let wanted = canonical_reference_instance_key(url, ref_name)?;
437 let mut keys: Vec<String> = mapping
438 .mappings
439 .keys()
440 .filter_map(|stored_key| {
441 let (stored_url, stored_ref_key) = parse_reference_mapping_storage_key(stored_key);
442 let (host, org_path, repo) = canonical_reference_key(&stored_url).ok()?;
443 let normalized_stored_ref_key = stored_ref_key
444 .as_deref()
445 .map(|ref_key| normalize_encoded_ref_key_for_identity(ref_key).into_owned());
446 let actual = (host, org_path, repo, normalized_stored_ref_key);
447 (actual == wanted).then(|| stored_key.clone())
448 })
449 .collect();
450 keys.sort();
451 Ok(keys)
452 }
453}
454
455fn reference_mapping_storage_key(url: &str, ref_name: Option<&str>) -> Result<String> {
456 let (base_url, _) = parse_url_and_subpath(url);
457 match ref_name {
458 Some(ref_name) => Ok(format!(
459 "{}{REFERENCE_MAPPING_MARKER}{}",
460 base_url,
461 encode_ref_key(ref_name)?
462 )),
463 None => Ok(base_url),
464 }
465}
466
467pub fn parse_reference_mapping_storage_key(stored_key: &str) -> (String, Option<String>) {
468 match stored_key.split_once(REFERENCE_MAPPING_MARKER) {
469 Some((base_url, ref_key)) => (base_url.to_string(), Some(ref_key.to_string())),
470 None => (stored_key.to_string(), None),
471 }
472}
473
474pub fn parse_url_and_subpath(url: &str) -> (String, Option<String>) {
478 identity_parse_url_and_subpath(url)
479}
480
481fn validate_subpath(subpath: &str) -> Result<()> {
486 let path = Path::new(subpath);
487 if path.is_absolute() {
488 bail!(
489 "Invalid subpath (must be relative and not contain '..'): {}",
490 subpath
491 );
492 }
493 for component in path.components() {
494 match component {
495 Component::ParentDir => {
496 bail!(
497 "Invalid subpath (must be relative and not contain '..'): {}",
498 subpath
499 );
500 }
501 Component::Prefix(_) => {
502 bail!(
503 "Invalid subpath (must be relative and not contain '..'): {}",
504 subpath
505 );
506 }
507 _ => {}
508 }
509 }
510 Ok(())
511}
512
513pub fn extract_repo_name_from_url(url: &str) -> Result<String> {
514 let url = url.trim_end_matches(".git");
515
516 if let Some(pos) = url.rfind('/') {
518 Ok(url[pos + 1..].to_string())
519 } else if let Some(pos) = url.rfind(':') {
520 if let Some(slash_pos) = url[pos + 1..].rfind('/') {
522 Ok(url[pos + 1 + slash_pos + 1..].to_string())
523 } else {
524 Ok(url[pos + 1..].to_string())
525 }
526 } else {
527 bail!("Cannot extract repository name from URL: {}", url)
528 }
529}
530
531pub fn extract_org_repo_from_url(url: &str) -> anyhow::Result<(String, String)> {
538 let (base, _) = parse_url_and_subpath(url);
539 let id = RepoIdentity::parse(&base)?;
540 Ok((id.org_path, id.repo))
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use tempfile::TempDir;
547
548 #[test]
549 fn test_parse_url_and_subpath() {
550 let (url, sub) = parse_url_and_subpath("git@github.com:user/repo.git");
551 assert_eq!(url, "git@github.com:user/repo.git");
552 assert_eq!(sub, None);
553
554 let (url, sub) = parse_url_and_subpath("git@github.com:user/repo.git:docs/api");
555 assert_eq!(url, "git@github.com:user/repo.git");
556 assert_eq!(sub, Some("docs/api".to_string()));
557
558 let (url, sub) = parse_url_and_subpath("https://github.com/user/repo");
559 assert_eq!(url, "https://github.com/user/repo");
560 assert_eq!(sub, None);
561 }
562
563 #[test]
564 fn test_extract_repo_name() {
565 assert_eq!(
566 extract_repo_name_from_url("git@github.com:user/repo.git").unwrap(),
567 "repo"
568 );
569 assert_eq!(
570 extract_repo_name_from_url("https://github.com/user/repo").unwrap(),
571 "repo"
572 );
573 assert_eq!(
574 extract_repo_name_from_url("git@github.com:user/repo").unwrap(),
575 "repo"
576 );
577 }
578
579 #[test]
580 fn test_extract_org_repo() {
581 assert_eq!(
582 extract_org_repo_from_url("git@github.com:user/repo.git").unwrap(),
583 ("user".to_string(), "repo".to_string())
584 );
585 assert_eq!(
586 extract_org_repo_from_url("https://github.com/user/repo").unwrap(),
587 ("user".to_string(), "repo".to_string())
588 );
589 assert_eq!(
590 extract_org_repo_from_url("git@github.com:user/repo").unwrap(),
591 ("user".to_string(), "repo".to_string())
592 );
593 assert_eq!(
594 extract_org_repo_from_url("https://github.com/modelcontextprotocol/rust-sdk.git")
595 .unwrap(),
596 ("modelcontextprotocol".to_string(), "rust-sdk".to_string())
597 );
598 }
599
600 #[test]
601 fn test_default_clone_path_hierarchical() {
602 let p =
604 RepoMappingManager::get_default_clone_path("git@github.com:org/repo.git:docs").unwrap();
605 assert!(p.ends_with(std::path::Path::new(".thoughts/clones/github.com/org/repo")));
606 }
607
608 #[test]
609 fn test_default_clone_path_gitlab_subgroups() {
610 let p = RepoMappingManager::get_default_clone_path(
611 "https://gitlab.com/group/subgroup/team/repo.git",
612 )
613 .unwrap();
614 assert!(p.ends_with(std::path::Path::new(
615 ".thoughts/clones/gitlab.com/group/subgroup/team/repo"
616 )));
617 }
618
619 #[test]
620 fn test_default_clone_path_ssh_port() {
621 let p = RepoMappingManager::get_default_clone_path(
622 "ssh://git@myhost.example.com:2222/org/repo.git",
623 )
624 .unwrap();
625 assert!(p.ends_with(std::path::Path::new(
626 ".thoughts/clones/myhost.example.com/org/repo"
627 )));
628 }
629
630 #[test]
631 fn test_default_reference_clone_path_appends_ref_key() {
632 let p = RepoMappingManager::get_default_reference_clone_path(
633 "https://github.com/org/repo.git",
634 Some("refs/tags/v1.2.3"),
635 )
636 .unwrap();
637 assert!(p.ends_with(std::path::Path::new(
638 ".thoughts/clones/github.com/org/repo@r-refs~2ftags~2fv1.2.3"
639 )));
640 }
641
642 #[test]
643 fn test_canonical_key_consistency() {
644 let ssh_key = RepoMappingManager::get_canonical_key("git@github.com:Org/Repo.git").unwrap();
645 let https_key =
646 RepoMappingManager::get_canonical_key("https://github.com/org/repo").unwrap();
647 assert_eq!(
648 ssh_key, https_key,
649 "SSH and HTTPS should have same canonical key"
650 );
651 }
652
653 #[test]
654 fn test_add_reference_mapping_keeps_different_refs_separate() {
655 let temp_dir = TempDir::new().unwrap();
656 let mapping_path = temp_dir.path().join("repos.json");
657 let mut manager = RepoMappingManager { mapping_path };
658
659 let main_path = temp_dir.path().join("repo-main");
660 let tag_path = temp_dir.path().join("repo-tag");
661 std::fs::create_dir_all(&main_path).unwrap();
662 std::fs::create_dir_all(&tag_path).unwrap();
663
664 manager
665 .add_reference_mapping(
666 "https://github.com/org/repo.git".to_string(),
667 Some("refs/heads/main"),
668 main_path.clone(),
669 true,
670 )
671 .unwrap();
672 manager
673 .add_reference_mapping(
674 "git@github.com:Org/Repo.git".to_string(),
675 Some("refs/tags/v1.0.0"),
676 tag_path.clone(),
677 true,
678 )
679 .unwrap();
680
681 assert_eq!(
682 manager
683 .resolve_reference_url("https://github.com/org/repo", Some("refs/heads/main"))
684 .unwrap(),
685 Some(main_path)
686 );
687 assert_eq!(
688 manager
689 .resolve_reference_url("https://github.com/org/repo", Some("refs/tags/v1.0.0"))
690 .unwrap(),
691 Some(tag_path)
692 );
693
694 let mapping = manager.load().unwrap();
695 assert_eq!(mapping.mappings.len(), 2);
696 }
697
698 #[test]
699 fn test_resolve_reference_url_matches_legacy_refs_remotes_and_heads_equivalently() {
700 let temp_dir = TempDir::new().unwrap();
701 let mapping_path = temp_dir.path().join("repos.json");
702 let mut manager = RepoMappingManager {
703 mapping_path: mapping_path.clone(),
704 };
705
706 let repo_path = temp_dir.path().join("repo-legacy");
707 std::fs::create_dir_all(&repo_path).unwrap();
708
709 manager
710 .add_reference_mapping(
711 "https://github.com/org/repo.git".to_string(),
712 Some("refs/remotes/origin/main"),
713 repo_path.clone(),
714 true,
715 )
716 .unwrap();
717
718 let mgr_ro = RepoMappingManager { mapping_path };
719 assert_eq!(
720 mgr_ro
721 .resolve_reference_url("https://github.com/org/repo", Some("refs/heads/main"))
722 .unwrap(),
723 Some(repo_path)
724 );
725 }
726
727 #[test]
728 fn test_update_reference_sync_time_updates_ref_specific_entry() {
729 let temp_dir = TempDir::new().unwrap();
730 let mapping_path = temp_dir.path().join("repos.json");
731 let mut manager = RepoMappingManager { mapping_path };
732
733 let repo_path = temp_dir.path().join("repo-main");
734 std::fs::create_dir_all(&repo_path).unwrap();
735
736 manager
737 .add_reference_mapping(
738 "https://github.com/org/repo.git".to_string(),
739 Some("refs/heads/main"),
740 repo_path,
741 true,
742 )
743 .unwrap();
744
745 manager
746 .update_reference_sync_time("git@github.com:Org/Repo.git", Some("refs/heads/main"))
747 .unwrap();
748
749 let mapping = manager.load().unwrap();
750 let found = mapping
751 .mappings
752 .iter()
753 .find(|(key, _)| key.contains("#thoughts-ref="))
754 .unwrap();
755 assert!(found.1.last_sync.is_some());
756 }
757
758 #[test]
759 fn test_is_reference_auto_managed_matches_ref_specific_entry() {
760 let temp_dir = TempDir::new().unwrap();
761 let mapping_path = temp_dir.path().join("repos.json");
762 let mut manager = RepoMappingManager {
763 mapping_path: mapping_path.clone(),
764 };
765
766 let repo_path = temp_dir.path().join("repo-main");
767 std::fs::create_dir_all(&repo_path).unwrap();
768
769 manager
770 .add_reference_mapping(
771 "https://github.com/org/repo.git".to_string(),
772 Some("refs/heads/main"),
773 repo_path,
774 true,
775 )
776 .unwrap();
777
778 let mgr_ro = RepoMappingManager { mapping_path };
779 assert!(
780 mgr_ro
781 .is_reference_auto_managed("git@github.com:Org/Repo.git", Some("refs/heads/main"))
782 .unwrap()
783 );
784 }
785
786 #[test]
793 fn test_validate_subpath_accepts_valid_paths() {
794 assert!(validate_subpath("docs").is_ok());
796 assert!(validate_subpath("docs/api").is_ok());
797 assert!(validate_subpath("src/lib/utils").is_ok());
798 assert!(validate_subpath("a/b/c/d/e").is_ok());
799 }
800
801 #[test]
802 fn test_validate_subpath_rejects_parent_dir_traversal() {
803 assert!(validate_subpath("..").is_err());
805 assert!(validate_subpath("../etc").is_err());
806 assert!(validate_subpath("docs/../..").is_err());
807 assert!(validate_subpath("docs/../../etc").is_err());
808 assert!(validate_subpath("a/b/c/../../../etc").is_err());
809 }
810
811 #[test]
812 fn test_validate_subpath_rejects_absolute_paths() {
813 assert!(validate_subpath("/etc").is_err());
815 assert!(validate_subpath("/etc/passwd").is_err());
816 assert!(validate_subpath("/home/user/.ssh").is_err());
817 }
818}