Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 136 additions & 17 deletions libs/cua-driver/rust/crates/cua-driver/src/autostart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,30 +132,58 @@ pub fn kick() -> Result<()> {
platform::kick()
}

/// Find the cua-driver executable to bake into the autostart entry.
/// Uses `std::env::current_exe`, canonicalised to its real path (resolves
/// junction / symlink chains so a versioned upgrade flipping `current`
/// stays transparent to the registered task). The resolved path is what
/// gets stored in the Scheduled Task / LaunchAgent / unit file.
/// Find the cua-driver executable to bake into the autostart entry. The
/// resolved path is what gets stored in the Scheduled Task / LaunchAgent /
/// unit file.
///
/// On Windows the path is used **as invoked**, without canonicalisation.
/// Canonicalising resolves the `bin -> current -> releases/<version>` junction
/// chain down to a versioned release path, which then gets baked into the
/// Scheduled Task — so the next upgrade (which only flips `current`) leaves the
/// task launching the previous build. Keeping the junction path is what makes a
/// versioned upgrade transparent to the registered task.
///
/// Elsewhere the path is canonicalised (best-effort — on error the path is
/// used as invoked) to resolve symlink chains.
fn current_exe_for_autostart() -> Result<String> {
let exe = std::env::current_exe()
.map_err(|e| anyhow!("could not resolve current executable: {e}"))?;
let canonical = std::fs::canonicalize(&exe).unwrap_or(exe);
let path = canonical.to_string_lossy().into_owned();
// On Windows, `canonicalize` returns a `\\?\C:\...` extended-length
// path. PowerShell + the Task Scheduler XML schema both handle it
// correctly, but it looks alarming in `schtasks /Query` output.
// Strip the prefix for readability — the unprefixed form is still
// valid as long as the path fits MAX_PATH (260 chars), which any
// realistic install will.
#[cfg(target_os = "windows")]
let path = path
.strip_prefix(r"\\?\")
.map(str::to_owned)
.unwrap_or(path);
let resolved = exe;
#[cfg(not(target_os = "windows"))]
let resolved = std::fs::canonicalize(&exe).unwrap_or(exe);
let path = resolved.to_string_lossy().into_owned();
// Defensive: should the path ever arrive in the `\\?\C:\...`
// extended-length form, strip the prefix for readability. PowerShell and
// the Task Scheduler XML schema handle both forms correctly, but the
// prefixed one looks alarming in `schtasks /Query` output.
//
// Only the plain drive-letter form is stripped, and only while the result
// still fits MAX_PATH: `\\?\UNC\server\share\...` is a different namespace
// (stripping it yields a bogus `UNC\server\...`), and a path longer than
// 260 chars needs the prefix to remain addressable.
#[cfg(target_os = "windows")]
let path = windows_task_path(path);
Ok(path)
}

#[cfg(any(target_os = "windows", test))]
fn windows_task_path(path: String) -> String {
match path.strip_prefix(r"\\?\") {
Some(stripped) if stripped.len() < 260 && starts_with_drive_letter(stripped) => {
stripped.to_owned()
}
_ => path,
}
}

/// `true` when the path opens with a plain `<drive>:` — i.e. not the
/// `UNC\server\share` form the extended-length namespace also carries.
#[cfg(any(target_os = "windows", test))]
fn starts_with_drive_letter(path: &str) -> bool {
matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic())
}

// ── Windows impl ──────────────────────────────────────────────────────────

