Skip to main content

projectatlas_core/
project_root.rs

1//! Native, lossless identity for one canonical project root.
2
3use crate::{CoreError, CoreResult, normalize_native_path_display};
4use std::ffi::{OsStr, OsString};
5use std::fmt;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9/// Version of the durable native project-root codec.
10pub const CANONICAL_PROJECT_ROOT_CODEC_VERSION: u8 = 1;
11
12/// One canonical native filesystem root.
13///
14/// Equality is native-path equality after filesystem canonicalization. The
15/// value is the authority for routing and persistence; its display projection
16/// is only for terminal diagnostics and compatibility metadata.
17#[derive(Clone, Debug, Eq, Hash, PartialEq)]
18pub struct CanonicalProjectRoot(PathBuf);
19
20impl CanonicalProjectRoot {
21    /// Canonicalize an existing absolute directory into a native identity.
22    ///
23    /// # Errors
24    ///
25    /// Returns an error when the path is relative, missing, or cannot be
26    /// canonicalized by the host filesystem.
27    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    /// Construct an identity from a persisted native path whose filesystem
55    /// object may no longer exist.
56    ///
57    /// This is the migration and recovery entry point for historical
58    /// worktree identities. It uses the same identity type and lexical
59    /// validation as [`Self::decode`], but deliberately does not require a
60    /// live directory. Active filesystem admission must continue to use
61    /// [`Self::from_path`].
62    ///
63    /// # Errors
64    ///
65    /// Returns an error when the path is relative, contains an interior NUL,
66    /// or does not satisfy the native canonical lexical contract.
67    pub fn from_persisted_path(path: PathBuf) -> CoreResult<Self> {
68        Self::from_decoded_path(path)
69    }
70
71    /// Construct an identity from a durable native codec value.
72    ///
73    /// This is deliberately private: active roots must enter through
74    /// [`Self::from_path`], which proves that the current filesystem object is
75    /// an existing directory. Historical moved-root identities may be decoded
76    /// while their old path is absent, but they still have to satisfy the
77    /// absolute, canonical lexical native-path contract.
78    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    /// Return the canonical native path.
100    #[must_use]
101    pub fn as_path(&self) -> &Path {
102        &self.0
103    }
104
105    /// Consume the identity and return its canonical native path.
106    #[must_use]
107    pub fn into_path(self) -> PathBuf {
108        self.0
109    }
110
111    /// Return the UTF-8 terminal/compatibility display projection.
112    ///
113    /// A native root containing bytes that are not UTF-8 has no lossless text
114    /// display. Callers carrying identity or compatibility state must retain
115    /// this typed refusal instead of turning the path into replacement text.
116    ///
117    /// # Errors
118    ///
119    /// Returns [`CoreError::NonUtf8Path`] when the native path has no lossless
120    /// UTF-8 display projection.
121    pub fn display_string(&self) -> CoreResult<String> {
122        crate::lossless_native_path_display(&self.0)
123    }
124
125    /// Return an explicitly lossy rendering for terminal-only diagnostics.
126    ///
127    /// This method must not be used for identity comparison, persistence,
128    /// compatibility keys, or structured adapter results.
129    #[must_use]
130    pub fn display_string_lossy(&self) -> String {
131        normalize_native_path_display(&self.0)
132    }
133
134    /// Encode the native path without lossy text conversion.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error when the host cannot encode its native path format.
139    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    /// Decode one versioned native path codec value.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error when the version, platform, bytes, or decoded path is
150    /// invalid.
151    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)]
173/// Normalize Windows extended-path prefixes for native identity equality.
174fn 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))]
185/// Preserve the canonical path unchanged on non-Windows hosts.
186fn 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)]
197/// Return the host-native path codec tag.
198fn platform_tag() -> u8 {
199    1
200}
201
202#[cfg(windows)]
203/// Return the host-native path codec tag.
204fn platform_tag() -> u8 {
205    2
206}
207
208#[cfg(not(any(unix, windows)))]
209/// Return the host-native path codec tag.
210fn platform_tag() -> u8 {
211    3
212}
213
214#[cfg(unix)]
215/// Encode an operating-system path without UTF-8 conversion.
216#[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/// Encode an operating-system path without UTF-8 conversion.
224#[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)))]
235/// Encode an operating-system path using the host fallback representation.
236fn 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/// Decode an operating-system path without UTF-8 conversion.
246///
247/// The Unix conversion is infallible, but this result remains `CoreResult` to
248/// match the fallible Windows codec helper at the shared decode call site.
249#[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/// Decode a Windows UTF-16 operating-system path.
257#[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)))]
273/// Decode a host fallback path representation.
274fn 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
282/// Return whether a decoded path has the lexical form emitted by canonicalization.
283fn 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)]
323/// Return whether a Unix path contains an interior NUL byte.
324fn 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)]
330/// Return whether a Windows path contains an interior NUL code unit.
331fn 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)))]
337/// Return whether a fallback path contains an interior NUL character.
338fn 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(&regular_file, b"not a root")?;
429        if CanonicalProjectRoot::from_path(&regular_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        // A case-sensitive Windows directory intentionally cannot resolve the
694        // old spelling after rename; the DB suite covers that refusal path.
695        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}