fix(experts): recognize Windows junction links to central store

On Windows the expert enable-status check compared `fs::read_link` output
against the central path, which silently failed for junctions: the link
state fell back to Broken/LinkedElsewhere, so enabled experts kept
reappearing as disabled and disappeared from the message-input expert
list. Switch the link verification to canonicalize both sides (which
transparently follows junctions and symlinks) and add a case-insensitive
path comparison on Windows. Also prefer `junction::get_target` for the
displayed target path.
This commit is contained in:
xintaofei
2026-04-24 14:59:14 +08:00
parent 51598f3b12
commit b79f06f7ae

View File

@@ -398,7 +398,18 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> {
Ok(()) Ok(())
} }
/// Best-effort human-readable link target. On Windows, `fs::read_link`
/// does not resolve junctions in all stdlib versions — prefer the
/// `junction` crate when the path is a reparse point.
fn read_link_target(path: &Path) -> Option<PathBuf> { fn read_link_target(path: &Path) -> Option<PathBuf> {
#[cfg(windows)]
{
if path_is_reparse_point(path) {
if let Ok(target) = junction::get_target(path) {
return Some(target);
}
}
}
fs::read_link(path).ok() fs::read_link(path).ok()
} }
@@ -425,53 +436,64 @@ fn path_is_reparse_point(_path: &Path) -> bool {
false false
} }
fn classify_link(link_path: &Path, expected_target: &Path) -> ExpertLinkState { /// Equality check for two already-canonicalized paths. On Windows the
if !link_path.exists() && !path_is_symlink(link_path) { /// filesystem is case-insensitive but `Path` comparison is not — canonical
return ExpertLinkState::NotLinked; /// forms can still differ in drive-letter case or user-supplied casing.
fn paths_equivalent(a: &Path, b: &Path) -> bool {
if a == b {
return true;
} }
// Broken symlink detection: `exists()` returns false if the link #[cfg(windows)]
// dangles; but we already checked that. Re-check the metadata. {
match fs::symlink_metadata(link_path) { let a_s = a.as_os_str().to_string_lossy();
Ok(meta) => { let b_s = b.as_os_str().to_string_lossy();
let is_link_like = meta.file_type().is_symlink() || path_is_reparse_point(link_path); return a_s.eq_ignore_ascii_case(b_s.as_ref());
if !is_link_like { }
return ExpertLinkState::BlockedByRealDirectory; #[cfg(not(windows))]
} {
// Verify the target. false
match read_link_target(link_path) { }
Some(target) => { }
// Normalize both sides by canonicalizing the expected
// target. The link target may be relative. /// Resolve a path while following symlinks and Windows junctions.
let target = if target.is_absolute() { /// Returns `None` if the path does not exist or cannot be resolved (e.g.
target /// dangling link).
} else if let Some(parent) = link_path.parent() { fn resolve_real_path(path: &Path) -> Option<PathBuf> {
parent.join(target) fs::canonicalize(path).ok()
} else { }
target
}; fn classify_link(link_path: &Path, expected_target: &Path) -> ExpertLinkState {
let canonical_target = fs::canonicalize(&target).ok(); // No entry at all (not even a dangling link) → not linked.
let canonical_expected = fs::canonicalize(expected_target).ok(); let meta = match fs::symlink_metadata(link_path) {
match (canonical_target.as_ref(), canonical_expected.as_ref()) { Ok(m) => m,
(Some(t), Some(e)) if t == e => ExpertLinkState::LinkedToCodeg, Err(_) => return ExpertLinkState::NotLinked,
(Some(_), Some(_)) => ExpertLinkState::LinkedElsewhere, };
(None, _) => ExpertLinkState::Broken,
(_, None) => ExpertLinkState::LinkedElsewhere, let is_link_like = meta.file_type().is_symlink() || path_is_reparse_point(link_path);
} if !is_link_like {
} // A real directory (or file) sits where we'd put our link.
None => { // This also covers Windows copy-mode fallback, where we could not
// On Windows junctions, `read_link` may fail but the // create a junction and fell back to `copy_dir_recursive`. We still
// directory still resolves. Fall back to canonical // surface it as BlockedByRealDirectory so experts_link_to_agent
// comparison via the path itself. // treats it as "needs user attention" (the copy will not track
let canonical_link = fs::canonicalize(link_path).ok(); // central-store updates and must be re-linked explicitly).
let canonical_expected = fs::canonicalize(expected_target).ok(); return ExpertLinkState::BlockedByRealDirectory;
match (canonical_link, canonical_expected) { }
(Some(t), Some(e)) if t == e => ExpertLinkState::LinkedToCodeg,
_ => ExpertLinkState::LinkedElsewhere, // `fs::canonicalize` transparently follows both symlinks and Windows
} // junctions, so comparing the two canonical forms is the single
} // source of truth for "does this link point at our central store?".
} // We intentionally do *not* rely on `fs::read_link`'s string output
} // for equality — on Windows junctions its output format is
Err(_) => ExpertLinkState::NotLinked, // stdlib-version-dependent and often fails to round-trip through
// `canonicalize` cleanly.
let resolved_link = resolve_real_path(link_path);
let resolved_expected = resolve_real_path(expected_target);
match (resolved_link, resolved_expected) {
(None, _) => ExpertLinkState::Broken,
(Some(l), Some(e)) if paths_equivalent(&l, &e) => ExpertLinkState::LinkedToCodeg,
_ => ExpertLinkState::LinkedElsewhere,
} }
} }