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
19 changes: 19 additions & 0 deletions .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,25 @@ jobs:
- name: Rust regression tests
run: cargo test --locked --lib --bins
working-directory: desktop/src-tauri
# Hosted Windows runners are administrators. Exercise setup again with an actual Users-only
# account: DISM's feature reads worked as CI's administrator but refused ordinary app users.
- name: Windows standard-user setup regression
if: matrix.platform.name == 'windows'
shell: pwsh
working-directory: desktop/src-tauri
run: |
$messages = @(cargo test --locked --lib --no-run --message-format=json)
if ($LASTEXITCODE -ne 0) { throw 'Could not build the native setup test.' }
$executables = @($messages | ForEach-Object { $_ | ConvertFrom-Json } | Where-Object {
$_.reason -eq 'compiler-artifact' -and $_.target.name -eq 'openbot_desktop_lib' -and
$_.profile.test -and $_.executable
} | ForEach-Object { $_.executable } | Select-Object -Unique)
if ($executables.Count -ne 1) { throw 'Expected exactly one current desktop library test executable.' }
# Windows PowerShell's credentialed launch matches the desktop and native validation.
# PowerShell Core inherited the CI runner's profile directories into the temporary user.
$windowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
& $windowsPowerShell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File ../scripts/test-windows-standard-user.ps1 -TestExecutable $executables[0]
if ($LASTEXITCODE -ne 0) { throw 'The native standard-user setup test failed.' }
# Keep what was built. Without this the only way to try an installer is to build one on
# the machine you are trying it on, which is not what anybody installs.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
Expand Down
173 changes: 173 additions & 0 deletions desktop/scripts/test-windows-standard-user.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
#Requires -Version 5.1
#Requires -RunAsAdministrator
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$TestExecutable,
[ValidateRange(10, 600)]
[int]$TimeoutSeconds = 120
)

$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest

# A limited token belonging to an administrator is not this regression boundary.
# Create a fresh account whose only local group is Users, and run the actual Rust test there.
$source = (Get-Item -LiteralPath $TestExecutable).FullName
$testName = 'windows::tests::native_standard_user_setup_probe'
$listed = & $source --list --ignored --exact $testName
if ($LASTEXITCODE -ne 0 -or $listed -notcontains "${testName}: test") {
throw "The supplied executable does not contain the ignored native standard-user setup test."
}

$suffix = [Guid]::NewGuid().ToString('N')
$userName = "obci_$($suffix.Substring(0, 12))"
$directory = Join-Path $env:ProgramData "OpenBot-standard-user-$suffix"
$userCreated = $false
$directoryCreated = $false
$process = $null
$userSid = $null
$password = $null
$securePassword = $null
$passwordBytes = New-Object byte[] 48
$random = [Security.Cryptography.RandomNumberGenerator]::Create()

