1use crate::{CoreError, CoreResult, normalize_native_path_display};
4use std::ffi::{OsStr, OsString};
5use std::fmt;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9pub const CANONICAL_PROJECT_ROOT_CODEC_VERSION: u8 = 1;
11
12#[derive(Clone, Debug, Eq, Hash, PartialEq)]
18pub struct CanonicalProjectRoot(PathBuf);
19
20impl CanonicalProjectRoot {
21 pub fn from_path(path: &Path) -> CoreResult<Self> {
28 if !path.is_absolute() {
29 return Err(CoreError::InvalidCanonicalProjectRoot {
30 path: path.to_path_buf(),
31 reason: "project root must be absolute",
32 });
33 }
34 let canonical =
35 fs::canonicalize(path).map_err(|source| CoreError::CanonicalProjectRootIo {
36 path: path.to_path_buf(),
37 source,
38 })?;
39 if !fs::metadata(&canonical)
40 .map_err(|source| CoreError::CanonicalProjectRootIo {
41 path: canonical.clone(),
42 source,
43 })?
44 .is_dir()
45 {
46 return Err(CoreError::InvalidCanonicalProjectRoot {
47 path: canonical,
48 reason: "project root must be a directory",
49 });
50 }
51 Self::from_decoded_path(canonical)
52 }
53
54 pub fn from_persisted_path(path: PathBuf) -> CoreResult<Self> {
68 Self::from_decoded_path(path)
69 }
70
71 fn from_decoded_path(path: PathBuf) -> CoreResult<Self> {
79 let path = normalize_native_identity_path(path);
80 if !path.is_absolute() {
81 return Err(CoreError::InvalidCanonicalProjectRoot {
82 path,
83 reason: "project root must be absolute",
84 });
85 }
86 if native_path_has_interior_nul(&path) {
87 return Err(CoreError::CanonicalProjectRootCodec {
88 reason: "native path contains an interior NUL",
89 });
90 }
91 if !is_canonical_lexical_path(&path) {
92 return Err(CoreError::CanonicalProjectRootCodec {
93 reason: "native path is not canonically lexical",
94 });
95 }
96 Ok(Self(path))
97 }
98
99 #[must_use]
101 pub fn as_path(&self) -> &Path {
102 &self.0
103 }
104
105 #[must_use]
107 pub fn into_path(self) -> PathBuf {
108 self.0
109 }
110
111 pub fn display_string(&self) -> CoreResult<String> {
122 crate::lossless_native_path_display(&self.0)
123 }
124
125 #[must_use]
130 pub fn display_string_lossy(&self) -> String {
131 normalize_native_path_display(&self.0)
132 }
133
134 pub fn encode(&self) -> CoreResult<Vec<u8>> {
140 let mut encoded = vec![CANONICAL_PROJECT_ROOT_CODEC_VERSION, platform_tag()];
141 encoded.extend(native_path_bytes(self.0.as_os_str())?);
142 Ok(encoded)
143 }
144
145 pub fn decode(encoded: &[u8]) -> CoreResult<Self> {
152 if encoded.len() < 3 {
153 return Err(CoreError::CanonicalProjectRootCodec {
154 reason: "codec value is truncated",
155 });
156 }
157 if encoded[0] != CANONICAL_PROJECT_ROOT_CODEC_VERSION {
158 return Err(CoreError::CanonicalProjectRootCodec {
159 reason: "unsupported codec version",
160 });
161 }
162 if encoded[1] != platform_tag() {
163 return Err(CoreError::CanonicalProjectRootCodec {
164 reason: "codec platform does not match this host",
165 });
166 }
167 let path = native_path_from_bytes(&encoded[2..])?;
168 Self::from_decoded_path(path)
169 }
170}
171
172#[cfg(windows)]
173fn normalize_native_identity_path(path: PathBuf) -> PathBuf {
175 if let Some(value) = path.to_str() {
176 let normalized = PathBuf::from(crate::normalize_native_path_display_str(value));
177 if normalized.is_absolute() && !crate::windows_verbatim_semantics_require_prefix(&path) {
178 return normalized;
179 }
180 }
181 path
182}
183
184#[cfg(not(windows))]
185fn normalize_native_identity_path(path: PathBuf) -> PathBuf {
187 path
188}
189
190impl fmt::Display for CanonicalProjectRoot {
191 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
192 formatter.write_str(&self.display_string_lossy())
193 }
194}
195
196#[cfg(unix)]
197fn platform_tag() -> u8 {
199 1
200}
201
202#[cfg(windows)]
203fn platform_tag() -> u8 {
205 2
206}
207
208#[cfg(not(any(unix, windows)))]
209fn platform_tag() -> u8 {
211 3
212}
213
214#[cfg(unix)]
215#[allow(clippy::unnecessary_wraps)]
217fn native_path_bytes(path: &OsStr) -> CoreResult<Vec<u8>> {
218 use std::os::unix::ffi::OsStrExt;
219 Ok(path.as_bytes().to_vec())
220}
221
222#[cfg(windows)]
223#[allow(clippy::unnecessary_wraps)]
225fn native_path_bytes(path: &OsStr) -> CoreResult<Vec<u8>> {
226 use std::os::windows::ffi::OsStrExt;
227 let mut bytes = Vec::new();
228 for unit in path.encode_wide() {
229 bytes.extend(unit.to_le_bytes());
230 }
231 Ok(bytes)
232}
233
234#[cfg(not(any(unix, windows)))]
235fn native_path_bytes(path: &OsStr) -> CoreResult<Vec<u8>> {
237 path.to_str()
238 .map(|value| value.as_bytes().to_vec())
239 .ok_or_else(|| CoreError::CanonicalProjectRootCodec {
240 reason: "native path encoding is unavailable on this host",
241 })
242}
243
244#[cfg(unix)]
245#[allow(clippy::unnecessary_wraps)]
250fn native_path_from_bytes(bytes: &[u8]) -> CoreResult<PathBuf> {
251 use std::os::unix::ffi::OsStringExt;
252 Ok(PathBuf::from(OsString::from_vec(bytes.to_vec())))
253}
254
255#[cfg(windows)]
256#[allow(clippy::chunks_exact_to_as_chunks)]
258fn native_path_from_bytes(bytes: &[u8]) -> CoreResult<PathBuf> {
259 use std::os::windows::ffi::OsStringExt;
260 let units = bytes
261 .chunks_exact(2)
262 .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
263 .collect::<Vec<_>>();
264 if units.len() * 2 != bytes.len() {
265 return Err(CoreError::CanonicalProjectRootCodec {
266 reason: "windows codec value has an odd byte length",
267 });
268 }
269 Ok(PathBuf::from(OsString::from_wide(&units)))
270}
271
272#[cfg(not(any(unix, windows)))]
273fn native_path_from_bytes(bytes: &[u8]) -> CoreResult<PathBuf> {
275 let value =
276 String::from_utf8(bytes.to_vec()).map_err(|_| CoreError::CanonicalProjectRootCodec {
277 reason: "native path encoding is unavailable on this host",
278 })?;
279 Ok(PathBuf::from(value))
280}
281
282fn is_canonical_lexical_path(path: &Path) -> bool {
284 if path.components().any(|component| {
285 matches!(
286 component,
287 std::path::Component::CurDir | std::path::Component::ParentDir
288 )
289 }) {
290 return false;
291 }
292 #[cfg(unix)]
293 {
294 use std::os::unix::ffi::OsStrExt;
295 let bytes = path.as_os_str().as_bytes();
296 (bytes.len() == 1 || !bytes.ends_with(b"/")) && !bytes.windows(2).any(|pair| pair == b"//")
297 }
298 #[cfg(windows)]
299 {
300 use std::os::windows::ffi::OsStrExt;
301 let units = path.as_os_str().encode_wide().collect::<Vec<_>>();
302 let is_separator = |unit: &u16| *unit == u16::from(b'/') || *unit == u16::from(b'\\');
303 let leading_unc =
304 units.first().is_some_and(is_separator) && units.get(1).is_some_and(is_separator);
305 let body = if leading_unc { &units[2..] } else { &units[..] };
306 let has_normal_component = path
307 .components()
308 .any(|component| matches!(component, std::path::Component::Normal(_)));
309 !body.first().is_some_and(is_separator)
310 && !body
311 .windows(2)
312 .any(|pair| is_separator(&pair[0]) && is_separator(&pair[1]))
313 && (!units.last().is_some_and(is_separator) || !has_normal_component)
314 }
315 #[cfg(not(any(unix, windows)))]
316 {
317 let value = path.to_string_lossy();
318 return !value.ends_with('/') && !value.contains("//");
319 }
320}
321
322#[cfg(unix)]
323fn native_path_has_interior_nul(path: &Path) -> bool {
325 use std::os::unix::ffi::OsStrExt;
326 path.as_os_str().as_bytes().contains(&0)
327}
328
329#[cfg(windows)]
330fn native_path_has_interior_nul(path: &Path) -> bool {
332 use std::os::windows::ffi::OsStrExt;
333 path.as_os_str().encode_wide().any(|unit| unit == 0)
334}
335
336#[cfg(not(any(unix, windows)))]
337fn native_path_has_interior_nul(path: &Path) -> bool {
339 path.to_string_lossy().contains('\0')
340}
341
342#[cfg(test)]
343mod tests {
344 use super::CanonicalProjectRoot;
345 #[cfg(unix)]
346 use std::ffi::OsString;
347 #[cfg(any(unix, windows))]
348 use std::fs;
349 use std::path::PathBuf;
350 use tempfile::tempdir;
351
352 #[test]
353 fn canonical_root_codec_round_trips_native_path() -> Result<(), Box<dyn std::error::Error>> {
354 let directory = tempdir()?;
355 let root = CanonicalProjectRoot::from_path(directory.path())?;
356 let decoded = CanonicalProjectRoot::decode(&root.encode()?)?;
357 if root != decoded || root.as_path() != decoded.as_path() {
358 return Err("canonical root codec changed the native path".into());
359 }
360 Ok(())
361 }
362
363 #[test]
364 fn canonical_root_codec_rejects_malformed_payloads() -> Result<(), Box<dyn std::error::Error>> {
365 let directory = tempdir()?;
366 let root = CanonicalProjectRoot::from_path(directory.path())?;
367 let encoded = root.encode()?;
368
369 for malformed in [
370 encoded[..2].to_vec(),
371 {
372 let mut value = encoded.clone();
373 value[0] = 0xff;
374 value
375 },
376 {
377 let mut value = encoded.clone();
378 value[1] = 0xff;
379 value
380 },
381 ] {
382 if CanonicalProjectRoot::decode(&malformed).is_ok() {
383 return Err("malformed canonical-root payload was accepted".into());
384 }
385 }
386
387 let mut nul = encoded[..2].to_vec();
388 nul.extend(super::native_path_bytes(std::ffi::OsStr::new(
389 "/tmp\0root",
390 ))?);
391 if CanonicalProjectRoot::decode(&nul).is_ok() {
392 return Err("interior-NUL canonical-root payload was accepted".into());
393 }
394
395 let relative = PathBuf::from("relative/project");
396 let mut relative_payload = encoded[..2].to_vec();
397 relative_payload.extend(super::native_path_bytes(relative.as_os_str())?);
398 if CanonicalProjectRoot::decode(&relative_payload).is_ok() {
399 return Err("relative canonical-root payload was accepted".into());
400 }
401
402 let noncanonical = if cfg!(windows) {
403 PathBuf::from(r"C:\temp\..\project")
404 } else {
405 PathBuf::from("/tmp/../project")
406 };
407 let mut noncanonical_payload = encoded[..2].to_vec();
408 noncanonical_payload.extend(super::native_path_bytes(noncanonical.as_os_str())?);
409 if CanonicalProjectRoot::decode(&noncanonical_payload).is_ok() {
410 return Err("non-canonical lexical root payload was accepted".into());
411 }
412 #[cfg(windows)]
413 {
414 let mut odd = encoded[..2].to_vec();
415 odd.push(0);
416 if CanonicalProjectRoot::decode(&odd).is_ok() {
417 return Err("odd-byte Windows root payload was accepted".into());
418 }
419 }
420 Ok(())
421 }
422
423 #[test]
424 fn canonical_root_requires_an_existing_directory() -> Result<(), Box<dyn std::error::Error>> {
425 let directory = tempdir()?;
426 let regular_file = directory.path().join("regular-file");
427 let missing = directory.path().join("missing-directory");
428 std::fs::write(®ular_file, b"not a root")?;
429 if CanonicalProjectRoot::from_path(®ular_file).is_ok()
430 || CanonicalProjectRoot::from_path(&missing).is_ok()
431 {
432 return Err("non-directory canonical root was accepted".into());
433 }
434 Ok(())
435 }
436
437 #[cfg(unix)]
438 #[test]
439 fn canonical_root_codec_round_trips_non_utf8_path() -> Result<(), Box<dyn std::error::Error>> {
440 use std::os::unix::ffi::OsStringExt;
441 let directory = tempdir()?;
442 let name = std::ffi::OsString::from_vec(vec![b'r', b'o', b'o', b't', 0x80]);
443 let path = directory.path().join(&name);
444 fs::create_dir(&path)?;
445 let root = CanonicalProjectRoot::from_path(&path)?;
446 if root != CanonicalProjectRoot::decode(&root.encode()?)? {
447 return Err("non-UTF-8 root codec changed the native path".into());
448 }
449 Ok(())
450 }
451
452 #[cfg(unix)]
453 #[test]
454 fn canonical_root_display_refuses_raw_bytes_without_colliding_with_replacement_text()
455 -> Result<(), Box<dyn std::error::Error>> {
456 use std::os::unix::ffi::OsStringExt;
457
458 let directory = tempdir()?;
459 let raw_name = OsString::from_vec(vec![b'r', b'o', b'o', b't', 0x80]);
460 let raw_path = directory.path().join(&raw_name);
461 let replacement_path = directory.path().join("root�");
462 fs::create_dir(&raw_path)?;
463 fs::create_dir(&replacement_path)?;
464
465 let raw = CanonicalProjectRoot::from_path(&raw_path)?;
466 let replacement = CanonicalProjectRoot::from_path(&replacement_path)?;
467 if raw == replacement || raw.encode()? == replacement.encode()? {
468 return Err("raw and replacement-character roots collided".into());
469 }
470 if !matches!(
471 raw.display_string(),
472 Err(crate::CoreError::NonUtf8Path { .. })
473 ) {
474 return Err("raw root did not return typed display unavailability".into());
475 }
476 if raw.display_string_lossy() != replacement.display_string()? {
477 return Err("test roots did not demonstrate their lossy display collision".into());
478 }
479 if CanonicalProjectRoot::decode(&raw.encode()?)? != raw
480 || CanonicalProjectRoot::decode(&replacement.encode()?)? != replacement
481 {
482 return Err("native root codec round-trip changed one root".into());
483 }
484 Ok(())
485 }
486
487 #[cfg(windows)]
488 #[test]
489 fn canonical_root_codec_preserves_volume_guid_paths() -> Result<(), Box<dyn std::error::Error>>
490 {
491 let path = PathBuf::from(r"\\?\Volume{12345678-1234-1234-1234-123456789abc}\repo");
492 let mut encoded = vec![
493 super::CANONICAL_PROJECT_ROOT_CODEC_VERSION,
494 super::platform_tag(),
495 ];
496 encoded.extend(super::native_path_bytes(path.as_os_str())?);
497 let decoded = CanonicalProjectRoot::decode(&encoded)?;
498 if !decoded.as_path().is_absolute() || decoded.as_path() != path {
499 return Err("volume-GUID identity lost its native absolute path".into());
500 }
501 let display = decoded.display_string()?;
502 if !PathBuf::from(&display).is_absolute() || display != path.to_string_lossy() {
503 return Err("volume-GUID display projection lost its absolute native spelling".into());
504 }
505 if CanonicalProjectRoot::decode(&decoded.encode()?)? != decoded {
506 return Err("volume-GUID identity codec round-trip changed its native path".into());
507 }
508 Ok(())
509 }
510
511 #[cfg(windows)]
512 #[test]
513 fn canonical_root_codec_preserves_verbatim_components() -> Result<(), Box<dyn std::error::Error>>
514 {
515 let long_component = "a".repeat(240);
516 for path in [
517 PathBuf::from(r"\\?\C:\repo\folder."),
518 PathBuf::from(r"\\?\C:\repo\CON.txt"),
519 PathBuf::from(r"\\?\C:\repo\CONIN$"),
520 PathBuf::from(r"\\?\C:\repo\conout$.log"),
521 PathBuf::from(r"\\?\C:\repo\COM¹.txt"),
522 PathBuf::from(r"\\?\C:\repo\COM².dat"),
523 PathBuf::from(r"\\?\C:\repo\COM³.bin"),
524 PathBuf::from(r"\\?\C:\repo\LPT¹.tmp"),
525 PathBuf::from(r"\\?\C:\repo\LPT².cfg"),
526 PathBuf::from(r"\\?\C:\repo\LPT³.log"),
527 PathBuf::from(r"\\?\UNC\server\share\LPT1"),
528 PathBuf::from(format!(r"\\?\C:\{long_component}")),
529 PathBuf::from(format!(r"\\?\UNC\server\share\{long_component}")),
530 PathBuf::from(r"\\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy1"),
531 ] {
532 let mut encoded = vec![
533 super::CANONICAL_PROJECT_ROOT_CODEC_VERSION,
534 super::platform_tag(),
535 ];
536 encoded.extend(super::native_path_bytes(path.as_os_str())?);
537 let decoded = CanonicalProjectRoot::decode(&encoded)?;
538 if decoded.as_path() != path {
539 return Err("verbatim-sensitive root lost its extended prefix".into());
540 }
541 if decoded.display_string()? != path.to_str().ok_or("non-UTF-8 test path")? {
542 return Err("verbatim-sensitive display changed native spelling".into());
543 }
544 if CanonicalProjectRoot::decode(&decoded.encode()?)? != decoded {
545 return Err("verbatim-sensitive root codec round-trip changed its path".into());
546 }
547 }
548 Ok(())
549 }
550
551 #[cfg(windows)]
552 #[test]
553 fn canonical_root_round_trips_real_verbatim_only_directory()
554 -> Result<(), Box<dyn std::error::Error>> {
555 let directory = tempdir()?;
556 let base = directory
557 .path()
558 .to_str()
559 .ok_or("temporary directory was not UTF-8")?;
560 let verbatim_path = PathBuf::from(format!(r"\\?\{base}\verbatim-only."));
561 fs::create_dir(&verbatim_path)?;
562
563 let root = CanonicalProjectRoot::from_path(&verbatim_path)?;
564 if root.as_path() != verbatim_path {
565 return Err("verbatim-only directory changed native identity".into());
566 }
567 if root.display_string()? != verbatim_path.to_str().ok_or("non-UTF-8 test path")? {
568 return Err("verbatim-only directory changed its display spelling".into());
569 }
570 let decoded = CanonicalProjectRoot::decode(&root.encode()?)?;
571 if decoded.as_path() != root.as_path() {
572 return Err("verbatim-only directory codec changed its native path".into());
573 }
574 Ok(())
575 }
576
577 #[cfg(windows)]
578 #[test]
579 fn canonical_root_codec_rejects_noncanonical_volume_separators_and_accepts_roots()
580 -> Result<(), Box<dyn std::error::Error>> {
581 let valid_roots = [
582 (PathBuf::from("C:\\"), PathBuf::from("C:/")),
583 (
584 PathBuf::from(r"\\server\share\"),
585 PathBuf::from("//server/share/"),
586 ),
587 (
588 PathBuf::from(r"\\?\Volume{12345678-1234-1234-1234-123456789abc}\"),
589 PathBuf::from(r"\\?\Volume{12345678-1234-1234-1234-123456789abc}\"),
590 ),
591 ];
592 for (path, expected) in valid_roots {
593 let mut encoded = vec![
594 super::CANONICAL_PROJECT_ROOT_CODEC_VERSION,
595 super::platform_tag(),
596 ];
597 encoded.extend(super::native_path_bytes(path.as_os_str())?);
598 let decoded = CanonicalProjectRoot::decode(&encoded)?;
599 if !decoded.as_path().is_absolute() || decoded.as_path() != expected {
600 return Err(
601 "Windows root identity was not retained as an absolute native path".into(),
602 );
603 }
604 if CanonicalProjectRoot::decode(&decoded.encode()?)? != decoded {
605 return Err("Windows root identity codec round-trip changed its path".into());
606 }
607 }
608
609 for path in [
610 PathBuf::from(r"\\?\Volume{12345678-1234-1234-1234-123456789abc}\\repo"),
611 PathBuf::from(r"\\?\Volume{12345678-1234-1234-1234-123456789abc}\repo\"),
612 ] {
613 let mut encoded = vec![
614 super::CANONICAL_PROJECT_ROOT_CODEC_VERSION,
615 super::platform_tag(),
616 ];
617 encoded.extend(super::native_path_bytes(path.as_os_str())?);
618 if CanonicalProjectRoot::decode(&encoded).is_ok() {
619 return Err("noncanonical volume-GUID separator form was accepted".into());
620 }
621 }
622 Ok(())
623 }
624
625 #[cfg(windows)]
626 #[test]
627 fn canonical_root_codec_keeps_extended_drive_and_unc_compatibility()
628 -> Result<(), Box<dyn std::error::Error>> {
629 for (encoded_path, expected_path) in [
630 (r"\\?\C:\repo", PathBuf::from("C:/repo")),
631 (
632 r"\\?\UNC\server\share\repo",
633 PathBuf::from("//server/share/repo"),
634 ),
635 ] {
636 let path = PathBuf::from(encoded_path);
637 let mut encoded = vec![
638 super::CANONICAL_PROJECT_ROOT_CODEC_VERSION,
639 super::platform_tag(),
640 ];
641 encoded.extend(super::native_path_bytes(path.as_os_str())?);
642 let decoded = CanonicalProjectRoot::decode(&encoded)?;
643 if decoded.as_path() != expected_path
644 || CanonicalProjectRoot::decode(&decoded.encode()?)? != decoded
645 {
646 return Err("extended Windows identity compatibility changed".into());
647 }
648 }
649 Ok(())
650 }
651
652 #[cfg(windows)]
653 #[test]
654 fn canonical_root_case_only_rename_requires_fresh_canonicalization()
655 -> Result<(), Box<dyn std::error::Error>> {
656 use std::collections::{HashMap, HashSet};
657
658 let directory = tempdir()?;
659 let original_path = directory.path().join("CaseOnlyRoot");
660 let staging_path = directory.path().join("CaseOnlyRootStaging");
661 let renamed_path = directory.path().join("caseonlyroot");
662 std::fs::create_dir(&original_path)?;
663 let original = CanonicalProjectRoot::from_path(&original_path)?;
664
665 std::fs::rename(&original_path, &staging_path)?;
666 std::fs::rename(&staging_path, &renamed_path)?;
667 let renamed = CanonicalProjectRoot::from_path(&renamed_path)?;
668 if original.encode()? == renamed.encode()? {
669 return Err("case-only rename did not retain distinct native spellings".into());
670 }
671 if original == renamed {
672 return Err("stale native spellings were treated as equal".into());
673 }
674
675 let mut identities = HashSet::new();
676 identities.insert(original.clone());
677 identities.insert(renamed.clone());
678 if identities.len() != 2 {
679 return Err("spelling-sensitive roots were unexpectedly deduplicated".into());
680 }
681 let mut values = HashMap::new();
682 values.insert(original, "stale root");
683 if values.contains_key(&renamed) {
684 return Err("stale root spelling unexpectedly matched in HashMap".into());
685 }
686
687 let encoded = renamed.encode()?;
688 let decoded = CanonicalProjectRoot::decode(&encoded)?;
689 if decoded.as_path() != renamed.as_path() || decoded.encode()? != encoded {
690 return Err("case-only root codec round-trip changed native UTF-16".into());
691 }
692
693 let Ok(recanonicalized) = CanonicalProjectRoot::from_path(&original_path) else {
696 return Ok(());
697 };
698 if recanonicalized != renamed {
699 return Err("re-canonicalized case-only root did not match live spelling".into());
700 }
701 identities.clear();
702 identities.insert(recanonicalized.clone());
703 identities.insert(renamed.clone());
704 if identities.len() != 1 {
705 return Err("re-canonicalized roots were not deduplicated".into());
706 }
707 values.clear();
708 values.insert(recanonicalized, "live root");
709 if values.get(&renamed).copied() != Some("live root") {
710 return Err("re-canonicalized root was not found by HashMap lookup".into());
711 }
712 Ok(())
713 }
714}