#[cfg(target_os = "windows")]
Expand Down Expand Up @@ -505,6 +533,97 @@ pub fn run_autostart_cmd(subcommand: &str) {
mod tests {
use super::*;

#[test]
fn windows_task_path_strips_short_extended_drive_prefix() {
assert_eq!(
windows_task_path(r"\\?\C:\Users\Example\cua-driver.exe".to_owned()),
r"C:\Users\Example\cua-driver.exe"
);
}

#[test]
fn windows_task_path_preserves_extended_unc_path() {
let path = r"\\?\UNC\server\share\cua-driver.exe";
assert_eq!(windows_task_path(path.to_owned()), path);
}

#[test]
fn windows_task_path_preserves_extended_long_drive_path() {
let path = format!(r"\\?\C:\{}\cua-driver.exe", "nested".repeat(50));
assert!(path.len() >= 260);
assert_eq!(windows_task_path(path.clone()), path);
}

#[cfg(target_os = "windows")]
#[test]
fn child_reports_current_exe_for_junction_probe() {
let Ok(output_path) = std::env::var("CUA_CURRENT_EXE_PROBE_OUTPUT") else {
return;
};
std::fs::write(output_path, current_exe_for_autostart().unwrap()).unwrap();
}

#[cfg(target_os = "windows")]
#[test]
fn current_exe_preserves_installer_junction_chain() {
use std::process::Command;

let temp = tempfile::tempdir().unwrap();
let releases = temp.path().join("packages").join("releases");
let release = releases.join("probe-v1");
let current = temp.path().join("packages").join("current");
let visible = temp.path().join("bin");
std::fs::create_dir_all(&release).unwrap();

let test_exe = std::env::current_exe().unwrap();
let exe_name = test_exe.file_name().unwrap();
std::fs::copy(&test_exe, release.join(exe_name)).unwrap();

for (link, target) in [(&current, &release), (&visible, &current)] {
let output = Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(link)
.arg(target)
.output()
.unwrap();
assert!(
output.status.success(),
"failed to create junction {} -> {}: {}{}",
link.display(),
target.display(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}

let visible_exe = visible.join(exe_name);
let probe_output = temp.path().join("current-exe.txt");
let child = Command::new(&visible_exe)
.args([
"--exact",
"autostart::tests::child_reports_current_exe_for_junction_probe",
"--nocapture",
])
.env("CUA_CURRENT_EXE_PROBE_OUTPUT", &probe_output)
.output()
.unwrap();
assert!(
child.status.success(),
"junction-launched child failed: {}{}",
String::from_utf8_lossy(&child.stdout),
String::from_utf8_lossy(&child.stderr)
);

let observed = std::fs::read_to_string(&probe_output).unwrap();
let observed = windows_task_path(observed.trim().to_owned());
let expected = visible_exe.to_string_lossy();
assert_eq!(
observed.to_ascii_lowercase(),
expected.to_ascii_lowercase(),
"Windows resolved the invoked installer junction path to a release path"
);
}

#[test]
fn successful_task_query_is_registered() {
assert_eq!(
Expand Down
84 changes: 84 additions & 0 deletions libs/cua-driver/scripts/tests/install-windows-regression.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,87 @@ finally {
$env:CUA_DRIVER_RS_INSTALL_DIR = $savedInstallDir
$env:CUA_DRIVER_RS_HOME = $savedHome
}

$rustWorkspace = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) "rust"
Push-Location $rustWorkspace
try {
& cargo test -p cua-driver --bin cua-driver 'autostart::tests::' --locked -- --nocapture
if ($LASTEXITCODE -ne 0) {
throw "Windows autostart path tests failed with exit $LASTEXITCODE"
}

& cargo build -p cua-driver --bin cua-driver --locked
if ($LASTEXITCODE -ne 0) {
throw "Windows autostart task probe build failed with exit $LASTEXITCODE"
}
}
finally {
Pop-Location
}

# Exercise the installed topology and Task Scheduler end to end. Register the
# task through bin -> current -> probe-v1, retarget current to probe-v2, then
# prove the unchanged action reaches the new executable.
$taskName = "cua-driver-serve"
$taskProbeRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("cua-driver-autostart-task-" + [guid]::NewGuid().ToString("N"))
$taskPackages = Join-Path $taskProbeRoot "packages"
$taskReleaseV1 = Join-Path $taskPackages "releases\probe-v1"
$taskReleaseV2 = Join-Path $taskPackages "releases\probe-v2"
$taskCurrent = Join-Path $taskPackages "current"
$taskVisible = Join-Path $taskProbeRoot "bin"
$taskVisibleExe = Join-Path $taskVisible "cua-driver.exe"
$taskMarker = Join-Path $taskProbeRoot "probe-v2.txt"
$realSchtasks = Join-Path $env:SystemRoot "System32\schtasks.exe"

try {
New-Item -ItemType Directory -Force -Path $taskReleaseV1, $taskReleaseV2 | Out-Null
Copy-Item -LiteralPath (Join-Path $rustWorkspace "target\debug\cua-driver.exe") `
-Destination (Join-Path $taskReleaseV1 "cua-driver.exe")

$markerLiteral = $taskMarker.Replace('\', '\\').Replace('"', '\"')
$probeSourcePath = Join-Path $taskProbeRoot "probe-v2.rs"
$probeSource = @"
fn main() {
std::fs::write("$markerLiteral", "probe-v2").unwrap();
}
"@
Set-Content -LiteralPath $probeSourcePath -Value $probeSource -Encoding UTF8
& rustc $probeSourcePath -o (Join-Path $taskReleaseV2 "cua-driver.exe")
if ($LASTEXITCODE -ne 0) {
throw "probe-v2 build failed with exit $LASTEXITCODE"
}

New-Item -ItemType Junction -Path $taskCurrent -Target $taskReleaseV1 | Out-Null
New-Item -ItemType Junction -Path $taskVisible -Target $taskCurrent | Out-Null

& $taskVisibleExe autostart enable
if ($LASTEXITCODE -ne 0) {
throw "autostart task registration failed with exit $LASTEXITCODE"
}
$beforeAction = (Get-ScheduledTask -TaskName $taskName -ErrorAction Stop).Actions | Select-Object -First 1
Assert-True ($beforeAction.Arguments -like "*$taskVisibleExe*") `
"Scheduled Task action did not retain the visible junction path: $($beforeAction.Arguments)"

[System.IO.Directory]::Delete($taskCurrent, $false)
New-Item -ItemType Junction -Path $taskCurrent -Target $taskReleaseV2 | Out-Null
$afterAction = (Get-ScheduledTask -TaskName $taskName -ErrorAction Stop).Actions | Select-Object -First 1
Assert-True ($afterAction.Arguments -eq $beforeAction.Arguments) `
"Scheduled Task action changed when the current junction was retargeted"

& $realSchtasks /Run /TN $taskName | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "retargeted autostart task failed to start with exit $LASTEXITCODE"
}
for ($attempt = 0; $attempt -lt 100 -and -not (Test-Path -LiteralPath $taskMarker); $attempt++) {
[System.Threading.Thread]::Sleep(100)
}
Assert-True (Test-Path -LiteralPath $taskMarker) "retargeted task did not launch probe-v2"
Assert-True ((Get-Content -LiteralPath $taskMarker -Raw) -eq "probe-v2") `
"retargeted task did not report the next probe version"

Write-Host "Windows Scheduled Task junction-retarget regression check passed."
}
finally {
& $realSchtasks /End /TN $taskName 2>$null | Out-Null
& $realSchtasks /Delete /TN $taskName /F 2>$null | Out-Null
}
Loading