try {
# The password only reaches the account/process APIs in memory. It is never written into the
# wrapper, environment, command-line arguments, logs, or a credential file.
$random.GetBytes($passwordBytes)
$password = 'aZ9!' + [Convert]::ToBase64String($passwordBytes)
$securePassword = ConvertTo-SecureString $password -AsPlainText -Force
$user = New-LocalUser -Name $userName -Password $securePassword `
-Description 'OpenBot standard-user regression test' `
-AccountExpires (Get-Date).AddHours(1)
$userCreated = $true
$userSid = $user.SID
$usersSid = [Security.Principal.SecurityIdentifier]'S-1-5-32-545'
Add-LocalGroupMember -SID $usersSid -Member $user
$memberships = @(Get-LocalGroup | Where-Object {
@(Get-LocalGroupMember -SID $_.SID | Where-Object { $_.SID -eq $userSid }).Count -gt 0
})
if ($memberships.Count -ne 1 -or $memberships[0].SID -ne $usersSid) {
throw 'The temporary account must belong only to the local Users group.'
}

New-Item -ItemType Directory -Path $directory | Out-Null
$directoryCreated = $true
$acl = New-Object Security.AccessControl.DirectorySecurity
$acl.SetAccessRuleProtection($true, $false)
foreach ($sid in @('S-1-5-18', 'S-1-5-32-544', $userSid.Value)) {
$rights = if ($sid -eq $userSid.Value) { 'Modify' } else { 'FullControl' }
$rule = New-Object Security.AccessControl.FileSystemAccessRule(
[Security.Principal.SecurityIdentifier]$sid, $rights,
'ContainerInherit, ObjectInherit', 'None', 'Allow'
)
$acl.AddAccessRule($rule)
}
Set-Acl -LiteralPath $directory -AclObject $acl
Copy-Item -LiteralPath $source -Destination (Join-Path $directory 'native-test.exe')

# Windows PowerShell is available to the new user without depending on the CI user's PATH.
# This wrapper contains no credentials. Its output and atomic result file are the only IPC.
$wrapper = @'
param([Parameter(Mandatory)][string]$ExpectedSid)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$exitCode = 1
try {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
if ($identity.User.Value -ne $ExpectedSid) { throw 'The test did not start as the temporary user.' }
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if ($principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'The test process has administrator privileges.'
}
$profile = [Environment]::GetFolderPath('UserProfile')
$registeredProfile = (Get-ItemProperty -LiteralPath "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\$ExpectedSid").ProfileImagePath
if (-not $profile -or $profile -ne [Environment]::ExpandEnvironmentVariables($registeredProfile)) {
throw 'The temporary account profile was not loaded.'
}
if ($env:USERPROFILE -ne $profile -or
$env:APPDATA -ne [Environment]::GetFolderPath('ApplicationData') -or
$env:LOCALAPPDATA -ne [Environment]::GetFolderPath('LocalApplicationData')) {
throw 'The test inherited folders from a different user profile.'
}
@{ identity = $identity.Name; sid = $ExpectedSid; profile = $env:USERPROFILE; elevated = $false } |
ConvertTo-Json -Compress | Set-Content -LiteralPath (Join-Path $PSScriptRoot 'identity.log')
$test = Start-Process -FilePath (Join-Path $PSScriptRoot 'native-test.exe') `
-ArgumentList @('--ignored', '--exact', '--nocapture', 'windows::tests::native_standard_user_setup_probe') `
-WorkingDirectory $PSScriptRoot -NoNewWindow -Wait -PassThru `
-RedirectStandardOutput (Join-Path $PSScriptRoot 'stdout.log') `
-RedirectStandardError (Join-Path $PSScriptRoot 'stderr.log')
$exitCode = $test.ExitCode
} catch {
$_ | Out-String | Set-Content -LiteralPath (Join-Path $PSScriptRoot 'wrapper-error.log')
} finally {
@{ exitCode = $exitCode } | ConvertTo-Json -Compress |
Set-Content -LiteralPath (Join-Path $PSScriptRoot 'result.tmp')
Move-Item -LiteralPath (Join-Path $PSScriptRoot 'result.tmp') -Destination (Join-Path $PSScriptRoot 'result.json')
}
exit $exitCode
'@
$wrapperPath = Join-Path $directory 'run.ps1'
Set-Content -LiteralPath $wrapperPath -Value $wrapper -Encoding UTF8
$powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
# Run from the CI runner's administrator account, not LocalSystem. LoadUserProfile gives the
# child its own HKCU hive, which setup reads to find the user's WSL configuration.
# https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/start-process
$credential = New-Object Management.Automation.PSCredential("$env:COMPUTERNAME\$userName", $securePassword)
$process = Start-Process -FilePath $powershell -Credential $credential -LoadUserProfile `
-WorkingDirectory $directory -WindowStyle Hidden -PassThru `
-ArgumentList "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$wrapperPath`" -ExpectedSid $($userSid.Value)"
$password = $null
$securePassword.Dispose()
$securePassword = $null
[Array]::Clear($passwordBytes, 0, $passwordBytes.Length)

if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
throw "Standard-user setup test timed out after $TimeoutSeconds seconds."
}
$resultPath = Join-Path $directory 'result.json'
if (-not (Test-Path -LiteralPath $resultPath)) {
throw "Standard-user wrapper exited without a result (exit code: $($process.ExitCode))."
}
$result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json
if ($result.exitCode -ne 0) {
throw "Standard-user setup test failed with exit code $($result.exitCode)."
}
$stdout = Get-Content -LiteralPath (Join-Path $directory 'stdout.log') -Raw
if ($stdout -notmatch 'test result: ok\. 1 passed; 0 failed; 0 ignored;') {
throw 'The standard-user executable did not report exactly one passing native test.'
}
Write-Host 'Verified Windows setup detection under a real standard-user account.'
} finally {
$password = $null
if ($null -ne $securePassword) { $securePassword.Dispose() }
[Array]::Clear($passwordBytes, 0, $passwordBytes.Length)
$random.Dispose()
$cleanupErrors = @()
if ($null -ne $process -and -not $process.HasExited) {
try {
& "$env:SystemRoot\System32\taskkill.exe" /PID $process.Id /T /F | Out-Null
if ($LASTEXITCODE -ne 0 -and -not $process.HasExited) { throw 'Could not stop the native test process tree.' }
if (-not $process.WaitForExit(10000)) { throw 'The native test process did not stop.' }
} catch { $cleanupErrors += $_.Exception.Message }
}
if ($directoryCreated) {
foreach ($name in @('identity.log', 'stdout.log', 'stderr.log', 'wrapper-error.log')) {
$path = Join-Path $directory $name
if (Test-Path -LiteralPath $path) { Get-Content -LiteralPath $path }
}
}
if ($userCreated) {
try {
Get-CimInstance Win32_UserProfile -Filter "SID='$($userSid.Value)'" | Remove-CimInstance
} catch { $cleanupErrors += $_.Exception.Message }
try { Remove-LocalUser -SID $userSid } catch { $cleanupErrors += $_.Exception.Message }
}
if ($directoryCreated) {
try { Remove-Item -LiteralPath $directory -Recurse -Force } catch { $cleanupErrors += $_.Exception.Message }
}
if ($cleanupErrors.Count -gt 0) {
throw "Standard-user test cleanup failed: $($cleanupErrors -join '; ')"
}
}
90 changes: 83 additions & 7 deletions desktop/src-tauri/src/acquire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,24 @@ fn command_failure(binary: &str, args: &[&str], output: &std::process::Output) -
}

