craft-update: resolve the whole default folders, not just LOCALAPPDATA

Review follow-up (CodeRabbit on #423). If LOCALAPPDATA\Programs or Programs\ArtCraft is itself a
junction, the exe's folder resolves through it but a default path built from a resolved
LOCALAPPDATA does not, so a default install was planned as a chosen folder. The Windows layer now
resolves each complete default folder (new default_folders) and hands them to the planner, which
only compares.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
JeremyandClaude Opus 5.5 committed 2026-10-29 13:50:36 +00:00
1 parent eff2500447
commit 25c2e9ffc5
1 file changed
+37 -11
+37 -11
View File
@@ -32,12 +32,21 @@ pub(crate) enum Step {
Move { product_code: String, folder: PathBuf, old_folder: PathBuf },
}
/// Where this account's files live: `USERPROFILE`, `LOCALAPPDATA` and `APPDATA`, which may be
/// redirected away from one another.
/// Where this account's files live. `roots` are `USERPROFILE`, `LOCALAPPDATA` and `APPDATA`, which
/// may be redirected away from one another; `defaults` are the app's per-user default folders
/// ([`default_folders`]). All resolved the way the exe's folder is, so links and `subst` drives
/// compare as the same place.
#[derive(Clone, Debug, Default)]
pub(crate) struct Profile {
pub roots: Vec<PathBuf>,
pub local_app_data: Option<PathBuf>,
pub defaults: Vec<PathBuf>,
}
/// The per-user default folders of `app_name` under `local_app_data`: the suite folder
/// `Programs\ArtCraft\<App>`, then the older `Programs\<App>`.
pub(crate) fn default_folders(local_app_data: &Path, app_name: &str) -> [PathBuf; 2] {
let programs = local_app_data.join("Programs");
[programs.join("ArtCraft").join(app_name), programs.join(app_name)]
}
/// The step for a copy whose exe is in `dir`. `resolved` is its confirmed context and product
@@ -49,11 +58,7 @@ pub(crate) fn plan(resolved: Option<(MsiScope, String)>, dir: &Path, app_name: &
return Step::Update(scope_arguments(scope));
}
let stay = Step::Update(scope_arguments(MsiScope::PerUser));
let defaults = profile.local_app_data.iter().flat_map(|local| {
let programs = local.join("Programs");
[programs.join("ArtCraft").join(app_name), programs.join(app_name)]
});
let folder = if defaults.into_iter().any(|default| same_path(&default, dir)) {
let folder = if profile.defaults.iter().any(|default| same_path(default, dir)) {
match program_files {
Some(root) => root.join("ArtCraft").join(app_name),
None => return stay,
@@ -124,11 +129,14 @@ pub(crate) mod windows {
let resolved = crate::msi_copy::windows::resolve(app, exe)?;
let dir = exe.parent().ok_or_else(|| format!("{} isn't in a folder", app.name))?;
let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
// Resolved like `dir`, so a profile behind a junction or a `subst` drive still matches.
// Resolved like `dir`, whole path at a time, so a profile or a default folder reached
// through a junction or a `subst` drive still matches.
let canonical = |p: PathBuf| dunce::canonicalize(&p).unwrap_or(p);
let profile = Profile {
roots: ["USERPROFILE", "LOCALAPPDATA", "APPDATA"].into_iter().filter_map(env_path).map(canonical).collect(),
local_app_data: crate::folders::data_local_dir().map(canonical),
defaults: crate::folders::data_local_dir()
.map(|local| super::default_folders(&local, app.name).into_iter().map(canonical).collect())
.unwrap_or_default(),
};
// This process has the package's architecture (the helper is a copy of the app), so its
// own `ProgramFiles` is the right root: `Program Files (x86)` for a 32-bit app.
@@ -165,10 +173,28 @@ mod tests {
fn profile() -> Profile {
Profile {
roots: vec![p(r"C:\Users\Ann"), p(r"C:\Users\Ann\AppData\Local"), p(r"C:\Users\Ann\AppData\Roaming")],
local_app_data: Some(p(r"C:\Users\Ann\AppData\Local")),
defaults: [r"C:\Users\Ann\AppData\Local\Programs\ArtCraft\GridCraft", r"C:\Users\Ann\AppData\Local\Programs\GridCraft"].map(p).to_vec(),
}
}
#[test]
fn the_default_folders_are_the_suite_folder_then_the_older_one() {
let local = p(r"C:\Users\Ann\AppData\Local");
let expected = [local.join("Programs").join("ArtCraft").join("GridCraft"), local.join("Programs").join("GridCraft")];
assert_eq!(default_folders(&local, "GridCraft"), expected);
}
#[test]
fn a_default_folder_reached_through_a_link_still_moves_to_the_suite_folder() {
// The Windows layer resolves each default folder whole, so a linked `Programs` arrives
// here as the real path the exe's folder also resolves to.
let old = r"D:\Redirected\Programs\ArtCraft\GridCraft";
let linked = Profile { defaults: vec![p(old)], ..profile() };
let suite = pf().map(|root| root.join("ArtCraft").join("GridCraft")).unwrap();
let expected = Step::Move { product_code: CODE.to_string(), folder: suite, old_folder: p(old) };
assert_eq!(plan(user(), &p(old), "GridCraft", &linked, pf()), expected);
}
fn user() -> Option<(MsiScope, String)> {
Some((MsiScope::PerUser, CODE.to_string()))
}