fix(windows): create the shortcut directory before writing the shortcut - #400
Conversation
`create_shortcut` handed a path straight to `mslnk`, which does a plain file create and will not build the path leading to it. When the Desktop or Start Menu directory does not exist - trimmed images, redirected `%PUBLIC%`, some corporate policies - the shortcut step fails and, since it propagates with `?`, the whole install fails. That is the cause underneath sftwrdotdev#122; sftwrdotdev#364 made the failure clean and named the directory, but the directory still never got created. Each missing level is created separately rather than through `create_dir_all`, so every level can be recorded as its own `InstallStep::CreatedDir` and sftwrdotdev#364's existing rollback removes them deepest-first. `AlreadyExists` is tolerated but records nothing - we did not create it, so we must not delete it. A failure to create the directory still fails the install rather than skipping the shortcut: sftwrdotdev#364 defined exactly two outcomes, complete or rolled back, and a third one - "installed, minus the icon you asked for" - is sftwrdotdev#122's symptom with the actionable error removed. Routing the error through `io_error_message` also keeps the locale-independent `ELEVATION_REQUIRED:` prefix the frontend branches on. Uninstall does not remove these directories. Rollback runs seconds after creation, inside one operation, while the directory is still empty; at uninstall time arbitrary time has passed and the directory is the user's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Windows 1 — CRLF, the same trap as #389. No 2 — the race test was accidentally green on macOS. Its
So every ancestor level was noise, and that noise happens to have a platform-dependent error kind. The closure is now Production is not affected: No assertion was weakened — both got stronger. The source-text test still requires
Worth noting for a separate decision: this is the second PR bitten by the missing |
3fdbcdb to
4717e95
Compare
|
Withdrawing the The exposure is two call sites, both of which are now fixed at the read. Everything else that reads source as text (the ~44 TypeScript convention tests) matches with patterns that don't depend on exact newlines, which is why So a repo-wide checkout policy would be guarding an empty set, and it would only flatten the environment rather than make the pattern robust — a contributor whose editor writes CRLF would still hit it. Please disregard; nothing to decide here. |
The cause underneath #122, still present after #364.
The defect
create_shortcutbuildsdir.join("Markpad.lnk")and hands it tomslnk, which does a plain file create and will not build the path leading to it. There is nocreate_dir_all, no existence check, anywhere between thePathBuf::from(directory)andcreate_lnk.Both call sites are in
perform_installstep 3, and the directories come from environment variables:all_users%PUBLIC%\Desktop/%USERPROFILE%\Desktop%ProgramData%\…\Start Menu\Programs/%APPDATA%\…\Start Menu\ProgramsBoth propagate with
?, so either one aborts the install. #364 made that abort clean and named the directory in the error — but the directory still never gets created, so the user still cannot install.The fix
Two new platform-independent functions wired in before
ShellLink::new:missing_directories(dir, exists)— the levels to create, shallowest firstensure_directory(dir, exists, create, steps)— creates each level, pushing anInstallStep::CreatedDirper levelPer-level rather than
create_dir_all: on a profile missing the whole…\Start Menu\Programschain,create_dir_allwould create two directories but let us record only one. Recording each level in creation order means #364'srollback_actionsreverses them and its already-non-recursive, already-only-if-we-created-itremove_dirremoves the deepest first. No change to the rollback machinery was needed — the new steps ride it unchanged.AlreadyExistsis tolerated but records nothing. We did not create it, so we must not delete it — same semantics ascreate_dir_all.Judgement call: a failed
create_dirfails the install, it does not skip the shortcutio_error_message, anERROR_ACCESS_DENIEDon%PUBLIC%\Desktopstill emits the locale-independentELEVATION_REQUIRED:prefix the frontend branches on. Skipping destroys that signal.io_error_messageis used rather than the existingdescribeclosure because this is a realio::Error, so the permission case can be read offerror.kind()instead of guessed at with a second write probe. (describeexists becausemslnk's error is not anio::Error.)Uninstall does not remove these directories. Even a non-recursive "only if empty" removal would target
%USERPROFILE%\Desktopor the sharedStart Menu\Programs. An empty Desktop is a normal state, andProgramsbelongs to every application. The asymmetry with rollback is deliberate: rollback runs seconds after creation, inside one operation, while the directory is still empty — that is the only window where "we made it and nobody has used it" holds.Tests
Eight new. Six run against a real filesystem on macOS:
ensure_directorytakesexists/createas closures, so the tests drive it withstd::fsagainst a real temp directory. They cover creating a missing two-level chain, handing each created level torollback_actionsdeepest-first, recording nothing for an existing directory, tolerating a directory that appears underneath us, and returning an error rather than skipping.One is a source-text assertion, for the only thing unreachable from macOS: whether
create_shortcutcalls the helper at all, and beforecreate_lnk. It slices out just that function body so it cannot match its own text. It proves the call exists and is ordered. It proves nothing about Windows behaviour or aboutmslnk. No Windows run was performed.mastermissing.reverse(), recordAlreadyExistsas ours, unwire the callA Windows-only compile error this caught
Passing
fs::create_dirdirectly as thecreateargument does not compile for a Windows target — the generic function item binds one concrete lifetime and fails the higher-rankedFn(&Path)bound. macOS CI can never surface that. I built a stub harness and compiledsetup.rsfor a realx86_64-pc-windows-msvctarget: clean, and reverting that one line reproduces the error. (Fullcargo check --targetis impossible here —ringneeds a Windows C toolchain.) The call site uses a closure, with a comment saying why.Worth knowing: this changes the failure mode, it does not make it right
These directories come from environment variables, not
SHGetKnownFolderPath.%USERPROFILE%\Desktopis simply wrong when the Desktop is redirected — OneDrive folder backup, or Group Policy redirection to a network share, both common on exactly the enterprise images #122 is reported from.After this fix that case changes from "install fails" to "install succeeds, and a stray
%USERPROFILE%\Desktopis created with the shortcut in it, where the shell does not show it." Still a strict improvement — the app installs, and the Start Menu shortcut lands in the rarely-redirected%APPDATA%path — but it is a different failure mode and you should know.The proper fix is
SHGetKnownFolderPathwithFOLDERID_Desktop/CommonDesktopDirectory/Programs/CommonPrograms. The crate already depends onwindows 0.61.3for Windows targets, but enabling the Shell feature is aCargo.tomlchange and it is untestable FFI from here, so I left it out rather than ship unverified Windows FFI. Happy to do it if you'd like it.Side audit: one more "assumes a directory exists"
uninstall_appwrites its batch/vbs helpers intoenv::temp_dir()viacreate_new(true), which does not create the parent; Windows'GetTempPathexplicitly does not verify the directory exists. It is already safe by ordering — it happens before shortcut removal and registry deletion and returnsErr, so the installation stays intact and uninstallable. Fixing it means choosing a fallback temp location; separate change.Everything else checks out: install step 1 already
create_dir_alls the install dir,can_write_dirdeliberately probes the nearest existing ancestor, registrycreate_subkeycreates intermediates, and the batch script'srmdiris guarded by a followingif exist.🤖 Generated with Claude Code