fn machine_exists_with(run: impl FnOnce() -> Result<String, String>) -> Result<bool, String> {
let names = run()?;
Ok(names.lines().any(|name| name.trim() == MACHINE))
#[derive(Deserialize)]
struct ListedMachine {
#[serde(rename = "Name")]
name: String,
}

let listing = run()?;
let machines: Vec<ListedMachine> = serde_json::from_str(&listing).map_err(|error| {
format!("could not read podman machine list JSON: {error}; stdout: {listing}")
})?;
Ok(machines.iter().any(|machine| machine.name == MACHINE))
}

/// Does this app's machine already exist?
pub fn machine_exists() -> Result<bool, String> {
machine_exists_with(|| podman(&["machine", "list", "--quiet"]))
// --quiet still uses Podman's human format, which appends '*' to the default machine's name.
// JSON preserves the raw Name, independently of whether the machine is running or default.
machine_exists_with(|| podman(&["machine", "list", "--format", "json"]))
}

/// Create the machine.
Expand Down Expand Up @@ -537,9 +548,41 @@ mod tests {
}

#[test]
fn machine_existence_uses_the_quiet_machine_list_names() {
assert!(machine_exists_with(|| Ok("default\nopenbot\n".into())).unwrap());
assert!(!machine_exists_with(|| Ok("default\nopenbot-old\n".into())).unwrap());
fn machine_existence_uses_exact_json_names() {
for (listing, expected) in [
(r#"[{"Name":"default"},{"Name":"openbot"}]"#, true),
(r#"[{"Name":"default"},{"Name":"openbot-old"}]"#, false),
("[]", false),
] {
assert_eq!(
machine_exists_with(|| Ok(listing.into())).unwrap(),
expected
);
}
}

#[test]
fn existing_stopped_default_machine_is_not_initialized_again() {
// Podman 6.1.1 reports this stopped default machine as `openbot*` in --quiet output.
// JSON keeps its raw name and reports default/running state as separate fields.
let listing = r#"[{"Name":"openbot","Default":true,"Running":false,"VMType":"wsl"}]"#;
let mut init_called = false;
let result = create_machine_with(
2,
4096,
20,
|| machine_exists_with(|| Ok(listing.into())),
|_args| {
init_called = true;
Err("machine openbot already exists".into())
},
);
assert!(
!init_called,
"existing stopped default machine was initialized again"
);
assert!(result.ok, "{result:?}");
assert_eq!(result.said, "openbot already exists.");
}

#[test]
Expand All @@ -549,7 +592,11 @@ mod tests {
2,
4096,
20,
|| Err("podman machine list exited with status 125; stdout: denied".into()),
|| {
machine_exists_with(|| {
Err("podman machine list exited with status 125; stdout: denied".into())
})
},
|_args| {
init_called = true;
Ok(String::new())
Expand All @@ -568,6 +615,35 @@ mod tests {
);
}

#[test]
fn malformed_machine_list_stops_create_and_keeps_the_response() {
for listing in [
"openbot*",
"",
"{}",
"null",
r#"[{"Name":null}]"#,
r#"[{"Running":false}]"#,
] {
let result = create_machine_with(
2,
4096,
20,
|| machine_exists_with(|| Ok(listing.into())),
|_args| panic!("machine init must not run after malformed list output"),
);
assert!(!result.ok, "{result:?}");
let detail = result
.detail
.expect("malformed listing needs diagnostic detail");
assert!(
detail.contains("could not read podman machine list JSON"),
"{detail}"
);
assert!(detail.ends_with(&format!("stdout: {listing}")), "{detail}");
}
}

#[test]
fn absent_machine_creates_with_requested_resources() {
let mut captured = Vec::new();
Expand Down
Loading