From ed051a09c9105574a6821b5b15ba4cd414494406 Mon Sep 17 00:00:00 2001 From: Luca Bolognese Date: Sun, 9 Aug 2026 15:46:04 +0200 Subject: [PATCH 1/4] Add sticky profile selection Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 82b392f2-39a3-43ec-a492-8274728239a7 --- .github/workflows/ci.yml | 10 +- .github/workflows/release.yml | 98 ++++- Cargo.lock | 65 +++ Cargo.toml | 3 + README.md | 26 +- build.rs | 60 +++ docs/guide.md | 57 ++- docs/releasing.md | 52 ++- docs/spec/oscan-spec.md | 34 +- docs/technical-details.md | 27 +- packaging/windows/oscan.wxs | 26 +- scripts/assemble-release.ps1 | 10 + scripts/build-msi.ps1 | 33 +- scripts/install-dev.ps1 | 29 +- scripts/install-latest.ps1 | 10 +- scripts/install-oscan.ps1 | 418 +++++++++++++++---- scripts/install-oscan.sh | 315 +++++++++----- scripts/release_tools.py | 108 ++++- scripts/smoke-release.ps1 | 14 + scripts/stage-release.ps1 | 8 + scripts/stage-release.sh | 16 +- scripts/test_install_latest.py | 2 +- scripts/test_installers.py | 257 ++++++++++-- scripts/test_release_packaging.py | 23 ++ scripts/test_release_workflow.py | 30 +- scripts/test_smoke_release.py | 37 +- src/bin/oscan-default.rs | 31 ++ src/bin/oscan-selector.rs | 67 +++ src/lib.rs | 1 + src/profile_selector.rs | 663 ++++++++++++++++++++++++++++++ tests/profile_selector_cli.rs | 194 +++++++++ 31 files changed, 2389 insertions(+), 335 deletions(-) create mode 100644 src/bin/oscan-default.rs create mode 100644 src/bin/oscan-selector.rs create mode 100644 src/lib.rs create mode 100644 src/profile_selector.rs create mode 100644 tests/profile_selector_cli.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28d9fdc..f77a528 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,7 +191,7 @@ jobs: run: cargo test - name: Archive installer coexistence tests - run: python -m unittest scripts.test_installers.WindowsInstallerCoexistenceTests + run: python -m unittest scripts.test_installers.UnixInstallerCoexistenceTests - name: Integration tests — positive (freestanding) shell: bash @@ -413,6 +413,14 @@ jobs: - name: Run unit tests (53 tests) run: cargo test + - name: Install Python + uses: actions/setup-python@v6 + with: + python-version: "3.x" + + - name: Archive installer coexistence tests + run: python -m unittest scripts.test_installers.WindowsInstallerCoexistenceTests + - name: Integration tests — positive (freestanding) shell: pwsh run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d19d2a..8af88cd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -382,6 +382,34 @@ jobs: throw "The release contract lists no profiles for ${env:RELEASE_TARGET}." } $binRoot = Join-Path $env:RUNNER_TEMP 'oscan-release-binaries' + $sharedBinDir = Join-Path $binRoot 'shared' + New-Item -ItemType Directory -Path $sharedBinDir -Force | Out-Null + Remove-Item Env:OSCAN_DISTRIBUTION_PROFILE, Env:OSCAN_DEFAULT_BACKEND, ` + Env:OSCAN_DISTRIBUTION_BACKEND, Env:OSCAN_INPROCESS_TOOLCHAIN_DIR, ` + Env:OSCAN_INPROCESS_ASSETS_DIR, Env:RUSTFLAGS -ErrorAction SilentlyContinue + $env:OSCAN_VERSION = $env:RELEASE_TAG + if ($env:RELEASE_TARGET -eq 'windows-x86_64') { + $env:RUSTFLAGS = '-C target-feature=+crt-static' + } + Write-Host "::group::cargo build shared profile selectors" + # backend-c satisfies the package build contract without pulling in + # LLVM; selector code itself is backend-independent and is built once. + & cargo build --release --no-default-features --features backend-c ` + --bin oscan-selector --bin oscan-default + if ($LASTEXITCODE -ne 0) { + throw "cargo build failed for the shared profile selectors." + } + $executableSuffix = [System.IO.Path]::GetExtension($env:RELEASE_BINARY_NAME) + foreach ($selectorName in @('oscan-selector', 'oscan-default')) { + $selectorOutput = Join-Path 'target/release' "$selectorName$executableSuffix" + if (-not (Test-Path -LiteralPath $selectorOutput -PathType Leaf)) { + throw "cargo produced no shared selector at $selectorOutput." + } + Copy-Item -LiteralPath $selectorOutput ` + -Destination (Join-Path $sharedBinDir "$selectorName$executableSuffix") -Force + } + Write-Host "::endgroup::" + foreach ($profile in $profiles) { $variant = $variants[$profile] $profileSpec = $contract['profiles'][$profile] @@ -406,7 +434,7 @@ jobs: $env:OSCAN_DISTRIBUTION_PROFILE = $profile $env:OSCAN_DEFAULT_BACKEND = [string]$profileSpec['default_backend'] $env:OSCAN_VERSION = $env:RELEASE_TAG - & cargo build --release --no-default-features --features $featureList + & cargo build --release --no-default-features --features $featureList --bin oscan if ($LASTEXITCODE -ne 0) { throw "cargo build failed for profile '$profile'." } @@ -433,6 +461,14 @@ jobs: $variants = $contract['variants'][$target]['profiles'] $profiles = $env:RELEASE_PROFILES.Split(',', [System.StringSplitOptions]::RemoveEmptyEntries) $binRoot = Join-Path $env:RUNNER_TEMP 'oscan-release-binaries' + $executableSuffix = [System.IO.Path]::GetExtension($env:RELEASE_BINARY_NAME) + $selectorBinary = Join-Path (Join-Path $binRoot 'shared') "oscan-selector$executableSuffix" + $defaultManagerBinary = Join-Path (Join-Path $binRoot 'shared') "oscan-default$executableSuffix" + foreach ($sharedBinary in @($selectorBinary, $defaultManagerBinary)) { + if (-not (Test-Path -LiteralPath $sharedBinary -PathType Leaf)) { + throw "No shared selector binary was built for $target (expected $sharedBinary)." + } + } $outputDir = Join-Path $env:RUNNER_TEMP 'oscan-release-artifacts' $uploadDir = Join-Path $env:GITHUB_WORKSPACE 'release-upload' New-Item -ItemType Directory -Path $uploadDir -Force | Out-Null @@ -456,6 +492,8 @@ jobs: Profile = $profile Version = $version BinaryPath = $binary + SelectorBinaryPath = $selectorBinary + DefaultManagerBinaryPath = $defaultManagerBinary OutputDir = $outputDir } if ($components -contains 'direct_link_sidecar') { @@ -560,7 +598,10 @@ jobs: } $adminRoot = Join-Path $msiScratch 'admin' New-Item -ItemType Directory -Path $adminRoot -Force | Out-Null + $env:LOCALAPPDATA = Join-Path $msiScratch 'local-app-data' + New-Item -ItemType Directory -Path $env:LOCALAPPDATA -Force | Out-Null $produced = @() + $sharedSelectorDigests = $null foreach ($profile in $profiles) { $variant = $contract['variants'][$target]['profiles'][$profile] @@ -569,6 +610,15 @@ jobs: if (-not (Test-Path -LiteralPath $bundleDir -PathType Container)) { throw "The staged $target/$profile bundle is missing at $bundleDir; the MSI must be built from the bundle that was packaged and smoked." } + $bundleSelectorDigests = @( + (Get-FileHash -LiteralPath (Join-Path $bundleDir 'oscan-selector.exe') -Algorithm SHA256).Hash, + (Get-FileHash -LiteralPath (Join-Path $bundleDir 'oscan-default.exe') -Algorithm SHA256).Hash + ) + if ($null -eq $sharedSelectorDigests) { + $sharedSelectorDigests = $bundleSelectorDigests + } elseif (($bundleSelectorDigests -join ',') -ne ($sharedSelectorDigests -join ',')) { + throw "The $profile package does not contain the exact shared selector binaries used by its sibling profiles." + } $msiName = ([string]$variant['msi_name_template']).Replace('{version}', $version) if ($produced -contains $msiName) { throw "Two Windows profiles claim the same MSI name '$msiName'." @@ -593,10 +643,12 @@ jobs: } $installedRoot = Join-Path $adminRoot "PFiles64\Oscan\profiles\$profile\$version" + $installedSelectorDir = Join-Path $adminRoot 'PFiles64\Oscan\bin' & ./scripts/smoke-release.ps1 ` -Target $target ` -Profile $profile ` -InstalledPackageDir $installedRoot ` + -InstalledSelectorDir $installedSelectorDir ` -Version $version ` -ScratchDir (Join-Path $msiScratch "smoke-$profile") if ($LASTEXITCODE -ne 0) { @@ -619,13 +671,51 @@ jobs: if ($missing.Count -gt 0 -or $unexpected.Count -gt 0) { throw "MSI set differs from the release contract. Missing: $($missing -join ', '); unexpected: $($unexpected -join ', ')." } - $selector = Join-Path $adminRoot 'PFiles64\Oscan\bin\oscan.cmd' + $selector = Join-Path $adminRoot 'PFiles64\Oscan\bin\oscan.exe' if (-not (Test-Path -LiteralPath $selector -PathType Leaf)) { throw "The coinstalled MSI image has no shared oscan selector." } - & $selector --version | Out-Null + $defaultManager = Join-Path $adminRoot 'PFiles64\Oscan\bin\oscan-default.exe' + if (-not (Test-Path -LiteralPath $defaultManager -PathType Leaf)) { + throw "The coinstalled MSI image has no oscan-default manager." + } + $selectorVersion = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($selector).ProductVersion + if ($selectorVersion -ne $version) { + throw "The selector PE version is '$selectorVersion', expected '$version'." + } + + foreach ($selection in @('c', 'full')) { + & $defaultManager set $selection | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "Could not explicitly select the '$selection' profile." + } + $selectedVersion = & $selector --version | Out-String + if ($LASTEXITCODE -ne 0 -or $selectedVersion -notmatch "distribution: $selection") { + throw "The shared selector did not dispatch to the explicitly selected '$selection' profile." + } + } + $fullShim = Join-Path $adminRoot 'PFiles64\Oscan\bin\oscan-full.cmd' + $hiddenFullShim = "$fullShim.hidden" + Move-Item -LiteralPath $fullShim -Destination $hiddenFullShim + try { + & $selector --version 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + throw "Removing the explicitly selected profile silently fell back to another MSI profile." + } + } finally { + Move-Item -LiteralPath $hiddenFullShim -Destination $fullShim + } + & $defaultManager auto | Out-Null if ($LASTEXITCODE -ne 0) { - throw "The coinstalled MSI selector failed." + throw "Could not enable automatic profile selection." + } + $automaticMode = & $defaultManager show | Out-String + if ($LASTEXITCODE -ne 0 -or $automaticMode -notmatch 'default profile: auto \(currently full\)') { + throw "The selection manager did not persist automatic mode with the expected current profile." + } + $automaticVersion = & $selector --version | Out-String + if ($LASTEXITCODE -ne 0 -or $automaticVersion -notmatch 'distribution: full') { + throw "Automatic mode did not resolve the expected full > llvm > cranelift > c priority." } - name: Upload packaged artifacts diff --git a/Cargo.lock b/Cargo.lock index 83f9a99..9c94e96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,6 +436,7 @@ dependencies = [ "target-lexicon", "tempfile", "windows-sys", + "winresource", ] [[package]] @@ -546,6 +547,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "sha2" version = "0.10.9" @@ -605,6 +615,45 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "twox-hash" version = "2.1.2" @@ -654,6 +703,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml", + "version_check", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 60bb508..f2b0790 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "oscan" version = "0.1.0" edition = "2021" +default-run = "oscan" # Backend selection at compile time. The default developer build enables # every backend and keeps the existing capability-based default behavior; @@ -58,6 +59,7 @@ tempfile = "3" windows-sys = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", + "Win32_Storage_FileSystem", "Win32_System_Threading", ] } @@ -66,3 +68,4 @@ windows-sys = { version = "0.61", features = [ # time (docs/design/native-link-embedding.md §5.2) using the same crate # `src/backend/native_assets.rs` uses at runtime. sha2 = "0.10" +winresource = "0.1" diff --git a/README.md b/README.md index c9ddc63..0728c15 100644 --- a/README.md +++ b/README.md @@ -62,21 +62,32 @@ Install full, or select a slim package explicitly: ```powershell iwr -useb https://raw.githubusercontent.com/lucabol/Oscan/master/scripts/install-latest.ps1 -OutFile install-latest.ps1 -.\install-latest.ps1 -Profile full -SetDefault +.\install-latest.ps1 -Profile full +oscan-default set full .\install-latest.ps1 -Backend llvm .\install-latest.ps1 -Backend cranelift .\install-latest.ps1 -Backend c ``` Archive profiles coexist as `oscan-full`, `oscan-llvm`, `oscan-cranelift`, and -`oscan-c`. The first archive install safely creates `oscan`; later installs -preserve that selection unless `-SetDefault` is passed. Open a new terminal, -then check: +`oscan-c`. Plain `oscan` uses a per-user sticky selection shared by archive and +MSI installs. Installing or upgrading a profile never changes an existing +selection. If none exists, the first `oscan` invocation chooses the +highest-priority installed profile and persists that exact choice. ```powershell +oscan-default set full # explicit and sticky +oscan-default show oscan --version +oscan-default auto # optional full > llvm > cranelift > c fallback ``` +Removing the explicitly selected profile does not silently switch compilers: +`oscan` fails with instructions until that profile is reinstalled or +`oscan-default set `/`oscan-default auto` is run. MSI selection is +always performed after installation as the target user; `-SetDefault` remains +archive-only because MSI installation is elevated. + ### Linux Download an archive and `SHA256SUMS`, verify it, extract it, and run the @@ -84,7 +95,8 @@ included installer. This example installs full and selects it as the default: ```bash tar xf oscan-vX.Y.Z-linux-x86_64-full.tar.xz -./oscan-vX.Y.Z-linux-x86_64-full/install.sh --set-default +./oscan-vX.Y.Z-linux-x86_64-full/install.sh +oscan-default set full oscan --version oscan app.osc --backend cranelift ``` @@ -106,8 +118,8 @@ tar xf oscan-vX.Y.Z-macos-x86_64-c.tar.gz ``` Run the included `install.sh` to create `oscan-c` without disturbing another -profile, then run `oscan-c --version`. Pass `--set-default` only when the plain -`oscan` command should select this profile. +profile, then run `oscan-c --version`. Select it with `oscan-default set c`; +`--set-default` remains a compatible installer shortcut. ### Build from source diff --git a/build.rs b/build.rs index 3db30a7..d02ec81 100644 --- a/build.rs +++ b/build.rs @@ -12,11 +12,71 @@ use sha2::{Digest, Sha256}; fn main() { stamp_git_version(); stamp_distribution_config(); + compile_windows_resources(); generate_native_link_assets(); configure_inprocess_toolchain(); generate_inprocess_link_assets(); } +fn compile_windows_resources() { + println!("cargo:rerun-if-env-changed=OSCAN_VERSION"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + + let display_version = std::env::var("OSCAN_VERSION") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| { + std::env::var("CARGO_PKG_VERSION").expect("Cargo sets CARGO_PKG_VERSION") + }); + let numeric_version = parse_windows_version(&display_version).unwrap_or_else(|| { + parse_windows_version( + &std::env::var("CARGO_PKG_VERSION").expect("Cargo sets CARGO_PKG_VERSION"), + ) + .expect("Cargo package version must begin with numeric components") + }); + + let mut resource = winresource::WindowsResource::new(); + resource + .set("CompanyName", "Oscan contributors") + .set("FileDescription", "Oscan compiler and profile selector") + .set("FileVersion", display_version.trim_start_matches('v')) + .set("LegalCopyright", "Oscan contributors") + .set("ProductName", "Oscan") + .set("ProductVersion", display_version.trim_start_matches('v')) + .set_version_info(winresource::VersionInfo::FILEVERSION, numeric_version) + .set_version_info(winresource::VersionInfo::PRODUCTVERSION, numeric_version) + .compile() + .expect("failed to compile Windows version resources"); +} + +fn parse_windows_version(value: &str) -> Option { + let mut parts = [0_u16; 4]; + for (index, component) in value + .trim() + .trim_start_matches('v') + .split(|character: char| character == '.' || character == '-' || character == '+') + .take(4) + .enumerate() + { + let digits: String = component + .chars() + .take_while(|character| character.is_ascii_digit()) + .collect(); + if digits.is_empty() { + break; + } + parts[index] = digits.parse().ok()?; + } + Some( + ((parts[0] as u64) << 48) + | ((parts[1] as u64) << 32) + | ((parts[2] as u64) << 16) + | parts[3] as u64, + ) +} + // --------------------------------------------------------------------------- // Packaged distribution builds. // diff --git a/docs/guide.md b/docs/guide.md index b2a0ccd..a66fc62 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -50,14 +50,14 @@ list and what each package contains. $expected = (Select-String -Path SHA256SUMS -Pattern "\s\*?$([regex]::Escape($asset))$").Line.Split(' ')[0] if ((Get-FileHash $asset -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expected.ToLowerInvariant()) { throw "checksum mismatch" } ``` -3. For a ZIP, extract and run `install.ps1`; pass `-SetDefault` to change an - existing unqualified `oscan` selection. For an MSI, just run it. +3. For a ZIP, extract and run `install.ps1`. For an MSI, just run it. 4. Verify the qualified command, for example `oscan-full --version` +5. Select a default as the target user: `oscan-default set full` -`scripts/install-latest.ps1 -Profile full -SetDefault` installs the all-backend -profile. `-Backend llvm|cranelift|c` remains compatibility shorthand for the -matching slim profile. The script verifies checksums and refuses an unverified -release unless `-SkipChecksum` is supplied. +`scripts/install-latest.ps1 -Profile full` installs the all-backend profile; +then `oscan-default set full` selects it. `-Backend llvm|cranelift|c` remains +compatibility shorthand for the matching slim profile. The script verifies +checksums and refuses an unverified release unless `-SkipChecksum` is supplied. **Linux x86_64:** @@ -70,9 +70,9 @@ release unless `-SkipChecksum` is supplied. ``` 3. For the `llvm` or `full` package on Debian/Ubuntu: `sudo apt-get install libedit2 libffi8 libxml2 libz3-4 libzstd1 zlib1g` -4. Run the included `install.sh`; pass `--set-default` to change an existing - unqualified selection +4. Run the included `install.sh` 5. Verify, for example: `oscan-llvm --version` +6. Select it if desired: `oscan-default set llvm` The Linux `llvm` package ships the pinned LLVM code generator only (no clang, no GCC, no headers, no sysroot). It requires glibc 2.34 or newer plus the host @@ -88,8 +88,8 @@ pinned C toolchain. grep -E "[[:space:]]\*?oscan-vX.Y.Z-macos-x86_64-c\.tar\.gz$" SHA256SUMS | shasum -a 256 -c - tar xf oscan-vX.Y.Z-macos-x86_64-c.tar.gz ``` -3. Copy the `oscan` binary to `/usr/local/bin/` or another directory in your PATH -4. Verify: `oscan --version` +3. Run the included `install.sh` +4. Verify `oscan-c --version`; select it with `oscan-default set c` **macOS requires Xcode Command Line Tools** (a native C compiler), because the macOS package is a C-backend package: @@ -144,16 +144,30 @@ Unix: ~/.local/share/oscan/profiles/// ``` The stable bin directory exposes `oscan-full`, `oscan-llvm`, -`oscan-cranelift`, and `oscan-c`. The first archive profile safely creates the -unqualified `oscan` selector. Installing or upgrading another profile does not -change it; use `-SetDefault` on Windows or `--set-default` on Unix explicitly. -An upgrade is staged and activated atomically, replaces only the same profile, -and preserves every other profile. +`oscan-cranelift`, and `oscan-c`, plus `oscan-default`. Plain `oscan` reads a +per-user selection from `%LOCALAPPDATA%\Oscan\default-profile` on Windows or +`${XDG_CONFIG_HOME:-$HOME/.config}/oscan/default-profile` on Unix. Use: + +```text +oscan-default set full explicit sticky choice +oscan-default show current mode and availability +oscan-default list installed profiles +oscan-default auto opt into full > llvm > cranelift > c fallback +``` + +Installing or upgrading any profile preserves the state. With no state, the +first `oscan` invocation chooses the highest-priority installed profile and +persists it explicitly. Removing that selected profile preserves the stale +choice and makes `oscan` fail with a repair instruction; it never silently +changes compiler/backend behavior. `-SetDefault`/`--set-default` remain archive +installer shortcuts for `oscan-default set `. MSI users run +`oscan-default` after installation so elevated setup never writes another +user's state. - **Uninstall (archive install):** Run that profile's extracted installer with `-Uninstall` or `--uninstall`. It removes only the named profile and qualified - command. If it was selected, the unqualified selector is removed rather than - redirected implicitly. + command. If it was selected, the sticky state remains and plain `oscan` + reports that the selected profile is unavailable. - **Upgrade (Windows MSI):** Run the newer MSI for that profile. `Oscan Full`, `Oscan LLVM`, `Oscan Cranelift`, and `Oscan C` are independent product families, so upgrading one preserves the others. @@ -165,10 +179,11 @@ and preserves every other profile. MSIs install isolated payloads under `Program Files\Oscan\profiles\\` and expose `oscan-full`, `oscan-llvm`, `oscan-cranelift`, and `oscan-c` through a shared -`Program Files\Oscan\bin`. Their reference-counted `oscan` selector chooses the -first installed command in this order: full, LLVM, Cranelift, C; uninstalling a -profile falls through to the next one. The LLVM family retains the historical -UpgradeCode so a new LLVM MSI upgrades the old single-product MSI. +`Program Files\Oscan\bin`. Their reference-counted, versioned `oscan.exe` and +`oscan-default.exe` use the same per-user state as archives. Explicit mode is +sticky; only `auto` uses full, LLVM, Cranelift, C priority. The LLVM family +retains the historical UpgradeCode so a new LLVM MSI upgrades the old +single-product MSI. The quick installer and MSI detect a per-user archive selector for the same Windows account and a matching profile-qualified archive command. They refuse diff --git a/docs/releasing.md b/docs/releasing.md index 50c34b4..3355ece 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -41,6 +41,11 @@ Invariants the tooling enforces (see `scripts/test_release_workflow.py`, uses `backend-llvm,inprocess-lld,static-llvm` plus `RUSTFLAGS="-C target-feature=+crt-static"`; ordinary variants use only `backend-`. +* **Selectors are target-shared and versioned.** `oscan-selector` and + `oscan-default` are built once per target, outside the profile loop, and the + exact same files are staged into every sibling package. Windows builds carry + the release PE file version; the four MSIs reference-count stable selector + component GUIDs so mixed-version repair cannot downgrade them. * **Slim object packages are toolchain-free.** An `llvm`/`cranelift` package may not contain a C compiler executable, a C header outside `native-link/`, a sysroot/`include/` tree, the hosted runtime archive, `native-runtime/`, or @@ -48,10 +53,11 @@ Invariants the tooling enforces (see `scripts/test_release_workflow.py`, factory* with the pinned toolchain. Sidecar variants ship those archives; strict Windows LLVM embeds them in `oscan.exe`. Downstream users need no C tools in either case. -* **Windows LLVM is a strict single-executable compiler.** Its ZIP/MSI contains +* **Windows LLVM has a strict single-executable compiler payload.** Its ZIP/MSI contains no `native-link/`, `build/`, `toolchain/`, `ld.lld.exe`, provider DLL or - linker runtime DLL. `LICENSES/embedded/` contains the notices for the static - LLVM/LLD and embedded llvm-mingw inputs. + linker runtime DLL. The two universal selector executables are package + plumbing, not backend/toolchain payload. `LICENSES/embedded/` contains the + notices for the static LLVM/LLD and embedded llvm-mingw inputs. * **C packages carry no object payload.** A `c` package may not ship `native-link/`, `build/`, `native-runtime/`, or `cross-linkers/`. * **Full is target-local.** Full is the union of that target's backend @@ -95,10 +101,11 @@ deliberately not cached. toolchain, build runtime archives, and prepare any sidecar inputs. Windows also resolves the pinned LLVM SDK/source, builds or restores the patched static LLVM/LLD toolchain, and prepares strict in-memory link inputs. It then - builds one compiler per profile and, for each profile, runs assemble → smoke - → stage for upload. Windows additionally cuts and administratively + builds the shared selectors once, builds one compiler per profile and, for + each profile, runs assemble → smoke → stage for upload. Windows additionally cuts and administratively coextracts all four profile MSIs from those staged bundles, then smokes each - extracted payload and its qualified command. + extracted payload, qualified command, explicit sticky selection, missing + selected-profile failure, and opt-in automatic mode. 3. `checksums` — collect every `.zip`/`.msi`/`.tar.gz`/`.tar.xz`, refuse duplicate asset names, and write one `SHA256SUMS` over all of them. 4. `publish` — only when publishing is enabled; uploads the archives, the MSIs @@ -182,7 +189,20 @@ python scripts/release_tools.py prepare-inprocess-notices ` --inprocess-toolchain-dir "build/inprocess-toolchain/$target" ` --output-dir $noticeDir -# 5. Build the strict compiler with static LLVM, in-process LLD and static CRT. +# 5. Build the target-shared selectors once, without a distribution stamp. +# Windows selectors use the static CRT so they run on a clean installation. +Remove-Item Env:OSCAN_DISTRIBUTION_PROFILE, Env:OSCAN_DEFAULT_BACKEND, ` + Env:OSCAN_DISTRIBUTION_BACKEND, Env:OSCAN_INPROCESS_TOOLCHAIN_DIR, ` + Env:OSCAN_INPROCESS_ASSETS_DIR, Env:RUSTFLAGS -ErrorAction SilentlyContinue +$env:OSCAN_VERSION = $tag +$env:RUSTFLAGS = '-C target-feature=+crt-static' +cargo build --release --no-default-features --features backend-c ` + --bin oscan-selector --bin oscan-default +New-Item -ItemType Directory build/release-selectors -Force | Out-Null +Copy-Item target/release/oscan-selector.exe build/release-selectors/ +Copy-Item target/release/oscan-default.exe build/release-selectors/ + +# 6. Build the strict compiler with static LLVM, in-process LLD and static CRT. Remove-Item Env:OSCAN_EMBED_ASSETS_DIR, Env:OSCAN_REQUIRE_EMBEDDED_ASSETS -ErrorAction SilentlyContinue Remove-Item Env:OSCAN_DISTRIBUTION_BACKEND -ErrorAction SilentlyContinue $env:OSCAN_DISTRIBUTION_PROFILE = $profile @@ -192,16 +212,18 @@ $env:OSCAN_INPROCESS_TOOLCHAIN_DIR = (Resolve-Path "build/inprocess-toolchain/$t $env:OSCAN_INPROCESS_ASSETS_DIR = (Resolve-Path "build/inprocess-assets/$target").Path $env:RUSTFLAGS = '-C target-feature=+crt-static' cargo build --release --no-default-features ` - --features backend-llvm,inprocess-lld,static-llvm + --features backend-llvm,inprocess-lld,static-llvm --bin oscan -# 6. Assemble that variant, passing only the inputs its component list declares. +# 7. Assemble that variant, passing only the inputs its component list declares. $archive = ./scripts/assemble-release.ps1 ` -Target $target -Profile $profile -Version $version ` -BinaryPath target/release/oscan.exe ` + -SelectorBinaryPath build/release-selectors/oscan-selector.exe ` + -DefaultManagerBinaryPath build/release-selectors/oscan-default.exe ` -EmbeddedNoticesDir (Resolve-Path $noticeDir).Path ` -OutputDir build/release-artifacts | Select-Object -Last 1 -# 7. Smoke exactly that archive, as that profile. +# 8. Smoke exactly that archive, as that profile. ./scripts/smoke-release.ps1 ` -Target $target -Profile $profile -Version $version ` -ArchivePath $archive ` @@ -755,14 +777,17 @@ input is prepared once and reused by that target's profiles): sidecar set (13 files/≈85.4 MB on Windows, 1 file/≈2.78 MB on Linux). Windows additionally builds/restores static LLVM/LLD and runs `prepare-inprocess-link-assets` for the strict full and LLVM variants. -5. **One contract-driven `cargo build` per profile**, stamped with +5. **Build the selectors once, then one contract-driven compiler per profile.** + The selector build has no distribution stamp and its exact two outputs are + reused by every sibling package. Each compiler is stamped with `OSCAN_DISTRIBUTION_PROFILE=

` and `OSCAN_DEFAULT_BACKEND=`, then copied to a per-profile path before the next build overwrites `target/release`. Strict Windows full and LLVM enable `inprocess-lld,static-llvm` and static CRT; that environment is cleared before the sidecar/C builds. 6. **For each profile: assemble → smoke → stage for upload.** - `assemble-release.ps1` takes `-Profile` plus exactly the prepared inputs + `assemble-release.ps1` takes the shared `-SelectorBinaryPath` and + `-DefaultManagerBinaryPath`, `-Profile`, plus exactly the prepared inputs that variant's component list declares (`-EmbeddedNoticesDir` for strict Windows full/LLVM; `-NativeLinkDir`/`-PrebuiltRuntimeArchiveDir` for sidecar object variants; `-ToolchainArchive` or `-LlvmProviderArchive` where @@ -770,7 +795,8 @@ input is prepared once and reused by that target's profiles): a C compiler; slim LLVM and Cranelift freestanding final links need none. 7. **Cut all four Windows profile MSIs** from their staged bundle directories, coextract them into one administrative image, and smoke every isolated - payload plus the shared/qualified command layout. + payload plus the shared/qualified command layout, explicit sticky selection, + missing-selection failure, and automatic priority mode. CI (`ci.yml`) keeps its main `linux` and `windows` jobs building *without* `OSCAN_EMBED_ASSETS_DIR`, so the dev/external-toolchain path stays covered. diff --git a/docs/spec/oscan-spec.md b/docs/spec/oscan-spec.md index e6b9bc8..cb6c15b 100644 --- a/docs/spec/oscan-spec.md +++ b/docs/spec/oscan-spec.md @@ -50,9 +50,9 @@ Oscan is distributed via [GitHub Releases](https://github.com/lucabol/Oscan/rele $expected = (Select-String -Path SHA256SUMS -Pattern "\s\*?$([regex]::Escape($asset))$").Line.Split(' ')[0] if ((Get-FileHash $asset -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expected.ToLowerInvariant()) { throw "checksum mismatch" } ``` -3. Extract and run the bundled `install.ps1`; use `-SetDefault` only when this - profile should replace an existing unqualified selection +3. Extract and run the bundled `install.ps1`, or install the MSI 4. Verify the qualified command, such as `oscan-full --version` +5. Run `oscan-default set full` to make that profile the sticky default `oscan-vX.Y.Z-windows-x86_64-cranelift.zip`, `oscan-vX.Y.Z-windows-x86_64-c.zip` install the same way. No C compiler @@ -62,6 +62,7 @@ bundle one. **Components (llvm package):** - `oscan.exe` — the strict single-executable compiler: LLVM 22.1.0, patched in-process LLD, runtime archives and link inputs are statically linked or embedded +- `oscan-selector.exe`, `oscan-default.exe` — shared sticky-profile dispatcher and manager - `LICENSES/embedded/` — notices for the embedded LLVM/LLD and llvm-mingw inputs - `install.ps1` — optional installation script @@ -71,6 +72,7 @@ retains the verified `native-link/` and runtime-archive sidecars. **Components (c package):** - `oscan.exe` — the Oscan compiler, built with the C backend only +- `oscan-selector.exe`, `oscan-default.exe` — shared sticky-profile dispatcher and manager - `toolchain/` — the pinned llvm-mingw Clang/LLD C and hosted-link toolchain - `install.ps1` — optional installation script @@ -86,9 +88,10 @@ retains the verified `native-link/` and runtime-archive sidecars. grep -E "[[:space:]]\*?oscan-vX.Y.Z-linux-x86_64-llvm\.tar\.xz$" SHA256SUMS | sha256sum -c - tar xf oscan-vX.Y.Z-linux-x86_64-llvm.tar.xz ``` -3. Run the included `install.sh`; use `--set-default` only to change an existing selection +3. Run the included `install.sh` 4. For LLVM/full on Debian/Ubuntu: `sudo apt-get install libedit2 libffi8 libxml2 libz3-4 libzstd1 zlib1g` 5. Verify a qualified command, such as `oscan-llvm --version` +6. Run `oscan-default set llvm` to make it the sticky default The packaged LLVM code generator requires glibc 2.34 or newer plus the listed host runtime libraries — but no installed C/Clang/LLVM toolchain. @@ -97,6 +100,7 @@ host runtime libraries — but no installed C/Clang/LLVM toolchain. **Components (llvm package):** - `oscan` — the Oscan compiler, built with the LLVM backend only +- `oscan-selector`, `oscan-default` — shared sticky-profile dispatcher and manager - `toolchain/` — the packaged LLVM code generator only (no clang, no GCC, no headers, no sysroot) - `native-link/` — the verified static linker assets - `build/runtime-archives/linux-x86_64/` — precompiled freestanding runtime archives @@ -104,6 +108,7 @@ host runtime libraries — but no installed C/Clang/LLVM toolchain. **Components (c package):** - `oscan` — the Oscan compiler, built with the C backend only +- `oscan-selector`, `oscan-default` — shared sticky-profile dispatcher and manager - `toolchain/` — the pinned musl GCC/binutils C toolchain - `install.sh` — optional installation script @@ -118,8 +123,8 @@ host runtime libraries — but no installed C/Clang/LLVM toolchain. grep -E "[[:space:]]\*?oscan-vX.Y.Z-macos-x86_64-c\.tar\.gz$" SHA256SUMS | shasum -a 256 -c - tar xf oscan-vX.Y.Z-macos-x86_64-c.tar.gz ``` -3. Copy `oscan` to `/usr/local/bin/` or another directory in your PATH -4. Verify: `oscan --version` +3. Run the included `install.sh` +4. Verify `oscan-c --version`; select it with `oscan-default set c` **Prerequisite:** Xcode Command Line Tools (Apple's native C compiler). @@ -235,7 +240,12 @@ that use one. Archive installers use profile-specific version roots and atomically activate a same-profile upgrade. Installing one profile never removes another or changes -an existing unqualified selector; `-SetDefault`/`--set-default` is explicit. +the per-user unqualified selection. `oscan-default set ` records an +explicit sticky choice; `oscan-default auto` is the only mode that dynamically +uses `full > llvm > cranelift > c`. With no state, first use chooses and persists +the highest-priority installed profile. The state lives at +`%LOCALAPPDATA%\Oscan\default-profile` on Windows and +`${XDG_CONFIG_HOME:-$HOME/.config}/oscan/default-profile` on Unix. - **Upgrade (archive install):** Run the newer profile's bundled installer; it replaces only that profile - **Upgrade (Windows MSI):** Run the newer same-profile MSI; each profile is an independent product family and preserves the others @@ -243,11 +253,13 @@ an existing unqualified selector; `-SetDefault`/`--set-default` is explicit. - **Uninstall (Windows MSI):** Use **Settings → Apps → Installed apps** to remove the named Oscan profile, or run `msiexec /x oscan-vX.Y.Z-windows-x86_64-.msi /quiet`. Deleting the directory by hand leaves the product registered with Windows Installer. Windows profile MSIs install isolated payloads and qualified commands. Their -shared, reference-counted `oscan` selector chooses the first available profile -in `full`, `llvm`, `cranelift`, `c` order and falls through when one is -uninstalled. The LLVM MSI keeps the historical UpgradeCode so it upgrades the -legacy LLVM-only product. Other profile MSIs require that migration first so -the old flat executable cannot shadow the shared selector. +shared, reference-counted `oscan.exe` and `oscan-default.exe` use that same +per-user state. Installing, upgrading, or removing an MSI never rewrites the +selection. If an explicitly selected profile is unavailable, plain `oscan` +fails rather than falling through. The LLVM MSI keeps the historical +UpgradeCode so it upgrades the legacy LLVM-only product. Other profile MSIs +require that migration first so the old flat executable cannot shadow the +shared selector. On Windows, archive and MSI installers reject ambiguous ownership of the unqualified `oscan` command or a matching profile-qualified command by default. diff --git a/docs/technical-details.md b/docs/technical-details.md index 2454b67..fae65a7 100644 --- a/docs/technical-details.md +++ b/docs/technical-details.md @@ -146,7 +146,8 @@ Archive installs use one stable bin directory and isolated profile payloads: ```text / - oscan # managed default selector + oscan # versioned selector executable + oscan-default # selection manager oscan-full oscan-llvm oscan-cranelift @@ -157,19 +158,25 @@ Archive installs use one stable bin directory and isolated profile payloads: llvm//... ``` -Windows uses `.cmd` shims; Unix uses symlinks. Archive installs preserve an -existing selector unless `-SetDefault`/`--set-default` changes it. Their staged -same-profile upgrades update only that qualified entry and payload. +Windows uses `.cmd` qualified shims; Unix uses qualified symlinks. The selector +is an executable shared across packages. It reads per-user state from +`%LOCALAPPDATA%\Oscan\default-profile` or +`${XDG_CONFIG_HOME:-$HOME/.config}/oscan/default-profile`; installers never +rewrite existing state. Explicit state fails closed when its profile is absent. +`auto` alone resolves `full > llvm > cranelift > c > dev`. State updates use +same-directory atomic replacement. Windows also publishes four coinstallable MSI product families. They use distinct UpgradeCodes and payload directories, a qualified command per profile, and identical reference-counted PATH/selector components. The MSI selector -falls through in `full`, `llvm`, `cranelift`, `c` order as profiles are -uninstalled. The LLVM family preserves the historical UpgradeCode to upgrade -legacy LLVM installs in place. Other MSI families require that migration first -so the old flat executable cannot shadow the shared selector. Archive and MSI -installers reject ownership of the same unqualified or profile-qualified -Windows command by default. +and manager are PE executables with release file versions, so a repair from an +older coinstalled product cannot downgrade a newer shared component. The +legacy `oscan.cmd` component remains untouched for mixed-generation servicing; +normal Windows command resolution prefers the new `oscan.exe`. The LLVM family +preserves the historical UpgradeCode to upgrade legacy LLVM installs in place. +Other MSI families require that migration first so the old flat executable +cannot shadow the shared selector. Archive and MSI installers reject ownership +of the same unqualified or profile-qualified Windows command by default. ## Runtime and final linking diff --git a/packaging/windows/oscan.wxs b/packaging/windows/oscan.wxs index e1006ea..db9079f 100644 --- a/packaging/windows/oscan.wxs +++ b/packaging/windows/oscan.wxs @@ -84,7 +84,7 @@ + Value="$(var.ProductName) has been installed. Run 'oscan-default set $(var.Profile)' to select it, or use 'oscan-$(var.Profile)' directly." /> @@ -125,6 +125,30 @@ KeyPath="yes" /> + + + + + + + + + diff --git a/scripts/assemble-release.ps1 b/scripts/assemble-release.ps1 index eabb384..95801a4 100644 --- a/scripts/assemble-release.ps1 +++ b/scripts/assemble-release.ps1 @@ -18,6 +18,14 @@ param( [Parameter(Mandatory = $true)] [string]$BinaryPath, + # Shared, backend-independent profile selector binaries. CI builds these + # once per target and reuses the exact files in every profile package. + [Parameter(Mandatory = $true)] + [string]$SelectorBinaryPath, + + [Parameter(Mandatory = $true)] + [string]$DefaultManagerBinaryPath, + [string]$OutputDir, [string]$ContractPath, @@ -124,6 +132,8 @@ $stageArgs = @{ Profile = $Profile Version = $Version BinaryPath = $BinaryPath + SelectorBinaryPath = $SelectorBinaryPath + DefaultManagerBinaryPath = $DefaultManagerBinaryPath OutputDir = $OutputDir ContractPath = $ContractPath } diff --git a/scripts/build-msi.ps1 b/scripts/build-msi.ps1 index 2f2ac54..9a1e115 100644 --- a/scripts/build-msi.ps1 +++ b/scripts/build-msi.ps1 @@ -27,6 +27,11 @@ $BundleDir = (Resolve-Path -LiteralPath $BundleDir).Path if (-not (Test-Path (Join-Path $BundleDir "oscan.exe"))) { throw "Bundle directory must contain oscan.exe" } +foreach ($requiredSelector in @("oscan-selector.exe", "oscan-default.exe")) { + if (-not (Test-Path -LiteralPath (Join-Path $BundleDir $requiredSelector) -PathType Leaf)) { + throw "Bundle directory must contain $requiredSelector" + } +} $packageMetadataPath = Join-Path $BundleDir "oscan-package.json" if (-not (Test-Path -LiteralPath $packageMetadataPath)) { throw "Bundle directory must contain oscan-package.json" @@ -102,7 +107,13 @@ if (-not $HarvestOnly) { # Every profile MSI is cut from its already staged package. The payload differs # by profile, so harvest everything except files declared directly by the .wxs # and the archive install script, which has no meaning inside an MSI. -$RootFileExclusions = @("oscan.exe", "README-install.txt", "install.ps1") +$RootFileExclusions = @( + "oscan.exe", + "oscan-selector.exe", + "oscan-default.exe", + "README-install.txt", + "install.ps1" +) if (-not $HarvestPath) { $HarvestPath = Join-Path ([System.IO.Path]::GetTempPath()) "oscan-bundle-harvest.wxs" } @@ -225,24 +236,8 @@ try { "@ $selectorShim = @' @echo off -if exist "%~dp0oscan-full.cmd" goto full -if exist "%~dp0oscan-llvm.cmd" goto llvm -if exist "%~dp0oscan-cranelift.cmd" goto cranelift -if exist "%~dp0oscan-c.cmd" goto c -echo Oscan has no installed MSI profile. 1>&2 -exit /b 1 -:full -call "%~dp0oscan-full.cmd" %* -exit /b %ERRORLEVEL% -:llvm -call "%~dp0oscan-llvm.cmd" %* -exit /b %ERRORLEVEL% -:cranelift -call "%~dp0oscan-cranelift.cmd" %* -exit /b %ERRORLEVEL% -:c -call "%~dp0oscan-c.cmd" %* -exit /b %ERRORLEVEL% +"%~dp0oscan.exe" %* +@exit /b %ERRORLEVEL% '@ Set-Content -LiteralPath $profileShimPath -Value $profileShim -Encoding Ascii Set-Content -LiteralPath $selectorShimPath -Value $selectorShim -Encoding Ascii diff --git a/scripts/install-dev.ps1 b/scripts/install-dev.ps1 index 0a9cba9..cc8fea3 100644 --- a/scripts/install-dev.ps1 +++ b/scripts/install-dev.ps1 @@ -5,8 +5,9 @@ .DESCRIPTION 1. Runs `cargo build --release` (skip with -SkipBuild). - 2. Stages target\release\oscan.exe (and libbearssl.a if present under - deps\laststanding\bearssl\build\) into a temporary directory. + 2. Stages target\release\oscan.exe, the shared selector executables, and + libbearssl.a if present under deps\laststanding\bearssl\build\ into a + temporary directory. 3. Delegates to scripts\install-oscan.ps1, which copies the bundle to $env:LOCALAPPDATA\Programs\oscan (override with -InstallDir) and adds it to the User PATH. @@ -21,8 +22,8 @@ Install location. Defaults to $env:LOCALAPPDATA\Programs\oscan. .PARAMETER BinDir - Optional directory where a thin `oscan.cmd` shim is created. When set, - only BinDir is added to PATH (not InstallDir). + Optional directory where the shared selector and `oscan-dev.cmd` are + installed. When set, only BinDir is added to PATH (not InstallDir). .PARAMETER NoPathUpdate Skip modifying the User PATH. @@ -80,6 +81,11 @@ if (-not $SkipBuild) { try { $cargoArgs = @('build') if ($Configuration -eq 'release') { $cargoArgs += '--release' } + $cargoArgs += @( + '--bin', 'oscan', + '--bin', 'oscan-selector', + '--bin', 'oscan-default' + ) & cargo @cargoArgs if ($LASTEXITCODE -ne 0) { throw "cargo build failed (exit code $LASTEXITCODE)." @@ -90,8 +96,12 @@ if (-not $SkipBuild) { } $BuiltExe = Join-Path $RepoRoot "target\$Configuration\oscan.exe" -if (-not (Test-Path $BuiltExe)) { - throw "Built binary not found at $BuiltExe. Run without -SkipBuild." +$BuiltSelector = Join-Path $RepoRoot "target\$Configuration\oscan-selector.exe" +$BuiltDefaultManager = Join-Path $RepoRoot "target\$Configuration\oscan-default.exe" +foreach ($requiredBinary in @($BuiltExe, $BuiltSelector, $BuiltDefaultManager)) { + if (-not (Test-Path -LiteralPath $requiredBinary -PathType Leaf)) { + throw "Built binary not found at $requiredBinary. Run without -SkipBuild." + } } # Stage files to a temp dir so install-oscan.ps1 can MIRror it cleanly. @@ -100,6 +110,10 @@ New-Item -ItemType Directory -Path $Stage -Force | Out-Null try { Copy-Item -LiteralPath $BuiltExe -Destination (Join-Path $Stage 'oscan.exe') -Force + Copy-Item -LiteralPath $BuiltSelector ` + -Destination (Join-Path $Stage 'oscan-selector.exe') -Force + Copy-Item -LiteralPath $BuiltDefaultManager ` + -Destination (Join-Path $Stage 'oscan-default.exe') -Force # Optionally bundle libbearssl.a so TLS-using programs keep working after install. $BearSsl = Join-Path $RepoRoot 'deps\laststanding\bearssl\build\libbearssl.a' @@ -120,9 +134,6 @@ try { if ($NoPathUpdate) { $installerArgs.NoPathUpdate = $true } & $Installer @installerArgs - if ($LASTEXITCODE -ne 0) { - throw "install-oscan.ps1 failed (exit code $LASTEXITCODE)." - } } finally { if (Test-Path $Stage) { Remove-Item -LiteralPath $Stage -Recurse -Force -ErrorAction SilentlyContinue diff --git a/scripts/install-latest.ps1 b/scripts/install-latest.ps1 index da562d5..649afb0 100644 --- a/scripts/install-latest.ps1 +++ b/scripts/install-latest.ps1 @@ -7,8 +7,8 @@ Windows publishes a full package containing all backends and three slim packages (LLVM, Cranelift, and C). Archive installs use profile-specific roots and qualified commands, so installing one profile does not remove - another. The unqualified oscan command is created when no selector exists - and changes only when -SetDefault is supplied. + another. The unqualified oscan command uses a per-user sticky selection: + installation preserves an existing selection, and oscan-default changes it. This script queries the GitHub Releases API for lucabol/Oscan, picks the asset whose name is exactly the one the release contract derives from the @@ -49,7 +49,9 @@ .PARAMETER SetDefault Select the installed archive profile for the unqualified oscan command. - Without this switch, an existing selection is preserved. + Without this switch, an existing selection is preserved; first use chooses + and persists the highest-priority installed profile. MSI users select after + installation with oscan-default set . .PARAMETER NoPathUpdate Forwarded to install.ps1 when installing a zip. Ignored for MSI. @@ -270,7 +272,7 @@ function Invoke-OscanInstallLatest { $packageProfile = Resolve-OscanPackageProfile -Profile $Profile -Backend $Backend if ($Mode -eq 'msi' -and $SetDefault) { - throw "-SetDefault applies to archive profiles. MSI profiles use the shared deterministic selector and qualified commands." + throw "-SetDefault cannot run inside an elevated MSI install. Install the MSI, then run 'oscan-default set $PackageProfile' as the user who should own the selection." } if ($Version) { diff --git a/scripts/install-oscan.ps1 b/scripts/install-oscan.ps1 index aaeab2b..6d8c6f6 100644 --- a/scripts/install-oscan.ps1 +++ b/scripts/install-oscan.ps1 @@ -54,6 +54,34 @@ function Write-AtomicText { } } +function Write-AtomicTextIfAbsent { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Content + ) + + if (Test-Path -LiteralPath $Path -PathType Container) { + throw "Cannot create state file '$Path' because a directory already exists there." + } + $parent = Split-Path -Parent $Path + New-Item -ItemType Directory -Path $parent -Force | Out-Null + $temporary = Join-Path $parent (".tmp-" + [guid]::NewGuid().ToString("N")) + try { + Set-Content -LiteralPath $temporary -Value $Content -Encoding ASCII -NoNewline + try { + Move-Item -LiteralPath $temporary -Destination $Path -ErrorAction Stop + return $true + } catch { + if (Test-Path -LiteralPath $Path -PathType Leaf) { + return $false + } + throw + } + } finally { + Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue + } +} + function Write-ProfileShim { param( [Parameter(Mandatory = $true)][string]$Path, @@ -63,14 +91,43 @@ function Write-ProfileShim { Write-AtomicText -Path $Path -Content "@echo off`r`n`"%~dp0$RelativeExecutable`" %*`r`n" } -function Write-DefaultShim { +function Copy-AtomicFile { + param( + [Parameter(Mandatory = $true)][string]$Source, + [Parameter(Mandatory = $true)][string]$Destination + ) + + $parent = Split-Path -Parent $Destination + New-Item -ItemType Directory -Path $parent -Force | Out-Null + $temporary = Join-Path $parent (".tmp-" + [guid]::NewGuid().ToString("N")) + try { + Copy-Item -LiteralPath $Source -Destination $temporary -Force + Move-Item -LiteralPath $temporary -Destination $Destination -Force + } finally { + Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue + } +} + +function Restore-AtomicBytes { param( [Parameter(Mandatory = $true)][string]$Path, - [Parameter(Mandatory = $true)][string]$SelectedProfile + [Parameter(Mandatory = $true)][bool]$Existed, + [byte[]]$Content ) - Write-AtomicText -Path $Path ` - -Content "@echo off`r`ncall `"%~dp0oscan-$SelectedProfile.cmd`" %*`r`n" + if ($Existed) { + $parent = Split-Path -Parent $Path + New-Item -ItemType Directory -Path $parent -Force | Out-Null + $temporary = Join-Path $parent (".tmp-" + [guid]::NewGuid().ToString("N")) + try { + [System.IO.File]::WriteAllBytes($temporary, $Content) + Move-Item -LiteralPath $temporary -Destination $Path -Force + } finally { + Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue + } + } else { + Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + } } function Restore-AtomicFile { @@ -178,10 +235,15 @@ function Get-SelectorOwnership { ) if (Test-Path -LiteralPath $OwnerPath) { - $recordedOwner = (Get-Content -LiteralPath $OwnerPath -Raw).Trim() + $recordedOwner = @( + (Get-Content -LiteralPath $OwnerPath -Raw) -split '\r?\n' + )[0].Trim() if ($recordedOwner -eq $OwnerId) { return "Current" } + if ($LegacyOwned) { + return "Legacy" + } return "Foreign" } if (Test-Path -LiteralPath $CommandPath) { @@ -360,11 +422,21 @@ if (Test-Path -LiteralPath $metadataPath) { if ($expectedCompilerDigest -notmatch '^[0-9a-fA-F]{64}$') { throw "Package metadata does not declare a valid SHA-256 digest for oscan.exe." } + $expectedSelectorDigest = [string]$metadata.component_digests.'oscan-selector.exe' + if ($expectedSelectorDigest -notmatch '^[0-9a-fA-F]{64}$') { + throw "Package metadata does not declare a valid SHA-256 digest for oscan-selector.exe." + } + $expectedManagerDigest = [string]$metadata.component_digests.'oscan-default.exe' + if ($expectedManagerDigest -notmatch '^[0-9a-fA-F]{64}$') { + throw "Package metadata does not declare a valid SHA-256 digest for oscan-default.exe." + } } elseif ($Uninstall -and $Profile) { $version = $null } elseif ($Profile -eq "dev") { $version = "current" $expectedCompilerDigest = $null + $expectedSelectorDigest = $null + $expectedManagerDigest = $null } else { throw "Source bundle must contain schema-2 oscan-package.json; development installs must pass -Profile dev." } @@ -375,12 +447,30 @@ if (-not $Uninstall) { if (-not (Test-Path -LiteralPath (Join-Path $SourceDir "oscan.exe"))) { throw "Source bundle must contain oscan.exe." } + foreach ($required in @("oscan-selector.exe", "oscan-default.exe")) { + if (-not (Test-Path -LiteralPath (Join-Path $SourceDir $required) -PathType Leaf)) { + throw "Source bundle must contain $required." + } + } if ($expectedCompilerDigest) { $actualCompilerDigest = (Get-FileHash -LiteralPath (Join-Path $SourceDir "oscan.exe") -Algorithm SHA256).Hash if ($actualCompilerDigest -ne $expectedCompilerDigest) { throw "oscan.exe digest mismatch: package metadata has $expectedCompilerDigest, actual is $actualCompilerDigest." } } + foreach ($selectorFile in @( + [pscustomobject]@{ Name = "oscan-selector.exe"; Digest = $expectedSelectorDigest }, + [pscustomobject]@{ Name = "oscan-default.exe"; Digest = $expectedManagerDigest } + )) { + if ($selectorFile.Digest) { + $actualDigest = ( + Get-FileHash -LiteralPath (Join-Path $SourceDir $selectorFile.Name) -Algorithm SHA256 + ).Hash + if ($actualDigest -ne $selectorFile.Digest) { + throw "$($selectorFile.Name) digest mismatch: package metadata has $($selectorFile.Digest), actual is $actualDigest." + } + } + } } $archiveMarkerBase = Join-Path $env:LOCALAPPDATA "Programs\Oscan" @@ -390,8 +480,11 @@ $markerLock = Enter-OscanInstallLock -Root $archiveMarkerBase -Name ".archive-ma $profilesRoot = Join-Path $InstallRoot "profiles" $profileRoot = Join-Path $profilesRoot $Profile $qualifiedShim = Join-Path $BinDir "oscan-$Profile.cmd" -$defaultShim = Join-Path $BinDir "oscan.cmd" -$defaultState = Join-Path $InstallRoot "default-profile" +$selectorExecutable = Join-Path $BinDir "oscan.exe" +$defaultManager = Join-Path $BinDir "oscan-default.exe" +$legacyDefaultShim = Join-Path $BinDir "oscan.cmd" +$legacyDefaultState = Join-Path $InstallRoot "default-profile" +$defaultState = Join-Path $env:LOCALAPPDATA "Oscan\default-profile" $archiveDefaultMarker = Join-Path $archiveMarkerBase "archive-default" $archiveProfileMarker = Join-Path $archiveMarkerBase "archive-profile-$Profile" $archiveOwnerRoot = Join-Path $archiveMarkerBase "archive-defaults" @@ -408,7 +501,8 @@ try { $archiveOwnerHash.Dispose() } $archiveOwnerMarker = Join-Path $archiveOwnerRoot $archiveOwnerName -$selectorOwnerId = $archiveOwnerName +$qualifiedOwnerId = $archiveOwnerName +$sharedSelectorOwnerId = "oscan-selector-v1" $qualifiedOwner = Join-Path $BinDir ".oscan-$Profile.owner" $defaultOwner = Join-Path $BinDir ".oscan-default.owner" $qualifiedLegacyOwned = Test-LegacyProfileShimOwnedByRoot ` @@ -419,43 +513,60 @@ $qualifiedLegacyOwned = Test-LegacyProfileShimOwnedByRoot ` $qualifiedOwnership = Get-SelectorOwnership ` -CommandPath $qualifiedShim ` -OwnerPath $qualifiedOwner ` - -OwnerId $selectorOwnerId ` + -OwnerId $qualifiedOwnerId ` -LegacyOwned $qualifiedLegacyOwned -$selected = if (Test-Path -LiteralPath $defaultState) { +$legacySelected = if (Test-Path -LiteralPath $legacyDefaultState -PathType Leaf) { + (Get-Content -LiteralPath $legacyDefaultState -Raw).Trim().ToLowerInvariant() +} else { + "" +} +$selected = if (Test-Path -LiteralPath $defaultState -PathType Leaf) { (Get-Content -LiteralPath $defaultState -Raw).Trim() +} elseif ($legacySelected -in @("full", "llvm", "cranelift", "c", "dev", "auto")) { + $legacySelected } else { "" } -if ($selected -and $selected -notin @("full", "llvm", "cranelift", "c", "dev")) { +if ($selected -and $selected -notin @("full", "llvm", "cranelift", "c", "dev", "auto")) { throw "Default-profile state contains unknown profile '$selected'." } -$selectedQualifiedOwnership = "Absent" -if ($selected) { - $selectedQualifiedShim = Join-Path $BinDir "oscan-$selected.cmd" - $selectedQualifiedOwner = Join-Path $BinDir ".oscan-$selected.owner" +$legacyOwnershipProfile = if ( + $legacySelected -in @("full", "llvm", "cranelift", "c", "dev") +) { + $legacySelected +} else { + $selected +} +$legacyDefaultOwned = $false +if ($legacyOwnershipProfile -and + $legacyOwnershipProfile -ne "auto" -and + (Test-Path -LiteralPath $legacyDefaultShim -PathType Leaf)) { + $selectedQualifiedShim = Join-Path $BinDir "oscan-$legacyOwnershipProfile.cmd" + $selectedQualifiedOwner = Join-Path $BinDir ".oscan-$legacyOwnershipProfile.owner" $selectedLegacyOwned = Test-LegacyProfileShimOwnedByRoot ` -BinDir $BinDir ` -InstallRoot $InstallRoot ` -ProfilesRoot $profilesRoot ` - -Profile $selected + -Profile $legacyOwnershipProfile $selectedQualifiedOwnership = Get-SelectorOwnership ` -CommandPath $selectedQualifiedShim ` -OwnerPath $selectedQualifiedOwner ` - -OwnerId $selectorOwnerId ` + -OwnerId $qualifiedOwnerId ` -LegacyOwned $selectedLegacyOwned + $legacyDefaultOwned = $selectedQualifiedOwnership -in @("Current", "Legacy") -and + (Get-Content -LiteralPath $legacyDefaultShim -Raw).Contains( + "oscan-$legacyOwnershipProfile.cmd" + ) } -$defaultLegacyOwned = $false -if ($selected -and - $selectedQualifiedOwnership -eq "Current" -and - (Test-Path -LiteralPath $defaultShim -PathType Leaf)) { - $defaultLegacyOwned = (Get-Content -LiteralPath $defaultShim -Raw). - Contains("oscan-$selected.cmd") -} +$newSelectorAbsent = -not (Test-Path -LiteralPath $selectorExecutable) $defaultOwnership = Get-SelectorOwnership ` - -CommandPath $defaultShim ` + -CommandPath $selectorExecutable ` -OwnerPath $defaultOwner ` - -OwnerId $selectorOwnerId ` - -LegacyOwned $defaultLegacyOwned + -OwnerId $sharedSelectorOwnerId ` + -LegacyOwned ($legacyDefaultOwned -and $newSelectorAbsent) +if ($defaultOwnership -eq "Absent" -and $legacyDefaultOwned) { + $defaultOwnership = "Legacy" +} if ($Uninstall) { $uninstallBackup = Join-Path $profilesRoot ( @@ -464,7 +575,9 @@ if ($Uninstall) { $payloadStagedForRemoval = $false $qualifiedShimRemoved = $false $qualifiedOwnerRemoved = $false - $defaultShimRemoved = $false + $selectorRemoved = $false + $managerRemoved = $false + $legacyDefaultRemoved = $false $defaultOwnerRemoved = $false $stateRemoved = $false $ownerMarkerRemoved = $false @@ -480,9 +593,17 @@ if ($Uninstall) { $qualifiedOwnerContent = if ($qualifiedOwnerExisted) { Get-Content -LiteralPath $qualifiedOwner -Raw } else { "" } - $defaultExisted = Test-Path -LiteralPath $defaultShim -PathType Leaf - $defaultContent = if ($defaultExisted) { - Get-Content -LiteralPath $defaultShim -Raw + $selectorExisted = Test-Path -LiteralPath $selectorExecutable -PathType Leaf + $selectorContent = if ($selectorExisted) { + [System.IO.File]::ReadAllBytes($selectorExecutable) + } else { $null } + $managerExisted = Test-Path -LiteralPath $defaultManager -PathType Leaf + $managerContent = if ($managerExisted) { + [System.IO.File]::ReadAllBytes($defaultManager) + } else { $null } + $legacyDefaultExisted = Test-Path -LiteralPath $legacyDefaultShim -PathType Leaf + $legacyDefaultContent = if ($legacyDefaultExisted) { + Get-Content -LiteralPath $legacyDefaultShim -Raw } else { "" } $defaultOwnerExisted = Test-Path -LiteralPath $defaultOwner -PathType Leaf $defaultOwnerContent = if ($defaultOwnerExisted) { @@ -524,9 +645,9 @@ if ($Uninstall) { $profileMarkerContent -split '\r?\n' | Where-Object { $_ -match '^[0-9a-f]{64}$' } ) - if ($profileMarkerOwners -contains $selectorOwnerId) { + if ($profileMarkerOwners -contains $qualifiedOwnerId) { $remainingProfileOwners = @( - $profileMarkerOwners | Where-Object { $_ -ne $selectorOwnerId } + $profileMarkerOwners | Where-Object { $_ -ne $qualifiedOwnerId } ) $profileMarkerChanged = $true if ($remainingProfileOwners.Count -gt 0) { @@ -537,31 +658,58 @@ if ($Uninstall) { Remove-Item -LiteralPath $archiveProfileMarker -Force -ErrorAction Stop } } - if ($selected -eq $Profile) { + $remainingRootProfiles = if (Test-Path -LiteralPath $profilesRoot) { + @( + Get-ChildItem -LiteralPath $profilesRoot -Directory -ErrorAction Stop | + Where-Object { $_.Name -in @("full", "llvm", "cranelift", "c", "dev") } + ) + } else { + @() + } + if ($remainingRootProfiles.Count -eq 0 -and $ownerMarkerExisted) { + Remove-Item -LiteralPath $archiveOwnerMarker -Force -ErrorAction Stop + $ownerMarkerRemoved = $true + } + $remainingQualifiedCommands = @( + @("full", "llvm", "cranelift", "c", "dev") | + Where-Object { + Test-Path -LiteralPath (Join-Path $BinDir "oscan-$_.cmd") -PathType Leaf + } + ) + if ($remainingQualifiedCommands.Count -eq 0) { if ($defaultOwnership -eq "Current") { - if ($defaultExisted) { - Remove-Item -LiteralPath $defaultShim -Force -ErrorAction Stop - $defaultShimRemoved = $true + if (Test-Path -LiteralPath $selectorExecutable -PathType Leaf) { + Remove-Item -LiteralPath $selectorExecutable -Force -ErrorAction Stop + $selectorRemoved = $true + } + if (Test-Path -LiteralPath $defaultManager -PathType Leaf) { + Remove-Item -LiteralPath $defaultManager -Force -ErrorAction Stop + $managerRemoved = $true } - if ($defaultOwnerExisted) { + if (Test-Path -LiteralPath $defaultOwner -PathType Leaf) { Remove-Item -LiteralPath $defaultOwner -Force -ErrorAction Stop $defaultOwnerRemoved = $true } } - if ($stateExisted) { - Remove-Item -LiteralPath $defaultState -Force -ErrorAction Stop - $stateRemoved = $true - } - if ($ownerMarkerExisted) { - Remove-Item -LiteralPath $archiveOwnerMarker -Force -ErrorAction Stop - $ownerMarkerRemoved = $true + if ($legacyDefaultOwned -and + (Test-Path -LiteralPath $legacyDefaultShim -PathType Leaf)) { + Remove-Item -LiteralPath $legacyDefaultShim -Force -ErrorAction Stop + $legacyDefaultRemoved = $true } + } + if ($remainingRootProfiles.Count -eq 0) { $otherArchiveOwners = if (Test-Path -LiteralPath $archiveOwnerRoot) { @(Get-ChildItem -LiteralPath $archiveOwnerRoot -File -ErrorAction Stop) } else { @() } - if ($otherArchiveOwners.Count -eq 0 -and $defaultMarkerExisted) { + $archiveCommandsRemain = + (Test-Path -LiteralPath $selectorExecutable -PathType Leaf) -or + (Test-Path -LiteralPath $defaultManager -PathType Leaf) -or + (Test-Path -LiteralPath $legacyDefaultShim -PathType Leaf) + if ($otherArchiveOwners.Count -eq 0 -and + -not $archiveCommandsRemain -and + $defaultMarkerExisted) { Remove-Item -LiteralPath $archiveDefaultMarker -Force -ErrorAction Stop $defaultMarkerRemoved = $true } @@ -577,10 +725,25 @@ if ($Uninstall) { $rollbackErrors += "restore profile payload: $($_.Exception.Message)" } } + foreach ($restore in @( + [pscustomobject]@{ Path = $selectorExecutable; Existed = $selectorExisted; Content = $selectorContent; Changed = $selectorRemoved }, + [pscustomobject]@{ Path = $defaultManager; Existed = $managerExisted; Content = $managerContent; Changed = $managerRemoved } + )) { + if ($restore.Changed) { + try { + Restore-AtomicBytes ` + -Path ([string]$restore.Path) ` + -Existed ([bool]$restore.Existed) ` + -Content ([byte[]]$restore.Content) + } catch { + $rollbackErrors += "restore '$($restore.Path)': $($_.Exception.Message)" + } + } + } foreach ($restore in @( [pscustomobject]@{ Path = $qualifiedShim; Existed = $qualifiedExisted; Content = $qualifiedContent; Changed = $qualifiedShimRemoved }, [pscustomobject]@{ Path = $qualifiedOwner; Existed = $qualifiedOwnerExisted; Content = $qualifiedOwnerContent; Changed = $qualifiedOwnerRemoved }, - [pscustomobject]@{ Path = $defaultShim; Existed = $defaultExisted; Content = $defaultContent; Changed = $defaultShimRemoved }, + [pscustomobject]@{ Path = $legacyDefaultShim; Existed = $legacyDefaultExisted; Content = $legacyDefaultContent; Changed = $legacyDefaultRemoved }, [pscustomobject]@{ Path = $defaultOwner; Existed = $defaultOwnerExisted; Content = $defaultOwnerContent; Changed = $defaultOwnerRemoved }, [pscustomobject]@{ Path = $defaultState; Existed = $stateExisted; Content = $stateContent; Changed = $stateRemoved }, [pscustomobject]@{ Path = $archiveOwnerMarker; Existed = $ownerMarkerExisted; Content = $ownerMarkerContent; Changed = $ownerMarkerRemoved }, @@ -614,7 +777,7 @@ if ($Uninstall) { Write-Warning "Preserved '$qualifiedShim' because it is not owned by install root '$InstallRoot'." } if ($selected -eq $Profile) { - Write-Warning "Removed the selected '$Profile' profile. The unqualified oscan command now has no default for this install root; reinstall or use -SetDefault with another profile." + Write-Warning "Removed the explicitly selected '$Profile' profile. The selection was preserved; 'oscan' will fail until that profile is reinstalled or you run 'oscan-default set ' or 'oscan-default auto'." } Write-Host "Uninstalled Oscan profile '$Profile'; other profiles were preserved." return @@ -624,24 +787,23 @@ $selectedBeforeInstall = $selected if ($qualifiedOwnership -in @("Foreign", "Unmanaged")) { throw "Refusing to replace qualified command '$qualifiedShim' because it is not owned by install root '$InstallRoot'. Use a different -BinDir or uninstall its owning profile first." } -if ($selectedBeforeInstall -and $defaultOwnership -notin @("Current")) { - throw "Default-profile state at '$defaultState' does not own the shared selector '$defaultShim'. Repair or remove the stale state before installing into this root." +if ($defaultOwnership -in @("Foreign", "Unmanaged")) { + throw "Refusing to replace unqualified command '$selectorExecutable' because it is not a managed Oscan selector. Use a different -BinDir or remove the conflicting command." } -$willSelectProfile = $SetDefault -or - (-not $selectedBeforeInstall -and -not (Test-Path -LiteralPath $defaultShim)) -if ($willSelectProfile -and $defaultOwnership -in @("Foreign", "Unmanaged")) { - throw "Refusing to replace default command '$defaultShim' because it is not owned by install root '$InstallRoot'. Uninstall its owning default or use a different -BinDir." +if ((Test-Path -LiteralPath $defaultManager -PathType Leaf) -and + $defaultOwnership -ne "Current") { + throw "Refusing to replace '$defaultManager' because the shared selector is not managed by this installer." } if (-not $AllowMsiCommandConflict) { $installedMsiProfiles = @() - if ($willSelectProfile -or $Profile -in @("full", "llvm", "cranelift", "c")) { + if ($Profile -in @("full", "llvm", "cranelift", "c")) { $installedMsiProfiles = @(Get-InstalledOscanMsiProfiles) } if ($installedMsiProfiles -contains $Profile) { throw "The Oscan $Profile MSI already owns the qualified oscan-$Profile command. Uninstall that MSI, choose a different archive profile, or pass -AllowMsiCommandConflict to acknowledge ambiguous PATH ownership." } - if ($willSelectProfile -and $installedMsiProfiles.Count -gt 0) { - throw "An Oscan profile MSI is installed and already owns an unqualified oscan command. Installing an archive default would make PATH order ambiguous. Install the qualified archive profile without -SetDefault, uninstall the MSI profiles, or pass -AllowMsiCommandConflict to acknowledge the collision." + if ($installedMsiProfiles.Count -gt 0) { + throw "An Oscan profile MSI is installed and already owns an unqualified oscan command. Installing an archive selector would make PATH order ambiguous. Uninstall the MSI profiles, use a separate -BinDir outside PATH, or pass -AllowMsiCommandConflict to acknowledge the collision." } } @@ -660,11 +822,17 @@ $qualifiedOwnerContent = if ($qualifiedOwnerExisted) { } else { "" } -$defaultExisted = Test-Path -LiteralPath $defaultShim -$defaultContent = if ($defaultExisted) { - Get-Content -LiteralPath $defaultShim -Raw +$selectorExisted = Test-Path -LiteralPath $selectorExecutable -PathType Leaf +$selectorContent = if ($selectorExisted) { + [System.IO.File]::ReadAllBytes($selectorExecutable) } else { - "" + $null +} +$managerExisted = Test-Path -LiteralPath $defaultManager -PathType Leaf +$managerContent = if ($managerExisted) { + [System.IO.File]::ReadAllBytes($defaultManager) +} else { + $null } $defaultOwnerExisted = Test-Path -LiteralPath $defaultOwner $defaultOwnerContent = if ($defaultOwnerExisted) { @@ -672,7 +840,7 @@ $defaultOwnerContent = if ($defaultOwnerExisted) { } else { "" } -$stateExisted = Test-Path -LiteralPath $defaultState +$stateExisted = Test-Path -LiteralPath $defaultState -PathType Leaf $stateContent = if ($stateExisted) { Get-Content -LiteralPath $defaultState -Raw } else { @@ -699,7 +867,9 @@ $profileMarkerContent = if ($profileMarkerExisted) { $payloadActivated = $false $backupCreated = $false $qualifiedChanged = $false -$defaultChanged = $false +$selectorChanged = $false +$managerChanged = $false +$stateChanged = $false $defaultOwnerChanged = $false $markerChanged = $false $profileMarkerChanged = $false @@ -716,6 +886,21 @@ try { if ($stagedDigest -ne $expectedCompilerDigest) { throw "Staged oscan.exe digest mismatch: package metadata has $expectedCompilerDigest, actual is $stagedDigest." } + foreach ($selectorFile in @( + [pscustomobject]@{ Name = "oscan-selector.exe"; Digest = $expectedSelectorDigest }, + [pscustomobject]@{ Name = "oscan-default.exe"; Digest = $expectedManagerDigest } + )) { + $stagedSelector = Join-Path $staged $selectorFile.Name + if (-not (Test-Path -LiteralPath $stagedSelector -PathType Leaf)) { + throw "Staged profile is missing $($selectorFile.Name)." + } + $stagedSelectorDigest = ( + Get-FileHash -LiteralPath $stagedSelector -Algorithm SHA256 + ).Hash + if ($stagedSelectorDigest -ne $selectorFile.Digest) { + throw "Staged $($selectorFile.Name) digest mismatch: package metadata has $($selectorFile.Digest), actual is $stagedSelectorDigest." + } + } } if ($metadata -and -not (Test-Path -LiteralPath (Join-Path $staged "oscan-package.json"))) { throw "Staged profile is missing oscan-package.json." @@ -742,39 +927,71 @@ try { } $qualifiedChanged = $true Write-ProfileShim -Path $qualifiedShim -RelativeExecutable $relativeExecutable - Write-AtomicText -Path $qualifiedOwner -Content "$selectorOwnerId`r`n" + Write-AtomicText -Path $qualifiedOwner -Content "$qualifiedOwnerId`r`n" $profileMarkerOwners = @( $profileMarkerContent -split '\r?\n' | Where-Object { $_ -match '^[0-9a-f]{64}$' } ) - if ($profileMarkerOwners -notcontains $selectorOwnerId) { + if ($profileMarkerOwners -notcontains $qualifiedOwnerId) { $profileMarkerChanged = $true Write-AtomicText -Path $archiveProfileMarker -Content ( - (($profileMarkerOwners + $selectorOwnerId) -join "`r`n") + "`r`n" + (($profileMarkerOwners + $qualifiedOwnerId) -join "`r`n") + "`r`n" ) } - $selected = if (Test-Path -LiteralPath $defaultState) { + $replaceSharedSelector = $true + if ($defaultOwnership -eq "Current" -and + $defaultOwnerExisted -and + $selectorExisted -and + $managerExisted) { + $ownerLines = @($defaultOwnerContent -split '\r?\n') + $installedSelectorVersion = if ($ownerLines.Count -gt 1) { + $ownerLines[1].Trim() + } else { + "" + } + if ($installedSelectorVersion -match '^\d+\.\d+\.\d+(?:\.\d+)?$') { + if ($version -eq "current") { + $replaceSharedSelector = $false + } elseif ($version -match '^\d+\.\d+\.\d+(?:\.\d+)?$' -and + ([version]$installedSelectorVersion -gt [version]$version)) { + $replaceSharedSelector = $false + } + } + } + if ($replaceSharedSelector) { + $selectorChanged = $true + Copy-AtomicFile ` + -Source (Join-Path $versionDir "oscan-selector.exe") ` + -Destination $selectorExecutable + $managerChanged = $true + Copy-AtomicFile ` + -Source (Join-Path $versionDir "oscan-default.exe") ` + -Destination $defaultManager + $defaultOwnerChanged = $true + Write-AtomicText ` + -Path $defaultOwner ` + -Content "$sharedSelectorOwnerId`r`n$version`r`n" + } + + $selected = if (Test-Path -LiteralPath $defaultState -PathType Leaf) { (Get-Content -LiteralPath $defaultState -Raw).Trim() } else { "" } - $defaultExists = Test-Path -LiteralPath $defaultShim - if ($SetDefault -or (-not $selected -and -not $defaultExists)) { - $defaultChanged = $true - Write-DefaultShim -Path $defaultShim -SelectedProfile $Profile + if ($SetDefault) { + $stateChanged = $true Write-AtomicText -Path $defaultState -Content "$Profile`r`n" $selected = $Profile + } elseif ($legacySelected -in @("full", "llvm", "cranelift", "c", "dev", "auto")) { + if (Write-AtomicTextIfAbsent -Path $defaultState -Content "$legacySelected`r`n") { + $stateChanged = $true + } + $selected = (Get-Content -LiteralPath $defaultState -Raw).Trim() } - if ($selected -and - (Test-Path -LiteralPath $defaultShim) -and - ($defaultChanged -or $defaultOwnership -eq "Current")) { - $defaultOwnerChanged = $true - Write-AtomicText -Path $defaultOwner -Content "$selectorOwnerId`r`n" - $markerChanged = $true - Write-AtomicText -Path $archiveOwnerMarker -Content "archive-default-owner`r`n" - Write-AtomicText -Path $archiveDefaultMarker -Content "archive-default`r`n" - } + $markerChanged = $true + Write-AtomicText -Path $archiveOwnerMarker -Content "archive-command-owner`r`n" + Write-AtomicText -Path $archiveDefaultMarker -Content "archive-command`r`n" $committed = $true Get-ChildItem -LiteralPath $profileRoot -Directory -ErrorAction SilentlyContinue | @@ -808,12 +1025,31 @@ try { $rollbackErrors += "restore qualified command: $($_.Exception.Message)" } } - if ($defaultChanged) { + if ($selectorChanged) { + try { + Restore-AtomicBytes ` + -Path $selectorExecutable ` + -Existed $selectorExisted ` + -Content $selectorContent + } catch { + $rollbackErrors += "restore unqualified selector: $($_.Exception.Message)" + } + } + if ($managerChanged) { + try { + Restore-AtomicBytes ` + -Path $defaultManager ` + -Existed $managerExisted ` + -Content $managerContent + } catch { + $rollbackErrors += "restore profile manager: $($_.Exception.Message)" + } + } + if ($stateChanged) { try { - Restore-AtomicFile -Path $defaultShim -Existed $defaultExisted -Content $defaultContent Restore-AtomicFile -Path $defaultState -Existed $stateExisted -Content $stateContent } catch { - $rollbackErrors += "restore default selector: $($_.Exception.Message)" + $rollbackErrors += "restore default-profile state: $($_.Exception.Message)" } } if ($defaultOwnerChanged) { @@ -845,6 +1081,15 @@ try { $rollbackErrors += "remove profile-link junction: $($_.Exception.Message)" } } + try { + Remove-Item -LiteralPath $staged -Recurse -Force -ErrorAction SilentlyContinue + if ((Test-Path -LiteralPath $profileRoot -PathType Container) -and + @(Get-ChildItem -LiteralPath $profileRoot -Force).Count -eq 0) { + Remove-Item -LiteralPath $profileRoot -Force -ErrorAction Stop + } + } catch { + $rollbackErrors += "remove empty failed-install profile directory: $($_.Exception.Message)" + } if ($rollbackErrors.Count -gt 0) { throw "Installing profile '$Profile' failed: $($installError.Exception.Message). Rollback also failed: $($rollbackErrors -join '; ')." } @@ -866,10 +1111,11 @@ if (-not $NoPathUpdate) { Write-Host "Installed Oscan profile '$Profile' to $versionDir" Write-Host "Qualified command: $(Join-Path $BinDir "oscan-$Profile.cmd")" -if ($selected -eq $Profile) { - Write-Host "Default command: $(Join-Path $BinDir "oscan.cmd") -> oscan-$Profile" +Write-Host "Default manager: $defaultManager" +if ($selected) { + Write-Host "Default selection: $selected" } else { - Write-Host "Default remains '$selected'. Re-run with -SetDefault to select '$Profile'." + Write-Host "No default is selected yet. The first 'oscan' invocation will select the highest-priority installed profile, or run 'oscan-default set $Profile' now." } } finally { if ($markerLock) { diff --git a/scripts/install-oscan.sh b/scripts/install-oscan.sh index d7d312f..285e923 100644 --- a/scripts/install-oscan.sh +++ b/scripts/install-oscan.sh @@ -7,6 +7,8 @@ BIN_DIR="$HOME/.local/bin" PROFILE="" VERSION="" EXPECTED_COMPILER_DIGEST="" +EXPECTED_SELECTOR_DIGEST="" +EXPECTED_MANAGER_DIGEST="" SET_DEFAULT=0 UNINSTALL=0 CREATE_LINK=1 @@ -164,6 +166,8 @@ if [ -f "$METADATA" ]; then PACKAGE_TARGET="$(metadata_top_string target)" DEFAULT_BACKEND="$(metadata_top_string default_backend)" EXPECTED_COMPILER_DIGEST="$(metadata_component_digest oscan)" + EXPECTED_SELECTOR_DIGEST="$(metadata_component_digest oscan-selector)" + EXPECTED_MANAGER_DIGEST="$(metadata_component_digest oscan-default)" case "$PACKAGE_PROFILE" in full|llvm|cranelift|c) ;; *) fail "package metadata contains unknown profile '$PACKAGE_PROFILE'" ;; @@ -197,6 +201,16 @@ if [ -f "$METADATA" ]; then esac [ "${#EXPECTED_COMPILER_DIGEST}" -eq 64 ] || fail "package metadata does not declare a valid SHA-256 digest for oscan" + case "$EXPECTED_SELECTOR_DIGEST" in + *[!0-9a-fA-F]*|"") fail "package metadata does not declare a valid SHA-256 digest for oscan-selector" ;; + esac + [ "${#EXPECTED_SELECTOR_DIGEST}" -eq 64 ] || + fail "package metadata does not declare a valid SHA-256 digest for oscan-selector" + case "$EXPECTED_MANAGER_DIGEST" in + *[!0-9a-fA-F]*|"") fail "package metadata does not declare a valid SHA-256 digest for oscan-default" ;; + esac + [ "${#EXPECTED_MANAGER_DIGEST}" -eq 64 ] || + fail "package metadata does not declare a valid SHA-256 digest for oscan-default" elif [ "$UNINSTALL" -eq 1 ]; then case "$PROFILE" in full|llvm|cranelift|c|dev) ;; @@ -215,6 +229,10 @@ if [ "$UNINSTALL" -eq 0 ]; then ;; esac [ -f "$SOURCE_DIR/oscan" ] || fail "source bundle must contain an oscan binary" + [ -f "$SOURCE_DIR/oscan-selector" ] || + fail "source bundle must contain an oscan-selector binary" + [ -f "$SOURCE_DIR/oscan-default" ] || + fail "source bundle must contain an oscan-default binary" if [ -n "$EXPECTED_COMPILER_DIGEST" ]; then if command -v sha256sum >/dev/null 2>&1; then ACTUAL_COMPILER_DIGEST="$(sha256sum "$SOURCE_DIR/oscan")" @@ -227,6 +245,19 @@ if [ "$UNINSTALL" -eq 0 ]; then fi [ "$ACTUAL_COMPILER_DIGEST" = "$EXPECTED_COMPILER_DIGEST" ] || fail "oscan digest mismatch: package metadata has $EXPECTED_COMPILER_DIGEST, actual is $ACTUAL_COMPILER_DIGEST" + if command -v sha256sum >/dev/null 2>&1; then + ACTUAL_SELECTOR_DIGEST="$(sha256sum "$SOURCE_DIR/oscan-selector")" + ACTUAL_MANAGER_DIGEST="$(sha256sum "$SOURCE_DIR/oscan-default")" + else + ACTUAL_SELECTOR_DIGEST="$(shasum -a 256 "$SOURCE_DIR/oscan-selector")" + ACTUAL_MANAGER_DIGEST="$(shasum -a 256 "$SOURCE_DIR/oscan-default")" + fi + ACTUAL_SELECTOR_DIGEST="${ACTUAL_SELECTOR_DIGEST%% *}" + ACTUAL_MANAGER_DIGEST="${ACTUAL_MANAGER_DIGEST%% *}" + [ "$ACTUAL_SELECTOR_DIGEST" = "$EXPECTED_SELECTOR_DIGEST" ] || + fail "oscan-selector digest mismatch: package metadata has $EXPECTED_SELECTOR_DIGEST, actual is $ACTUAL_SELECTOR_DIGEST" + [ "$ACTUAL_MANAGER_DIGEST" = "$EXPECTED_MANAGER_DIGEST" ] || + fail "oscan-default digest mismatch: package metadata has $EXPECTED_MANAGER_DIGEST, actual is $ACTUAL_MANAGER_DIGEST" fi fi @@ -235,8 +266,16 @@ PROFILE_ROOT="$PROFILES_ROOT/$PROFILE" QUALIFIED_LINK="$BIN_DIR/oscan-$PROFILE" QUALIFIED_OWNER="$BIN_DIR/.oscan-$PROFILE.owner" DEFAULT_LINK="$BIN_DIR/oscan" +DEFAULT_MANAGER="$BIN_DIR/oscan-default" DEFAULT_OWNER="$BIN_DIR/.oscan-default.owner" -DEFAULT_STATE="$INSTALL_ROOT/default-profile" +CONFIG_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/oscan" +DEFAULT_STATE="$CONFIG_ROOT/default-profile" +LEGACY_DEFAULT_STATE="$INSTALL_ROOT/default-profile" +SHARED_SELECTOR_OWNER="oscan-selector-v1" + +if [ "$SET_DEFAULT" -eq 1 ] && [ "$CREATE_LINK" -eq 0 ]; then + fail "--set-default cannot be combined with --no-bin-link; install the selector first" +fi atomic_symlink() { _target="$1" @@ -256,14 +295,44 @@ atomic_symlink() { atomic_text() { _text="$1" _path="$2" + mkdir -p "$(dirname -- "$_path")" _temporary="$(dirname -- "$_path")/.tmp-$(basename -- "$_path")-$$" printf '%s\n' "$_text" > "$_temporary" mv -f "$_temporary" "$_path" } +atomic_text_if_absent() { + _text="$1" + _path="$2" + mkdir -p "$(dirname -- "$_path")" + _temporary="$(dirname -- "$_path")/.tmp-$(basename -- "$_path")-$$" + rm -f "$_temporary" + printf '%s\n' "$_text" > "$_temporary" + if ln "$_temporary" "$_path" 2>/dev/null; then + rm -f "$_temporary" + return 0 + fi + rm -f "$_temporary" + [ -f "$_path" ] && return 1 + fail "could not atomically create state file '$_path'" +} + +atomic_copy() { + _source="$1" + _path="$2" + mkdir -p "$(dirname -- "$_path")" + _temporary="$(dirname -- "$_path")/.tmp-$(basename -- "$_path")-$$" + rm -f "$_temporary" + cp "$_source" "$_temporary" + chmod 755 "$_temporary" + mv -f "$_temporary" "$_path" +} + selected_profile() { if [ -f "$DEFAULT_STATE" ]; then sed -n '1p' "$DEFAULT_STATE" + elif [ -f "$LEGACY_DEFAULT_STATE" ]; then + sed -n '1p' "$LEGACY_DEFAULT_STATE" fi } @@ -291,25 +360,27 @@ qualified_ownership() { fi } -default_ownership() { +selector_ownership() { _selected="$1" - if [ -e "$DEFAULT_LINK" ] && [ ! -L "$DEFAULT_LINK" ]; then - printf '%s\n' unmanaged - elif [ -f "$DEFAULT_OWNER" ]; then - if [ "$(sed -n '1p' "$DEFAULT_OWNER")" = "$INSTALL_ROOT" ]; then - printf '%s\n' current - else - printf '%s\n' foreign - fi + if [ -f "$DEFAULT_OWNER" ] && + [ "$(sed -n '1p' "$DEFAULT_OWNER")" = "$SHARED_SELECTOR_OWNER" ]; then + printf '%s\n' current elif [ -L "$DEFAULT_LINK" ]; then if [ -n "$_selected" ] && + [ "$_selected" != auto ] && [ "$(readlink "$DEFAULT_LINK")" = "oscan-$_selected" ] && [ "$(qualified_ownership "$_selected")" = current ]; then + printf '%s\n' legacy + else + printf '%s\n' foreign + fi + elif [ -f "$DEFAULT_OWNER" ]; then + if [ "$(sed -n '1p' "$DEFAULT_OWNER")" = "$SHARED_SELECTOR_OWNER" ]; then printf '%s\n' current else printf '%s\n' foreign fi - elif [ -e "$DEFAULT_LINK" ]; then + elif [ -e "$DEFAULT_LINK" ] || [ -L "$DEFAULT_LINK" ]; then printf '%s\n' unmanaged else printf '%s\n' absent @@ -318,20 +389,25 @@ default_ownership() { SELECTED="$(selected_profile)" case "$SELECTED" in - ""|full|llvm|cranelift|c|dev) ;; + ""|auto|full|llvm|cranelift|c|dev) ;; *) fail "default-profile contains unknown profile '$SELECTED'" ;; esac +LEGACY_SELECTED="" +if [ -f "$LEGACY_DEFAULT_STATE" ]; then + LEGACY_SELECTED="$(sed -n '1p' "$LEGACY_DEFAULT_STATE")" +fi +case "$LEGACY_SELECTED" in + full|llvm|cranelift|c|dev) LEGACY_OWNERSHIP_PROFILE="$LEGACY_SELECTED" ;; + *) LEGACY_OWNERSHIP_PROFILE="$SELECTED" ;; +esac QUALIFIED_OWNERSHIP="$(qualified_ownership "$PROFILE")" -DEFAULT_OWNERSHIP="$(default_ownership "$SELECTED")" +DEFAULT_OWNERSHIP="$(selector_ownership "$LEGACY_OWNERSHIP_PROFILE")" if [ "$UNINSTALL" -eq 1 ]; then UNINSTALL_BACKUP="$PROFILES_ROOT/.$PROFILE-uninstall-$$" PAYLOAD_STAGED_FOR_REMOVAL=0 QUALIFIED_LINK_REMOVED=0 QUALIFIED_OWNER_REMOVED=0 - DEFAULT_LINK_REMOVED=0 - DEFAULT_OWNER_REMOVED=0 - STATE_REMOVED=0 UNINSTALL_COMMITTED=0 QUALIFIED_EXISTED=0 @@ -346,22 +422,6 @@ if [ "$UNINSTALL" -eq 1 ]; then QUALIFIED_OWNER_EXISTED=1 QUALIFIED_OWNER_VALUE="$(sed -n '1p' "$QUALIFIED_OWNER")" fi - DEFAULT_EXISTED=0 - DEFAULT_TARGET="" - if [ -L "$DEFAULT_LINK" ]; then - DEFAULT_EXISTED=1 - DEFAULT_TARGET="$(readlink "$DEFAULT_LINK")" - fi - DEFAULT_OWNER_EXISTED=0 - DEFAULT_OWNER_VALUE="" - if [ -f "$DEFAULT_OWNER" ]; then - DEFAULT_OWNER_EXISTED=1 - DEFAULT_OWNER_VALUE="$(sed -n '1p' "$DEFAULT_OWNER")" - fi - STATE_EXISTED=0 - STATE_VALUE="$SELECTED" - [ ! -f "$DEFAULT_STATE" ] || STATE_EXISTED=1 - rollback_uninstall() { _status="$?" trap - EXIT HUP INT TERM @@ -388,27 +448,6 @@ if [ "$UNINSTALL" -eq 1 ]; then _rollback_failed=1 } fi - if [ "$DEFAULT_LINK_REMOVED" -eq 1 ] && - [ "$DEFAULT_EXISTED" -eq 1 ]; then - atomic_symlink "$DEFAULT_TARGET" "$DEFAULT_LINK" || { - echo "warning: could not restore '$DEFAULT_LINK'" >&2 - _rollback_failed=1 - } - fi - if [ "$DEFAULT_OWNER_REMOVED" -eq 1 ] && - [ "$DEFAULT_OWNER_EXISTED" -eq 1 ]; then - atomic_text "$DEFAULT_OWNER_VALUE" "$DEFAULT_OWNER" || { - echo "warning: could not restore '$DEFAULT_OWNER'" >&2 - _rollback_failed=1 - } - fi - if [ "$STATE_REMOVED" -eq 1 ] && - [ "$STATE_EXISTED" -eq 1 ]; then - atomic_text "$STATE_VALUE" "$DEFAULT_STATE" || { - echo "warning: could not restore '$DEFAULT_STATE'" >&2 - _rollback_failed=1 - } - fi fi [ "$_rollback_failed" -eq 0 ] || echo "warning: uninstall rollback was incomplete" >&2 @@ -433,22 +472,6 @@ if [ "$UNINSTALL" -eq 1 ]; then QUALIFIED_OWNER_REMOVED=1 fi fi - if [ "$SELECTED" = "$PROFILE" ]; then - if [ "$DEFAULT_OWNERSHIP" = current ]; then - if [ -L "$DEFAULT_LINK" ]; then - rm -f "$DEFAULT_LINK" - DEFAULT_LINK_REMOVED=1 - fi - if [ -f "$DEFAULT_OWNER" ]; then - rm -f "$DEFAULT_OWNER" - DEFAULT_OWNER_REMOVED=1 - fi - fi - if [ -f "$DEFAULT_STATE" ]; then - rm -f "$DEFAULT_STATE" - STATE_REMOVED=1 - fi - fi UNINSTALL_COMMITTED=1 if [ "$PAYLOAD_STAGED_FOR_REMOVAL" -eq 1 ] && ! rm -rf "$UNINSTALL_BACKUP"; then @@ -459,7 +482,7 @@ if [ "$UNINSTALL" -eq 1 ]; then echo "warning: preserved '$QUALIFIED_LINK' because it is not owned by install root '$INSTALL_ROOT'" >&2 fi if [ "$SELECTED" = "$PROFILE" ]; then - echo "warning: removed the selected '$PROFILE' profile; the unqualified oscan command now has no default for this install root" >&2 + echo "warning: removed the explicitly selected '$PROFILE' profile; the selection was preserved, so 'oscan' will fail until that profile is reinstalled or you run 'oscan-default set ' or 'oscan-default auto'" >&2 fi trap - EXIT HUP INT TERM release_install_lock @@ -491,56 +514,78 @@ if [ "$CREATE_LINK" -eq 1 ]; then QUALIFIED_OWNER_EXISTED=1 QUALIFIED_OWNER_VALUE="$(sed -n '1p' "$QUALIFIED_OWNER")" fi -fi - -if [ "$CREATE_LINK" -eq 1 ] && - [ -n "$SELECTED" ] && - [ "$DEFAULT_OWNERSHIP" != current ]; then - fail "default-profile state at '$DEFAULT_STATE' does not own shared selector '$DEFAULT_LINK'" -fi -SET_DEFAULT_NOW=0 -if [ "$CREATE_LINK" -eq 1 ] && - { [ "$SET_DEFAULT" -eq 1 ] || { [ -z "$SELECTED" ] && [ ! -e "$DEFAULT_LINK" ] && [ ! -L "$DEFAULT_LINK" ]; }; }; then - SET_DEFAULT_NOW=1 -fi -if [ "$SET_DEFAULT_NOW" -eq 1 ]; then case "$DEFAULT_OWNERSHIP" in foreign|unmanaged) - fail "refusing to replace default command '$DEFAULT_LINK' because it is not owned by install root '$INSTALL_ROOT'" + fail "refusing to replace unqualified command '$DEFAULT_LINK' because it is not a managed Oscan selector" ;; esac + if [ -e "$DEFAULT_MANAGER" ] && [ "$DEFAULT_OWNERSHIP" != current ]; then + fail "refusing to replace '$DEFAULT_MANAGER' because the shared selector is not managed by this installer" + fi fi -DEFAULT_EXISTED=0 +DEFAULT_KIND=absent DEFAULT_TARGET="" +DEFAULT_BACKUP="$PROFILE_ROOT/.selector-backup-$$" +MANAGER_EXISTED=0 +MANAGER_BACKUP="$PROFILE_ROOT/.manager-backup-$$" DEFAULT_OWNER_EXISTED=0 DEFAULT_OWNER_VALUE="" STATE_EXISTED=0 STATE_VALUE="$SELECTED" -if [ "$SET_DEFAULT_NOW" -eq 1 ]; then +if [ "$CREATE_LINK" -eq 1 ]; then if [ -L "$DEFAULT_LINK" ]; then - DEFAULT_EXISTED=1 + DEFAULT_KIND=symlink DEFAULT_TARGET="$(readlink "$DEFAULT_LINK")" - elif [ -e "$DEFAULT_LINK" ]; then - fail "refusing to replace unmanaged command '$DEFAULT_LINK'" + elif [ -f "$DEFAULT_LINK" ]; then + DEFAULT_KIND=file + cp -p "$DEFAULT_LINK" "$DEFAULT_BACKUP" + fi + if [ -f "$DEFAULT_MANAGER" ]; then + MANAGER_EXISTED=1 + cp -p "$DEFAULT_MANAGER" "$MANAGER_BACKUP" fi if [ -f "$DEFAULT_OWNER" ]; then DEFAULT_OWNER_EXISTED=1 - DEFAULT_OWNER_VALUE="$(sed -n '1p' "$DEFAULT_OWNER")" + DEFAULT_OWNER_VALUE="$(cat "$DEFAULT_OWNER")" fi [ ! -f "$DEFAULT_STATE" ] || STATE_EXISTED=1 -elif [ "$DEFAULT_OWNERSHIP" = current ] && [ -f "$DEFAULT_OWNER" ]; then - DEFAULT_OWNER_EXISTED=1 - DEFAULT_OWNER_VALUE="$(sed -n '1p' "$DEFAULT_OWNER")" fi PAYLOAD_ACTIVATED=0 BACKUP_CREATED=0 QUALIFIED_CHANGED=0 -DEFAULT_CHANGED=0 +SELECTOR_CHANGED=0 +MANAGER_CHANGED=0 +STATE_CHANGED=0 DEFAULT_OWNER_CHANGED=0 COMMITTED=0 +restore_selector() { + rm -f "$DEFAULT_LINK" + case "$DEFAULT_KIND" in + file) cp -p "$DEFAULT_BACKUP" "$DEFAULT_LINK" ;; + symlink) atomic_symlink "$DEFAULT_TARGET" "$DEFAULT_LINK" ;; + esac +} + +version_core() { + printf '%s\n' "$1" | sed 's/^v//; s/[-+].*$//' +} + +version_gt() { + awk -v left="$1" -v right="$2" 'BEGIN { + split(left, l, "."); split(right, r, "."); + for (i = 1; i <= 4; i++) { + lv = (i in l) ? l[i] + 0 : 0; + rv = (i in r) ? r[i] + 0 : 0; + if (lv > rv) exit 0; + if (lv < rv) exit 1; + } + exit 1; + }' +} + rollback_install() { _status="$?" trap - EXIT HUP INT TERM @@ -564,12 +609,18 @@ rollback_install() { echo "warning: could not restore '$QUALIFIED_OWNER'" >&2 fi fi - if [ "$DEFAULT_CHANGED" -eq 1 ]; then - rm -f "$DEFAULT_LINK" - if [ "$DEFAULT_EXISTED" -eq 1 ]; then - atomic_symlink "$DEFAULT_TARGET" "$DEFAULT_LINK" || - echo "warning: could not restore '$DEFAULT_LINK'" >&2 + if [ "$SELECTOR_CHANGED" -eq 1 ]; then + restore_selector || + echo "warning: could not restore '$DEFAULT_LINK'" >&2 + fi + if [ "$MANAGER_CHANGED" -eq 1 ]; then + rm -f "$DEFAULT_MANAGER" + if [ "$MANAGER_EXISTED" -eq 1 ]; then + cp -p "$MANAGER_BACKUP" "$DEFAULT_MANAGER" || + echo "warning: could not restore '$DEFAULT_MANAGER'" >&2 fi + fi + if [ "$STATE_CHANGED" -eq 1 ]; then if [ "$STATE_EXISTED" -eq 1 ]; then atomic_text "$STATE_VALUE" "$DEFAULT_STATE" || echo "warning: could not restore '$DEFAULT_STATE'" >&2 @@ -586,6 +637,7 @@ rollback_install() { fi fi rm -rf "$STAGED" + rm -f "$DEFAULT_BACKUP" "$MANAGER_BACKUP" if [ "$COMMITTED" -eq 1 ]; then rm -rf "$BACKUP" fi @@ -610,11 +662,26 @@ if [ -n "$EXPECTED_COMPILER_DIGEST" ]; then fi [ "$STAGED_COMPILER_DIGEST" = "$EXPECTED_COMPILER_DIGEST" ] || fail "staged oscan digest mismatch: package metadata has $EXPECTED_COMPILER_DIGEST, actual is $STAGED_COMPILER_DIGEST" + if command -v sha256sum >/dev/null 2>&1; then + STAGED_SELECTOR_DIGEST="$(sha256sum "$STAGED/oscan-selector")" + STAGED_MANAGER_DIGEST="$(sha256sum "$STAGED/oscan-default")" + else + STAGED_SELECTOR_DIGEST="$(shasum -a 256 "$STAGED/oscan-selector")" + STAGED_MANAGER_DIGEST="$(shasum -a 256 "$STAGED/oscan-default")" + fi + STAGED_SELECTOR_DIGEST="${STAGED_SELECTOR_DIGEST%% *}" + STAGED_MANAGER_DIGEST="${STAGED_MANAGER_DIGEST%% *}" + [ "$STAGED_SELECTOR_DIGEST" = "$EXPECTED_SELECTOR_DIGEST" ] || + fail "staged oscan-selector digest mismatch: package metadata has $EXPECTED_SELECTOR_DIGEST, actual is $STAGED_SELECTOR_DIGEST" + [ "$STAGED_MANAGER_DIGEST" = "$EXPECTED_MANAGER_DIGEST" ] || + fail "staged oscan-default digest mismatch: package metadata has $EXPECTED_MANAGER_DIGEST, actual is $STAGED_MANAGER_DIGEST" fi if [ -f "$METADATA" ]; then [ -f "$STAGED/oscan-package.json" ] || fail "staged profile is missing oscan-package.json" fi chmod +x "$STAGED/oscan" +[ ! -f "$STAGED/oscan-selector" ] || chmod +x "$STAGED/oscan-selector" +[ ! -f "$STAGED/oscan-default" ] || chmod +x "$STAGED/oscan-default" [ ! -f "$STAGED/install.sh" ] || chmod +x "$STAGED/install.sh" if [ -e "$VERSION_DIR" ]; then @@ -631,16 +698,42 @@ if [ "$CREATE_LINK" -eq 1 ]; then fail "could not activate the oscan-$PROFILE command" atomic_text "$INSTALL_ROOT" "$QUALIFIED_OWNER" - if [ "$SET_DEFAULT_NOW" -eq 1 ]; then - DEFAULT_CHANGED=1 - atomic_symlink "oscan-$PROFILE" "$DEFAULT_LINK" - atomic_text "$PROFILE" "$DEFAULT_STATE" - SELECTED="$PROFILE" + REPLACE_SHARED=1 + if [ "$DEFAULT_OWNERSHIP" = current ] && + [ -f "$DEFAULT_LINK" ] && + [ -f "$DEFAULT_MANAGER" ]; then + INSTALLED_SELECTOR_VERSION="$(sed -n '2p' "$DEFAULT_OWNER")" + INSTALLED_CORE="$(version_core "$INSTALLED_SELECTOR_VERSION")" + SOURCE_CORE="$(version_core "$VERSION")" + if printf '%s\n' "$INSTALLED_CORE" | grep -Eq '^[0-9]+(\.[0-9]+){2,3}$' && + printf '%s\n' "$SOURCE_CORE" | grep -Eq '^[0-9]+(\.[0-9]+){2,3}$' && + version_gt "$INSTALLED_CORE" "$SOURCE_CORE"; then + REPLACE_SHARED=0 + elif [ "$VERSION" = current ] && + printf '%s\n' "$INSTALLED_CORE" | grep -Eq '^[0-9]+(\.[0-9]+){2,3}$'; then + REPLACE_SHARED=0 + fi fi - if [ -n "$SELECTED" ] && - { [ "$SET_DEFAULT_NOW" -eq 1 ] || [ "$DEFAULT_OWNERSHIP" = current ]; }; then + if [ "$REPLACE_SHARED" -eq 1 ]; then + SELECTOR_CHANGED=1 + atomic_copy "$VERSION_DIR/oscan-selector" "$DEFAULT_LINK" + MANAGER_CHANGED=1 + atomic_copy "$VERSION_DIR/oscan-default" "$DEFAULT_MANAGER" DEFAULT_OWNER_CHANGED=1 - atomic_text "$INSTALL_ROOT" "$DEFAULT_OWNER" + atomic_text "$SHARED_SELECTOR_OWNER +$VERSION" "$DEFAULT_OWNER" + fi + + if [ "$SET_DEFAULT" -eq 1 ]; then + STATE_CHANGED=1 + atomic_text "$PROFILE" "$DEFAULT_STATE" + SELECTED="$PROFILE" + elif [ -n "$SELECTED" ]; then + if atomic_text_if_absent "$SELECTED" "$DEFAULT_STATE"; then + STATE_CHANGED=1 + else + SELECTED="$(sed -n '1p' "$DEFAULT_STATE")" + fi fi fi @@ -656,6 +749,7 @@ if [ "$CREATE_LINK" -eq 1 ]; then done fi rm -rf "$PROFILE_ROOT"/.install-* "$PROFILE_ROOT"/.backup-* +rm -f "$DEFAULT_BACKUP" "$MANAGER_BACKUP" trap - EXIT HUP INT TERM release_install_lock @@ -667,10 +761,11 @@ fi echo "Installed Oscan profile '$PROFILE' to $VERSION_DIR" if [ "$CREATE_LINK" -eq 1 ]; then echo "Qualified command: $QUALIFIED_LINK" - if [ "$SELECTED" = "$PROFILE" ]; then - echo "Default command: $DEFAULT_LINK -> oscan-$PROFILE" + echo "Default manager: $DEFAULT_MANAGER" + if [ -n "$SELECTED" ]; then + echo "Default selection: $SELECTED" else - echo "Default remains '$SELECTED'. Re-run with --set-default to select '$PROFILE'." + echo "No default is selected yet. The first 'oscan' invocation will select the highest-priority installed profile, or run 'oscan-default set $PROFILE' now." fi else echo "Run $VERSION_DIR/oscan directly or add a qualified link yourself." diff --git a/scripts/release_tools.py b/scripts/release_tools.py index 8332fc5..86d4e7b 100644 --- a/scripts/release_tools.py +++ b/scripts/release_tools.py @@ -8,6 +8,7 @@ import json import os import platform +import re import shutil import stat import subprocess @@ -1842,8 +1843,14 @@ def write_install_readme(path: Path, variant: dict, asset_name: str) -> None: f"Default backend: {variant['default_backend']}", "", install_hint, - "Installing this profile does not remove other profiles or change the unqualified " - "'oscan' command unless the installer is explicitly asked to select it.", + "Installing or upgrading this profile does not remove other profiles or change an " + "existing unqualified 'oscan' selection.", + f"Select it explicitly with 'oscan-default set {profile}'. Use 'oscan-default auto' only " + "when you want full > llvm > cranelift > c automatic fallback.", + "If no selection exists, the first 'oscan' invocation selects the highest-priority " + "installed profile and keeps that explicit choice.", + "Removing the selected profile preserves the choice and makes 'oscan' fail with repair " + "instructions instead of silently changing backends.", "", "What this package contains", "--------------------------", @@ -3044,6 +3051,23 @@ def stage_release(args: argparse.Namespace) -> int: binary_destination.chmod(0o755) component_digests = {variant["binary_name"]: compute_digest(binary_destination, "sha256")} + selector_names = ( + ("oscan-selector.exe", "oscan-default.exe") + if platform == "windows" + else ("oscan-selector", "oscan-default") + ) + for argument_name, destination_name in zip( + ("selector_binary", "default_manager_binary"), selector_names + ): + source = Path(getattr(args, argument_name)).resolve() + if not source.is_file(): + fail(f"{argument_name.replace('_', ' ')} not found: {source}") + destination = bundle_dir / destination_name + copy_path(source, destination) + if platform != "windows": + destination.chmod(0o755) + component_digests[destination_name] = compute_digest(destination, "sha256") + if variant.get("link_mode") == "inprocess": if not args.embedded_notices_dir: fail( @@ -3545,7 +3569,9 @@ def _verify_absent_components(root: Path, variant: dict) -> None: ) -def _verify_inprocess_package_payload(root: Path, variant: dict) -> None: +def _verify_inprocess_package_payload( + root: Path, variant: dict, selectors_in_profile: bool = True +) -> None: notice_root = root / "LICENSES" / "embedded" expected = set(variant["embedded_notice_files"]) if not notice_root.is_dir(): @@ -3579,6 +3605,21 @@ def _verify_inprocess_package_payload(root: Path, variant: dict) -> None: "README-install.txt", *(f"LICENSES/embedded/{relative}" for relative in expected), } + if selectors_in_profile: + expected_files.update( + { + ( + "oscan-selector.exe" + if variant["platform"] == "windows" + else "oscan-selector" + ), + ( + "oscan-default.exe" + if variant["platform"] == "windows" + else "oscan-default" + ), + } + ) allowed_files = { *expected_files, "install.ps1" if variant["platform"] == "windows" else "install.sh", @@ -3605,7 +3646,8 @@ def _verify_inprocess_package_payload(root: Path, variant: dict) -> None: or found_directories != expected_directories ): fail( - f"{variant['target']}/{variant['backend']} must be a single-executable compiler and " + f"{variant['target']}/{variant['backend']} must contain one compiler plus only the " + "universal selector executables and declared support files, but it " "contains undeclared payload " f"(extra files: {sorted(found_files - allowed_files)}, " f"missing files: {sorted(expected_files - found_files)}, " @@ -3670,6 +3712,7 @@ def verify_package_layout( version: str | None = None, archive: Path | None = None, expect_archive_root_name: bool = False, + selector_root: Path | None = None, ) -> dict: """Assert that an extracted or installed package is exactly the variant the contract describes — nothing missing, nothing extra. @@ -3713,6 +3756,40 @@ def verify_package_layout( if variant["platform"] != "windows" and not os.access(binary, os.X_OK): fail(f"packaged compiler binary is not executable: {binary}") + selector_names = ( + ("oscan-selector.exe", "oscan-default.exe") + if variant["platform"] == "windows" + else ("oscan-selector", "oscan-default") + ) + resolved_selector_root = (selector_root or root).resolve() + if not resolved_selector_root.is_dir(): + fail(f"selector root '{resolved_selector_root}' is not a directory") + selectors_are_external = resolved_selector_root != root.resolve() + for relative in (variant["binary_name"], *selector_names): + if relative == variant["binary_name"]: + file_path = root / relative + else: + installed_name = relative + if selectors_are_external and relative == selector_names[0]: + installed_name = "oscan.exe" if variant["platform"] == "windows" else "oscan" + file_path = resolved_selector_root / installed_name + if not file_path.is_file() or file_path.stat().st_size == 0: + fail(f"package is missing required executable '{relative}'") + if variant["platform"] != "windows" and not os.access(file_path, os.X_OK): + fail(f"packaged executable is not executable: {file_path}") + declared_digest = metadata.get("component_digests", {}).get(relative) + if ( + not isinstance(declared_digest, str) + or not re.fullmatch(r"[0-9a-fA-F]{64}", declared_digest) + ): + fail(f"{PACKAGE_METADATA_NAME} has no valid digest for '{relative}'") + actual_digest = compute_digest(file_path, "sha256") + if actual_digest.lower() != declared_digest.lower(): + fail( + f"packaged executable '{relative}' digest mismatch: metadata has " + f"{declared_digest}, actual is {actual_digest}" + ) + sidecar = None if "direct_link_sidecar" in variant["components"]: sidecar = _verify_sidecar_component(root, variant) @@ -3727,11 +3804,19 @@ def verify_package_layout( if variant["package_kind"] == "object": _verify_object_package_payload(root, variant) if variant.get("link_mode") == "inprocess": - _verify_inprocess_package_payload(root, variant) + _verify_inprocess_package_payload( + root, + variant, + selectors_in_profile=not selectors_are_external, + ) elif variant["package_kind"] == "c-source": _verify_c_package_payload(root, variant) elif variant.get("link_mode") == "inprocess": - _verify_inprocess_package_payload(root, variant) + _verify_inprocess_package_payload( + root, + variant, + selectors_in_profile=not selectors_are_external, + ) if variant.get("note_file") and not (root / Path(variant["note_file"]).name).is_file(): fail(f"package is missing the contract note file '{variant['note_file']}'") @@ -3750,6 +3835,7 @@ def verify_package_layout_command(args: argparse.Namespace) -> int: version=args.version, archive=Path(args.archive).resolve() if args.archive else None, expect_archive_root_name=args.stage == "extracted", + selector_root=Path(args.selector_root).resolve() if args.selector_root else None, ) print( f"{args.target}/{profile} {args.stage} package layout OK " @@ -5332,6 +5418,8 @@ def build_parser() -> argparse.ArgumentParser: stage.add_argument("--contract", default=str(CONTRACT_PATH)) stage.add_argument("--version", required=True) stage.add_argument("--binary", required=True) + stage.add_argument("--selector-binary", required=True) + stage.add_argument("--default-manager-binary", required=True) stage.add_argument("--output-dir", required=True) stage.add_argument( "--runtime-archive-dir", @@ -5499,6 +5587,14 @@ def build_parser() -> argparse.ArgumentParser: default=None, help="also check this archive's name/suffix against the contract", ) + verify_layout.add_argument( + "--selector-root", + default=None, + help=( + "directory containing shared selector executables when an installed package, " + "such as an MSI profile, stores them outside --root" + ), + ) verify_layout.add_argument("--contract", default=str(CONTRACT_PATH)) verify_layout.set_defaults(func=verify_package_layout_command) diff --git a/scripts/smoke-release.ps1 b/scripts/smoke-release.ps1 index fd0ff93..824001a 100644 --- a/scripts/smoke-release.ps1 +++ b/scripts/smoke-release.ps1 @@ -18,6 +18,11 @@ param( [Parameter(Mandatory = $true, ParameterSetName = "Installed")] [string]$InstalledPackageDir, + # Shared command directory for installed formats such as MSI, where the + # selectors are intentionally outside the profile payload. + [Parameter(ParameterSetName = "Installed")] + [string]$InstalledSelectorDir, + # The release version this package claims to be. Checked against the # archive name (when present), package metadata, and compiler --version. [string]$Version, @@ -57,6 +62,12 @@ if ($PSCmdlet.ParameterSetName -eq "Archive") { throw "-InstalledPackageDir must name an installed package directory, not '$InstalledPackageDir'." } $InstalledPackageDir = (Resolve-Path -LiteralPath $InstalledPackageDir).Path + if ($InstalledSelectorDir) { + if (-not (Test-Path -LiteralPath $InstalledSelectorDir -PathType Container)) { + throw "-InstalledSelectorDir must name an installed selector directory, not '$InstalledSelectorDir'." + } + $InstalledSelectorDir = (Resolve-Path -LiteralPath $InstalledSelectorDir).Path + } } if (-not $ContractPath) { $ContractPath = Join-Path $RepoRoot "packaging/toolchains/release-contract.json" @@ -607,6 +618,9 @@ $installedLayoutArgs = @( if ($Version) { $installedLayoutArgs += @("--version", $Version) } +if ($InstalledSelectorDir) { + $installedLayoutArgs += @("--selector-root", $InstalledSelectorDir) +} & $Python @installedLayoutArgs if ($LASTEXITCODE -ne 0) { throw "Installed $Target/$Backend package does not match the release contract." diff --git a/scripts/stage-release.ps1 b/scripts/stage-release.ps1 index 921aafc..b08be01 100644 --- a/scripts/stage-release.ps1 +++ b/scripts/stage-release.ps1 @@ -14,6 +14,12 @@ param( [Parameter(Mandatory = $true)] [string]$BinaryPath, + [Parameter(Mandatory = $true)] + [string]$SelectorBinaryPath, + + [Parameter(Mandatory = $true)] + [string]$DefaultManagerBinaryPath, + [string]$OutputDir, [string]$ContractPath, @@ -90,6 +96,8 @@ $pythonArgs = @( "--profile", $Profile, "--version", $Version, "--binary", $BinaryPath, + "--selector-binary", $SelectorBinaryPath, + "--default-manager-binary", $DefaultManagerBinaryPath, "--output-dir", $OutputDir, "--contract", $ContractPath ) diff --git a/scripts/stage-release.sh b/scripts/stage-release.sh index e3337bc..0f8c5fa 100644 --- a/scripts/stage-release.sh +++ b/scripts/stage-release.sh @@ -7,6 +7,8 @@ REPO_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)" TARGET="" VERSION="" BINARY_PATH="" +SELECTOR_BINARY_PATH="" +DEFAULT_MANAGER_BINARY_PATH="" OUTPUT_DIR="$REPO_ROOT/target/release-artifacts" CONTRACT_PATH="$REPO_ROOT/packaging/toolchains/release-contract.json" RUNTIME_ARCHIVE_DIR="" @@ -17,7 +19,7 @@ TOOLCHAIN_ARCHIVE="" LLVM_PROVIDER_ARCHIVE="" usage() { - echo "usage: $0 --target --profile --version --binary [--output-dir ] [--contract ] [--runtime-archive-dir ] [--native-link-dir ] [--embedded-notices-dir ] [--toolchain-archive ] [--llvm-provider-archive ]" >&2 + echo "usage: $0 --target --profile --version --binary --selector-binary --default-manager-binary [--output-dir ] [--contract ] [--runtime-archive-dir ] [--native-link-dir ] [--embedded-notices-dir ] [--toolchain-archive ] [--llvm-provider-archive ]" >&2 } while [ "$#" -gt 0 ]; do @@ -34,6 +36,14 @@ while [ "$#" -gt 0 ]; do BINARY_PATH="$2" shift 2 ;; + --selector-binary) + SELECTOR_BINARY_PATH="$2" + shift 2 + ;; + --default-manager-binary) + DEFAULT_MANAGER_BINARY_PATH="$2" + shift 2 + ;; --output-dir) OUTPUT_DIR="$2" shift 2 @@ -85,12 +95,16 @@ done [ -n "$PROFILE" ] || { echo "missing --profile (full|llvm|cranelift|c)" >&2; exit 1; } [ -n "$VERSION" ] || { echo "missing --version" >&2; exit 1; } [ -n "$BINARY_PATH" ] || { echo "missing --binary" >&2; exit 1; } +[ -n "$SELECTOR_BINARY_PATH" ] || { echo "missing --selector-binary" >&2; exit 1; } +[ -n "$DEFAULT_MANAGER_BINARY_PATH" ] || { echo "missing --default-manager-binary" >&2; exit 1; } set -- "$SCRIPT_DIR/release_tools.py" stage-release \ --target "$TARGET" \ --profile "$PROFILE" \ --version "$VERSION" \ --binary "$BINARY_PATH" \ + --selector-binary "$SELECTOR_BINARY_PATH" \ + --default-manager-binary "$DEFAULT_MANAGER_BINARY_PATH" \ --output-dir "$OUTPUT_DIR" \ --contract "$CONTRACT_PATH" [ -n "$RUNTIME_ARCHIVE_DIR" ] && set -- "$@" --runtime-archive-dir "$RUNTIME_ARCHIVE_DIR" diff --git a/scripts/test_install_latest.py b/scripts/test_install_latest.py index 172157f..aaf82a8 100644 --- a/scripts/test_install_latest.py +++ b/scripts/test_install_latest.py @@ -512,7 +512,7 @@ def test_msi_and_archive_uninstall_paths_are_documented_separately(self) -> None def test_default_selection_and_profile_isolation_are_documented(self) -> None: for text in (self.guide, self.technical, self.spec): - self.assertIn("SetDefault", text) + self.assertIn("oscan-default", text) self.assertIn("oscan-full", text) self.assertIn("oscan-llvm", text) diff --git a/scripts/test_installers.py b/scripts/test_installers.py index 0367e8a..d785888 100644 --- a/scripts/test_installers.py +++ b/scripts/test_installers.py @@ -32,6 +32,21 @@ def make_bundle(root: Path, profile: str, version: str, windows: bool) -> Path: ) if not windows: binary.chmod(0o755) + selector = bundle / ("oscan-selector.exe" if windows else "oscan-selector") + manager = bundle / ("oscan-default.exe" if windows else "oscan-default") + selector.write_bytes( + f"fake selector {version}".encode("ascii") + if windows + else b"#!/bin/sh\nprintf '%s\\n' fake-selector\n" + ) + manager.write_bytes( + f"fake default manager {version}".encode("ascii") + if windows + else b"#!/bin/sh\nprintf '%s\\n' fake-default-manager\n" + ) + if not windows: + selector.chmod(0o755) + manager.chmod(0o755) metadata = { "schema_version": 2, "package_id": f"oscan-{profile}", @@ -45,6 +60,8 @@ def make_bundle(root: Path, profile: str, version: str, windows: bool) -> Path: "default_backend": "llvm" if profile == "full" else profile, "component_digests": { binary.name: hashlib.sha256(binary.read_bytes()).hexdigest(), + selector.name: hashlib.sha256(selector.read_bytes()).hexdigest(), + manager.name: hashlib.sha256(manager.read_bytes()).hexdigest(), "c_toolchain": {"version": "nested-toolchain-version"}, }, } @@ -97,6 +114,22 @@ def invoke( self.assertNotEqual(completed.returncode, 0) return completed + @staticmethod + def make_legacy_install(install_root: Path, bin_dir: Path) -> None: + old_payload = install_root / "profiles" / "full" / "0.9.0" + old_payload.mkdir(parents=True) + (old_payload / "oscan.exe").write_bytes(b"old compiler") + bin_dir.mkdir() + (bin_dir / "oscan-full.cmd").write_text( + '@echo off\r\n"%~dp0..\\profiles\\full\\0.9.0\\oscan.exe" %*\r\n', + encoding="ascii", + ) + (bin_dir / "oscan.cmd").write_text( + '@echo off\r\ncall "%~dp0oscan-full.cmd" %*\r\n', + encoding="ascii", + ) + (install_root / "default-profile").write_text("full\n", encoding="ascii") + def test_profiles_coexist_upgrade_independently_and_preserve_default(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -114,6 +147,9 @@ def test_profiles_coexist_upgrade_independently_and_preserve_default(self) -> No c_marker = ( root / "local-app-data" / "Programs" / "Oscan" / "archive-profile-c" ) + default_state = ( + root / "local-app-data" / "Oscan" / "default-profile" + ) self.invoke(llvm_v1, install_root, bin_dir) self.assertTrue(archive_marker.is_file()) @@ -122,9 +158,9 @@ def test_profiles_coexist_upgrade_independently_and_preserve_default(self) -> No self.assertTrue(c_marker.is_file()) self.assertTrue((bin_dir / "oscan-llvm.cmd").is_file()) self.assertTrue((bin_dir / "oscan-c.cmd").is_file()) - self.assertEqual( - (install_root / "default-profile").read_text().strip(), "llvm" - ) + self.assertTrue((bin_dir / "oscan.exe").is_file()) + self.assertTrue((bin_dir / "oscan-default.exe").is_file()) + self.assertFalse(default_state.exists()) self.invoke(c_v1, install_root, bin_dir, "-SetDefault") self.invoke(llvm_v2, install_root, bin_dir) @@ -135,19 +171,38 @@ def test_profiles_coexist_upgrade_independently_and_preserve_default(self) -> No self.assertTrue( (install_root / "profiles" / "c" / "1.0.0" / "oscan.exe").is_file() ) - self.assertEqual( - (install_root / "default-profile").read_text().strip(), "c" - ) + self.assertEqual(default_state.read_text().strip(), "c") self.invoke(c_v1, install_root, bin_dir, "-Uninstall") self.assertFalse((install_root / "profiles" / "c").exists()) self.assertTrue((install_root / "profiles" / "llvm").is_dir()) - self.assertFalse((bin_dir / "oscan.cmd").exists()) - self.assertFalse((install_root / "default-profile").exists()) - self.assertFalse(archive_marker.exists()) + self.assertTrue((bin_dir / "oscan.exe").is_file()) + self.assertEqual(default_state.read_text().strip(), "c") + self.assertTrue(archive_marker.is_file()) self.assertFalse(c_marker.exists()) self.assertTrue(llvm_marker.is_file()) + def test_final_uninstall_removes_shared_commands_but_preserves_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + bundle = make_bundle(root, "c", "1.0.0", True) + marker = ( + root / "local-app-data" / "Programs" / "Oscan" / "archive-default" + ) + state = root / "local-app-data" / "Oscan" / "default-profile" + + self.invoke(bundle, install_root, bin_dir, "-SetDefault") + self.invoke(bundle, install_root, bin_dir, "-Uninstall") + + self.assertFalse((bin_dir / "oscan-c.cmd").exists()) + self.assertFalse((bin_dir / "oscan.exe").exists()) + self.assertFalse((bin_dir / "oscan-default.exe").exists()) + self.assertFalse((bin_dir / ".oscan-default.owner").exists()) + self.assertFalse(marker.exists()) + self.assertEqual(state.read_text().strip(), "c") + def test_invalid_repair_does_not_remove_working_profile(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -163,6 +218,27 @@ def test_invalid_repair_does_not_remove_working_profile(self) -> None: (install_root / "profiles" / "llvm" / "1.0.0" / "oscan.exe").is_file() ) + def test_failed_new_profile_does_not_leave_stale_msi_markers(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + llvm = make_bundle(root, "llvm", "1.0.0", True) + c_bundle = make_bundle(root, "c", "1.0.0", True) + invalid_bin = root / "not-a-directory" + invalid_bin.write_text("occupied", encoding="ascii") + marker = ( + root / "local-app-data" / "Programs" / "Oscan" / "archive-default" + ) + + self.invoke(llvm, install_root, bin_dir) + self.invoke(c_bundle, install_root, invalid_bin, succeeds=False) + self.assertFalse((install_root / "profiles" / "c").exists()) + + self.invoke(llvm, install_root, bin_dir, "-Uninstall") + self.assertFalse(marker.exists()) + self.assertFalse((bin_dir / "oscan.exe").exists()) + def test_explicit_profile_can_uninstall_a_damaged_bundle(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -203,7 +279,7 @@ def test_activation_failure_rolls_back_a_same_version_repair(self) -> None: ) self.assertTrue((bin_dir / "oscan-llvm.cmd").is_file()) - def test_foreign_install_root_cannot_replace_or_remove_shared_selectors(self) -> None: + def test_shared_bin_rejects_a_second_windows_profiles_root(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) first_root = root / "first" @@ -214,7 +290,7 @@ def test_foreign_install_root_cannot_replace_or_remove_shared_selectors(self) -> self.invoke(llvm, first_root, bin_dir) qualified_before = (bin_dir / "oscan-llvm.cmd").read_bytes() - default_before = (bin_dir / "oscan.cmd").read_bytes() + selector_before = (bin_dir / "oscan.exe").read_bytes() self.invoke(llvm, second_root, bin_dir, succeeds=False) self.invoke(c_bundle, second_root, bin_dir, "-SetDefault", succeeds=False) @@ -228,10 +304,13 @@ def test_foreign_install_root_cannot_replace_or_remove_shared_selectors(self) -> ) self.assertEqual((bin_dir / "oscan-llvm.cmd").read_bytes(), qualified_before) - self.assertEqual((bin_dir / "oscan.cmd").read_bytes(), default_before) + self.assertEqual((bin_dir / "oscan.exe").read_bytes(), selector_before) self.assertTrue( (first_root / "profiles" / "llvm" / "1.0.0" / "oscan.exe").is_file() ) + self.assertFalse( + (root / "local-app-data" / "Oscan" / "default-profile").exists() + ) def test_selector_failure_rolls_back_a_staged_uninstall(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -239,7 +318,7 @@ def test_selector_failure_rolls_back_a_staged_uninstall(self) -> None: install_root = root / "install" bin_dir = root / "bin" bundle = make_bundle(root, "llvm", "1.0.0", True) - self.invoke(bundle, install_root, bin_dir) + self.invoke(bundle, install_root, bin_dir, "-SetDefault") qualified = bin_dir / "oscan-llvm.cmd" with qualified.open("rb"): @@ -255,9 +334,12 @@ def test_selector_failure_rolls_back_a_staged_uninstall(self) -> None: (install_root / "profiles" / "llvm" / "1.0.0" / "oscan.exe").is_file() ) self.assertTrue(qualified.is_file()) - self.assertTrue((bin_dir / "oscan.cmd").is_file()) + self.assertTrue((bin_dir / "oscan.exe").is_file()) self.assertEqual( - (install_root / "default-profile").read_text().strip(), "llvm" + ( + root / "local-app-data" / "Oscan" / "default-profile" + ).read_text().strip(), + "llvm", ) def test_unicode_install_root_uses_an_ascii_relative_shim(self) -> None: @@ -271,6 +353,39 @@ def test_unicode_install_root_uses_an_ascii_relative_shim(self) -> None: self.assertTrue(all(byte < 128 for byte in shim), shim) self.assertIn(b"%~dp0.oscan-profiles", shim) + def test_legacy_install_root_selection_migrates_to_per_user_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = install_root / "bin" + self.make_legacy_install(install_root, bin_dir) + bundle = make_bundle(root, "full", "1.0.0", True) + + self.invoke(bundle, install_root, bin_dir) + + self.assertEqual( + ( + root / "local-app-data" / "Oscan" / "default-profile" + ).read_text().strip(), + "full", + ) + self.assertTrue((bin_dir / "oscan.exe").is_file()) + + def test_legacy_migration_preserves_unmanaged_pe_commands(self) -> None: + for unmanaged_name in ("oscan.exe", "oscan-default.exe"): + with self.subTest(command=unmanaged_name), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = install_root / "bin" + self.make_legacy_install(install_root, bin_dir) + unmanaged = bin_dir / unmanaged_name + unmanaged.write_bytes(b"unmanaged command") + bundle = make_bundle(root, "full", "1.0.0", True) + + self.invoke(bundle, install_root, bin_dir, succeeds=False) + + self.assertEqual(unmanaged.read_bytes(), b"unmanaged command") + def test_uninstall_keeps_the_msi_marker_for_another_install_root(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) @@ -305,7 +420,7 @@ def test_failed_uninstall_preserves_commands_and_selector(self) -> None: install_root = root / "install" bin_dir = root / "bin" bundle = make_bundle(root, "llvm", "1.0.0", True) - self.invoke(bundle, install_root, bin_dir) + self.invoke(bundle, install_root, bin_dir, "-SetDefault") executable = install_root / "profiles" / "llvm" / "1.0.0" / "oscan.exe" with executable.open("rb"): self.invoke( @@ -317,9 +432,12 @@ def test_failed_uninstall_preserves_commands_and_selector(self) -> None: ) self.assertTrue(executable.is_file()) self.assertTrue((bin_dir / "oscan-llvm.cmd").is_file()) - self.assertTrue((bin_dir / "oscan.cmd").is_file()) + self.assertTrue((bin_dir / "oscan.exe").is_file()) self.assertEqual( - (install_root / "default-profile").read_text().strip(), "llvm" + ( + root / "local-app-data" / "Oscan" / "default-profile" + ).read_text().strip(), + "llvm", ) def test_equivalent_root_spellings_share_marker_ownership(self) -> None: @@ -442,7 +560,11 @@ def invoke( str(bin_dir), *extra, ] - completed = subprocess.run(command, capture_output=True, text=True, env=env) + run_env = os.environ.copy() if env is None else env.copy() + run_env.setdefault( + "XDG_CONFIG_HOME", str(Path(install_root).parent / "config") + ) + completed = subprocess.run(command, capture_output=True, text=True, env=run_env) if succeeds: self.assertEqual( completed.returncode, 0, completed.stdout + completed.stderr @@ -459,12 +581,16 @@ def test_profiles_coexist_upgrade_independently_and_preserve_default(self) -> No full_v1 = make_bundle(root, "full", "1.0.0", False) llvm_v1 = make_bundle(root, "llvm", "1.0.0", False) full_v2 = make_bundle(root, "full", "2.0.0", False) + default_state = root / "config" / "oscan" / "default-profile" self.invoke(full_v1, install_root, bin_dir) self.invoke(llvm_v1, install_root, bin_dir) self.assertTrue((bin_dir / "oscan-full").is_symlink()) self.assertTrue((bin_dir / "oscan-llvm").is_symlink()) - self.assertEqual((install_root / "default-profile").read_text().strip(), "full") + self.assertTrue((bin_dir / "oscan").is_file()) + self.assertFalse((bin_dir / "oscan").is_symlink()) + self.assertTrue((bin_dir / "oscan-default").is_file()) + self.assertFalse(default_state.exists()) self.invoke(llvm_v1, install_root, bin_dir, "--set-default") self.invoke(full_v2, install_root, bin_dir) @@ -475,13 +601,14 @@ def test_profiles_coexist_upgrade_independently_and_preserve_default(self) -> No self.assertTrue( (install_root / "profiles" / "llvm" / "1.0.0" / "oscan").is_file() ) - self.assertEqual((install_root / "default-profile").read_text().strip(), "llvm") - self.assertEqual((bin_dir / "oscan").readlink(), Path("oscan-llvm")) + self.assertEqual(default_state.read_text().strip(), "llvm") + self.assertTrue((bin_dir / "oscan").is_file()) self.invoke(llvm_v1, install_root, bin_dir, "--uninstall") self.assertFalse((install_root / "profiles" / "llvm").exists()) self.assertTrue((install_root / "profiles" / "full").is_dir()) - self.assertFalse((bin_dir / "oscan").exists()) + self.assertTrue((bin_dir / "oscan").is_file()) + self.assertEqual(default_state.read_text().strip(), "llvm") def test_invalid_repair_does_not_remove_working_profile(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -548,9 +675,9 @@ def test_shared_bin_preserves_selectors_owned_by_other_install_roots(self) -> No self.invoke(llvm, first_root, bin_dir) self.invoke(c_bundle, second_root, bin_dir) - self.assertEqual((bin_dir / "oscan").readlink(), Path("oscan-llvm")) + selector_before = (bin_dir / "oscan").read_bytes() - self.invoke(c_bundle, second_root, bin_dir, "--set-default", succeeds=False) + self.invoke(c_bundle, second_root, bin_dir, "--set-default") self.invoke(c_bundle, second_root, bin_dir, "--uninstall") self.invoke( root / "missing-source", @@ -562,10 +689,16 @@ def test_shared_bin_preserves_selectors_owned_by_other_install_roots(self) -> No ) self.assertTrue((bin_dir / "oscan-llvm").is_symlink()) - self.assertEqual((bin_dir / "oscan").readlink(), Path("oscan-llvm")) + self.assertEqual((bin_dir / "oscan").read_bytes(), selector_before) self.assertTrue( (first_root / "profiles" / "llvm" / "1.0.0" / "oscan").is_file() ) + self.assertEqual( + (root / "config" / "oscan" / "default-profile") + .read_text() + .strip(), + "c", + ) def test_selector_failure_rolls_back_a_staged_uninstall(self) -> None: with tempfile.TemporaryDirectory() as tmp: @@ -573,7 +706,7 @@ def test_selector_failure_rolls_back_a_staged_uninstall(self) -> None: install_root = root / "install" bin_dir = root / "bin" bundle = make_bundle(root, "c", "1.0.0", False) - self.invoke(bundle, install_root, bin_dir) + self.invoke(bundle, install_root, bin_dir, "--set-default") fake_bin = root / "fake-bin" fake_bin.mkdir() @@ -605,9 +738,10 @@ def test_selector_failure_rolls_back_a_staged_uninstall(self) -> None: (install_root / "profiles" / "c" / "1.0.0" / "oscan").is_file() ) self.assertTrue((bin_dir / "oscan-c").is_symlink()) - self.assertTrue((bin_dir / "oscan").is_symlink()) + self.assertTrue((bin_dir / "oscan").is_file()) self.assertEqual( - (install_root / "default-profile").read_text().strip(), "c" + (root / "config" / "oscan" / "default-profile").read_text().strip(), + "c", ) def test_no_bin_link_preserves_payloads_referenced_by_existing_links(self) -> None: @@ -628,6 +762,71 @@ def test_no_bin_link_preserves_payloads_referenced_by_existing_links(self) -> No (install_root / "profiles" / "c" / "2.0.0" / "oscan").is_file() ) + def test_legacy_symlink_and_selection_migrate_to_the_shared_selector(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + old_payload = install_root / "profiles" / "full" / "0.9.0" + old_payload.mkdir(parents=True) + (old_payload / "oscan").write_text("#!/bin/sh\n", encoding="ascii") + bin_dir.mkdir() + (bin_dir / "oscan-full").symlink_to(old_payload / "oscan") + (bin_dir / ".oscan-full.owner").write_text( + f"{install_root}\n", encoding="utf-8" + ) + (bin_dir / "oscan").symlink_to("oscan-full") + (bin_dir / ".oscan-default.owner").write_text( + f"{install_root}\n", encoding="utf-8" + ) + (install_root / "default-profile").write_text( + "full\n", encoding="ascii" + ) + bundle = make_bundle(root, "full", "1.0.0", False) + + self.invoke(bundle, install_root, bin_dir) + + self.assertTrue((bin_dir / "oscan").is_file()) + self.assertFalse((bin_dir / "oscan").is_symlink()) + self.assertTrue((bin_dir / "oscan-default").is_file()) + self.assertEqual( + (root / "config" / "oscan" / "default-profile") + .read_text() + .strip(), + "full", + ) + + def test_legacy_ownership_uses_root_state_while_preserving_global_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + install_root = root / "install" + bin_dir = root / "bin" + old_payload = install_root / "profiles" / "full" / "0.9.0" + old_payload.mkdir(parents=True) + (old_payload / "oscan").write_text("#!/bin/sh\n", encoding="ascii") + bin_dir.mkdir() + (bin_dir / "oscan-full").symlink_to(old_payload / "oscan") + (bin_dir / ".oscan-full.owner").write_text( + f"{install_root}\n", encoding="utf-8" + ) + (bin_dir / "oscan").symlink_to("oscan-full") + (bin_dir / ".oscan-default.owner").write_text( + f"{install_root}\n", encoding="utf-8" + ) + (install_root / "default-profile").write_text( + "full\n", encoding="ascii" + ) + global_state = root / "config" / "oscan" / "default-profile" + global_state.parent.mkdir(parents=True) + global_state.write_text("c\n", encoding="ascii") + bundle = make_bundle(root, "full", "1.0.0", False) + + self.invoke(bundle, install_root, bin_dir) + + self.assertTrue((bin_dir / "oscan").is_file()) + self.assertFalse((bin_dir / "oscan").is_symlink()) + self.assertEqual(global_state.read_text().strip(), "c") + def test_rejects_a_non_x86_64_host(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/scripts/test_release_packaging.py b/scripts/test_release_packaging.py index 1de68ce..71b4552 100644 --- a/scripts/test_release_packaging.py +++ b/scripts/test_release_packaging.py @@ -141,6 +141,14 @@ def __init__(self, root: Path, target: str) -> None: self.binary = root / "bin" / ("oscan.exe" if self.platform == "windows" else "oscan") self.binary.parent.mkdir(parents=True, exist_ok=True) self.binary.write_bytes(b"fake oscan binary") + self.selector_binary = root / "bin" / ( + "oscan-selector.exe" if self.platform == "windows" else "oscan-selector" + ) + self.default_manager_binary = root / "bin" / ( + "oscan-default.exe" if self.platform == "windows" else "oscan-default" + ) + self.selector_binary.write_bytes(b"fake oscan selector") + self.default_manager_binary.write_bytes(b"fake oscan default manager") self.native_link_dir = root / "native-link-input" self.embedded_notices_dir = root / "embedded-notices-input" @@ -459,6 +467,8 @@ def stage_namespace(fixture: PackagingFixture, profile: str, output_dir: Path, * "profile": profile, "version": "9.9.9", "binary": str(fixture.binary), + "selector_binary": str(fixture.selector_binary), + "default_manager_binary": str(fixture.default_manager_binary), "output_dir": str(output_dir), "contract": str(fixture.contract_path), "runtime_archive_dir": str(fixture.runtime_dir), @@ -2078,6 +2088,15 @@ def test_gzip_header_carries_no_clock_or_file_name(self) -> None: class WrapperInterfaceTests(unittest.TestCase): + def test_development_installer_stages_the_shared_selectors(self) -> None: + script = (rt.REPO_ROOT / "scripts" / "install-dev.ps1").read_text( + encoding="utf-8" + ) + self.assertIn("'--bin', 'oscan-selector'", script) + self.assertIn("'--bin', 'oscan-default'", script) + self.assertIn("'oscan-selector.exe'", script) + self.assertIn("'oscan-default.exe'", script) + def test_powershell_wrappers_require_a_profile(self) -> None: for name in ("stage-release.ps1", "assemble-release.ps1"): script = (rt.REPO_ROOT / "scripts" / name).read_text(encoding="utf-8") @@ -2101,6 +2120,10 @@ def test_shell_wrapper_requires_a_profile(self) -> None: script = (rt.REPO_ROOT / "scripts" / "stage-release.sh").read_text(encoding="utf-8") self.assertIn("--profile", script) self.assertIn('missing --profile', script) + self.assertIn("--selector-binary", script) + self.assertIn("missing --selector-binary", script) + self.assertIn("--default-manager-binary", script) + self.assertIn("missing --default-manager-binary", script) self.assertNotIn("--cross-linker-sidecar-dir", script) def test_shell_wrapper_takes_archives_and_refuses_directories(self) -> None: diff --git a/scripts/test_release_workflow.py b/scripts/test_release_workflow.py index db72bca..17bca62 100644 --- a/scripts/test_release_workflow.py +++ b/scripts/test_release_workflow.py @@ -235,6 +235,21 @@ def test_every_profile_is_built_with_its_contract_features(self) -> None: r"-Destination \(Join-Path \$destinationDir \$env:RELEASE_BINARY_NAME\)", ) + def test_selectors_are_built_once_and_reused_by_every_profile(self) -> None: + self.assertIn("--bin oscan-selector --bin oscan-default", self.workflow) + self.assertIn("$sharedBinDir = Join-Path $binRoot 'shared'", self.workflow) + self.assertIn( + "if ($env:RELEASE_TARGET -eq 'windows-x86_64')", self.workflow + ) + self.assertIn("$env:RUSTFLAGS = '-C target-feature=+crt-static'", self.workflow) + self.assertIn("SelectorBinaryPath = $selectorBinary", self.workflow) + self.assertIn( + "DefaultManagerBinaryPath = $defaultManagerBinary", self.workflow + ) + self.assertIn( + "does not contain the exact shared selector binaries", self.workflow + ) + def test_sidecar_and_strict_build_inputs_are_kept_separate(self) -> None: self.assertNotRegex(self.workflow, r"OSCAN_EMBED_ASSETS_DIR:\s") self.assertNotRegex(self.workflow, r"OSCAN_REQUIRE_EMBEDDED_ASSETS:\s") @@ -326,7 +341,14 @@ def test_the_installed_msi_payload_is_smoked_before_upload(self) -> None: self.assertIn('"PFiles64\\Oscan\\profiles\\$profile\\$version"', self.workflow) self.assertIn("-InstalledPackageDir $installedRoot", self.workflow) self.assertIn('"PFiles64\\Oscan\\bin\\oscan-$profile.cmd"', self.workflow) - self.assertIn("'PFiles64\\Oscan\\bin\\oscan.cmd'", self.workflow) + self.assertIn("'PFiles64\\Oscan\\bin\\oscan.exe'", self.workflow) + self.assertIn("'PFiles64\\Oscan\\bin\\oscan-default.exe'", self.workflow) + self.assertIn("-InstalledSelectorDir $installedSelectorDir", self.workflow) + self.assertIn("& $defaultManager set $selection", self.workflow) + self.assertIn("& $defaultManager auto", self.workflow) + self.assertIn("did not persist automatic mode", self.workflow) + self.assertIn("did not dispatch to the explicitly selected", self.workflow) + self.assertIn("silently fell back to another MSI profile", self.workflow) build_index = self.workflow.index("& ./scripts/build-msi.ps1") extract_index = self.workflow.index("Start-Process -FilePath msiexec.exe") installed_smoke_index = self.workflow.index("-InstalledPackageDir $installedRoot") @@ -387,6 +409,12 @@ def test_windows_runs_archive_installer_coexistence_tests(self) -> None: self.workflow, ) + def test_linux_runs_archive_installer_coexistence_tests(self) -> None: + self.assertIn( + "python -m unittest scripts.test_installers.UnixInstallerCoexistenceTests", + self.workflow, + ) + @unittest.skipIf(yaml is None, "PyYAML is not available in this environment") class WorkflowStructureTests(unittest.TestCase): diff --git a/scripts/test_smoke_release.py b/scripts/test_smoke_release.py index 2e912cc..27e63fa 100644 --- a/scripts/test_smoke_release.py +++ b/scripts/test_smoke_release.py @@ -113,6 +113,28 @@ def test_the_command_line_entry_point_verifies_a_real_package(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("linux-x86_64/llvm extracted package layout OK", result.stdout) + def test_msi_layout_accepts_shared_selectors_outside_the_profile(self) -> None: + with tempfile.TemporaryDirectory() as tmp_name: + tmp = Path(tmp_name) + bundle, _, contract = stage_fixture_package( + tmp, "windows-x86_64", "llvm" + ) + selector_root = tmp / "shared-bin" + selector_root.mkdir() + for name in ("oscan-selector.exe", "oscan-default.exe"): + installed_name = "oscan.exe" if name == "oscan-selector.exe" else name + shutil.move(bundle / name, selector_root / installed_name) + + metadata = self.verify( + bundle, + contract, + "windows-x86_64", + "llvm", + selector_root=selector_root, + ) + + self.assertEqual(metadata["profile"], "llvm") + def _reject(self, target: str, backend: str, break_package, **kwargs) -> str: with tempfile.TemporaryDirectory() as tmp_name: tmp = Path(tmp_name) @@ -172,7 +194,7 @@ def plant_linker(bundle: Path) -> None: (bundle / "ld.lld.exe").write_bytes(b"forbidden sidecar linker") self.assertIn( - "single-executable compiler", + "only the universal selector executables and declared support files", self._reject("windows-x86_64", "llvm", plant_linker), ) @@ -294,6 +316,7 @@ def test_it_can_smoke_an_already_installed_package(self) -> None: ) self.assertIn('$PSCmdlet.ParameterSetName -eq "Archive"', self.script) self.assertIn("$InstallDir = $InstalledPackageDir", self.script) + self.assertIn("--selector-root", self.script) self.assertIn("$OscanShim = $OscanCommand", self.script) def test_the_layout_is_verified_after_extraction_and_after_install(self) -> None: @@ -758,12 +781,22 @@ def test_profile_msis_have_isolated_products_and_shared_commands(self) -> None: self.assertIn('UpgradeCode="$(var.UpgradeCode)"', wxs) self.assertIn('Name="oscan-$(var.Profile).cmd"', wxs) self.assertIn('Name="oscan.cmd"', wxs) + self.assertIn('Name="oscan.exe"', wxs) + self.assertIn('Source="$(var.BundleDir)\\oscan-selector.exe"', wxs) + self.assertIn('Name="oscan-default.exe"', wxs) + self.assertIn('Source="$(var.BundleDir)\\oscan-default.exe"', wxs) self.assertIn('Directory Id="ProfileFolder" Name="$(var.Profile)"', wxs) self.assertIn('Directory Id="InstallFolder" Name="$(var.Version)"', wxs) self.assertIn('', wxs) self.assertNotIn("WIXUI_INSTALLDIR", wxs) self.assertIn("F62BD086-496A-46AA-904B-36E149101397", wxs) + self.assertIn("6D5BEAAC-E555-426A-BBD2-888E1B4A96A8", wxs) + self.assertIn("E2FAEE5C-353A-44BB-BB99-D3C594A27262", wxs) self.assertIn("AA340A99-32F7-4689-8EC2-D48B7503A478", wxs) + self.assertEqual(wxs.count('SharedDllRefCount="yes"'), 2) + builder = BUILD_MSI.read_text(encoding="utf-8") + self.assertIn('"%~dp0oscan.exe" %*', builder) + self.assertNotIn("goto full", builder) def test_archive_installer_detects_every_msi_family(self) -> None: contract = rt.load_release_contract(rt.CONTRACT_PATH) @@ -845,6 +878,8 @@ def test_the_strict_bundle_harvests_metadata_and_notices_only(self) -> None: self.assertNotIn("README-install.txt", fragment) self.assertNotIn("install.ps1", fragment) self.assertNotRegex(fragment, r'Source="\$\(var\.BundleDir\)\\oscan\.exe"') + self.assertNotIn("oscan-selector.exe", fragment) + self.assertNotIn("oscan-default.exe", fragment) class StandardUserHelperTests(unittest.TestCase): diff --git a/src/bin/oscan-default.rs b/src/bin/oscan-default.rs new file mode 100644 index 0000000..ef4df6a --- /dev/null +++ b/src/bin/oscan-default.rs @@ -0,0 +1,31 @@ +use std::env; +use std::process::ExitCode; + +use oscan::profile_selector::{default_state_path, run_default_command}; + +fn main() -> ExitCode { + match run() { + Ok(output) => { + println!("{output}"); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("oscan-default: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result { + let executable = env::current_exe() + .map_err(|error| format!("cannot locate the oscan-default executable: {error}"))?; + let bin_dir = executable.parent().ok_or_else(|| { + format!( + "cannot locate the Oscan bin directory from '{}'", + executable.display() + ) + })?; + let state_path = default_state_path()?; + let arguments: Vec<_> = env::args_os().skip(1).collect(); + run_default_command(&arguments, &state_path, bin_dir) +} diff --git a/src/bin/oscan-selector.rs b/src/bin/oscan-selector.rs new file mode 100644 index 0000000..ebe4275 --- /dev/null +++ b/src/bin/oscan-selector.rs @@ -0,0 +1,67 @@ +use std::env; +use std::process::Command; + +use oscan::profile_selector::{default_state_path, profile_command_path, resolve_profile}; + +fn main() { + match run() { + Ok(code) => std::process::exit(code), + Err(error) => { + eprintln!("oscan: {error}"); + std::process::exit(1); + } + } +} + +fn run() -> Result { + let executable = + env::current_exe().map_err(|error| format!("cannot locate the oscan selector: {error}"))?; + let bin_dir = executable.parent().ok_or_else(|| { + format!( + "cannot locate the Oscan bin directory from '{}'", + executable.display() + ) + })?; + let state_path = default_state_path()?; + let profile = resolve_profile(&state_path, bin_dir)?; + let command = profile_command_path(bin_dir, profile); + + #[cfg(windows)] + { + let executable = oscan::profile_selector::windows_profile_executable_path(&command)?; + let status = Command::new(&executable) + .args(env::args_os().skip(1)) + .status() + .map_err(|error| { + format!( + "cannot launch the '{profile}' profile executable '{}': {error}", + executable.display() + ) + })?; + return Ok(status.code().unwrap_or(1)); + } + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let error = Command::new(&command).args(env::args_os().skip(1)).exec(); + Err(format!( + "cannot launch the '{profile}' profile through '{}': {error}", + command.display() + )) + } + + #[cfg(not(any(windows, unix)))] + { + let status = Command::new(&command) + .args(env::args_os().skip(1)) + .status() + .map_err(|error| { + format!( + "cannot launch the '{profile}' profile through '{}': {error}", + command.display() + ) + })?; + Ok(status.code().unwrap_or(1)) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..beb4ac8 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1 @@ +pub mod profile_selector; diff --git a/src/profile_selector.rs b/src/profile_selector.rs new file mode 100644 index 0000000..d38d0a5 --- /dev/null +++ b/src/profile_selector.rs @@ -0,0 +1,663 @@ +use std::env; +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::fs::{self, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; + +pub const PROFILE_PRIORITY: [Profile; 5] = [ + Profile::Full, + Profile::Llvm, + Profile::Cranelift, + Profile::C, + Profile::Dev, +]; + +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Profile { + Full, + Llvm, + Cranelift, + C, + Dev, +} + +impl Profile { + pub fn parse(value: &str) -> Option { + match value { + "full" => Some(Self::Full), + "llvm" => Some(Self::Llvm), + "cranelift" => Some(Self::Cranelift), + "c" => Some(Self::C), + "dev" => Some(Self::Dev), + _ => None, + } + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::Full => "full", + Self::Llvm => "llvm", + Self::Cranelift => "cranelift", + Self::C => "c", + Self::Dev => "dev", + } + } +} + +impl fmt::Display for Profile { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Selection { + Auto, + Profile(Profile), +} + +impl Selection { + fn parse(contents: &str) -> Option { + let normalized = contents.trim().to_ascii_lowercase(); + if normalized == "auto" { + Some(Self::Auto) + } else { + Profile::parse(&normalized).map(Self::Profile) + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Profile(profile) => profile.as_str(), + } + } +} + +pub fn default_state_path() -> Result { + #[cfg(windows)] + { + let root = nonempty_env("LOCALAPPDATA").ok_or_else(|| { + "LOCALAPPDATA is not set; Oscan cannot locate the per-user default-profile state" + .to_string() + })?; + return Ok(PathBuf::from(root).join("Oscan").join("default-profile")); + } + + #[cfg(not(windows))] + { + if let Some(root) = nonempty_env("XDG_CONFIG_HOME") { + return Ok(PathBuf::from(root).join("oscan").join("default-profile")); + } + let home = nonempty_env("HOME").ok_or_else(|| { + "neither XDG_CONFIG_HOME nor HOME is set; Oscan cannot locate the per-user \ + default-profile state" + .to_string() + })?; + Ok(PathBuf::from(home) + .join(".config") + .join("oscan") + .join("default-profile")) + } +} + +fn nonempty_env(name: &str) -> Option { + env::var_os(name).filter(|value| !value.is_empty()) +} + +pub fn profile_command_path(bin_dir: &Path, profile: Profile) -> PathBuf { + #[cfg(windows)] + let name = format!("oscan-{}.cmd", profile.as_str()); + #[cfg(not(windows))] + let name = format!("oscan-{}", profile.as_str()); + bin_dir.join(name) +} + +#[cfg(windows)] +pub fn windows_profile_executable_path(command_path: &Path) -> Result { + use std::path::Component; + + let contents = fs::read_to_string(command_path).map_err(|error| { + format!( + "cannot read the managed profile command '{}': {error}", + command_path.display() + ) + })?; + let relative = contents + .lines() + .map(str::trim) + .find_map(|line| { + line.strip_prefix("\"%~dp0") + .and_then(|rest| rest.strip_suffix("\" %*")) + }) + .ok_or_else(|| { + format!( + "the managed profile command '{}' has an unsupported format; reinstall that \ + Oscan profile", + command_path.display() + ) + })?; + let relative_path = Path::new(relative); + if relative.is_empty() + || relative_path + .components() + .any(|component| matches!(component, Component::Prefix(_) | Component::RootDir)) + { + return Err(format!( + "the managed profile command '{}' contains an invalid executable path", + command_path.display() + )); + } + let executable = command_path + .parent() + .ok_or_else(|| { + format!( + "the managed profile command '{}' has no parent directory", + command_path.display() + ) + })? + .join(relative_path); + if !executable.is_file() { + return Err(format!( + "the managed profile command '{}' points to missing executable '{}'; reinstall that \ + Oscan profile", + command_path.display(), + executable.display() + )); + } + Ok(executable) +} + +pub fn installed_profiles(bin_dir: &Path) -> Vec { + PROFILE_PRIORITY + .iter() + .copied() + .filter(|profile| profile_command_path(bin_dir, *profile).is_file()) + .collect() +} + +pub fn resolve_profile(state_path: &Path, bin_dir: &Path) -> Result { + match read_selection(state_path)? { + Some(Selection::Auto) => highest_available(bin_dir).ok_or_else(no_profiles_error), + Some(Selection::Profile(profile)) => { + if profile_command_path(bin_dir, profile).is_file() { + Ok(profile) + } else { + let installed = installed_profile_label(bin_dir); + Err(format!( + "the explicitly selected Oscan profile '{profile}' is not installed \ + (installed: {installed}). Run 'oscan-default set ' to select an \ + installed profile, or 'oscan-default auto' to opt into automatic fallback" + )) + } + } + None => { + let profile = highest_available(bin_dir).ok_or_else(no_profiles_error)?; + if write_initial_selection_atomic(state_path, Selection::Profile(profile))? { + Ok(profile) + } else { + // Another selector or oscan-default won the initialization + // race. Honor its complete atomic state instead of replacing it. + resolve_profile(state_path, bin_dir) + } + } + } +} + +pub fn run_default_command( + arguments: &[OsString], + state_path: &Path, + bin_dir: &Path, +) -> Result { + let command = arguments.first().and_then(|value| value.to_str()); + match command { + None if arguments.is_empty() => show_selection(state_path, bin_dir), + Some("show") if arguments.len() == 1 => show_selection(state_path, bin_dir), + Some("list") if arguments.len() == 1 => list_profiles(state_path, bin_dir), + Some("auto") if arguments.len() == 1 => { + highest_available(bin_dir).ok_or_else(no_profiles_error)?; + write_selection_atomic(state_path, Selection::Auto)?; + Ok(format!( + "Oscan default profile is now automatic (priority: {}).", + PROFILE_PRIORITY + .iter() + .map(|profile| profile.as_str()) + .collect::>() + .join(" > ") + )) + } + Some("set") if arguments.len() == 2 => { + let value = arguments[1] + .to_str() + .ok_or_else(|| "profile names must be valid UTF-8".to_string())? + .to_ascii_lowercase(); + let profile = Profile::parse(&value).ok_or_else(|| { + format!( + "unknown Oscan profile '{}'; expected one of: {}", + arguments[1].to_string_lossy(), + PROFILE_PRIORITY + .iter() + .map(|profile| profile.as_str()) + .collect::>() + .join(", ") + ) + })?; + if !profile_command_path(bin_dir, profile).is_file() { + return Err(format!( + "cannot select Oscan profile '{profile}' because it is not installed \ + (installed: {})", + installed_profile_label(bin_dir) + )); + } + write_selection_atomic(state_path, Selection::Profile(profile))?; + Ok(format!("Oscan default profile is now '{profile}'.")) + } + Some("help" | "--help" | "-h") if arguments.len() == 1 => Ok(help_text().to_string()), + _ => Err(format!("invalid command\n\n{}", help_text())), + } +} + +fn help_text() -> &'static str { + "Usage:\n oscan-default show\n oscan-default list\n oscan-default set \ + \n oscan-default auto\n\nAn explicit selection is sticky. \ + Installing, upgrading, or removing another profile does not change it." +} + +fn show_selection(state_path: &Path, bin_dir: &Path) -> Result { + match read_selection(state_path)? { + Some(Selection::Profile(profile)) => { + let availability = if profile_command_path(bin_dir, profile).is_file() { + "installed" + } else { + "not installed" + }; + Ok(format!( + "Oscan default profile: {profile} ({availability})\nState: {}", + state_path.display() + )) + } + Some(Selection::Auto) => { + let resolved = highest_available(bin_dir) + .map(|profile| profile.to_string()) + .unwrap_or_else(|| "none available".to_string()); + Ok(format!( + "Oscan default profile: auto (currently {resolved})\nState: {}", + state_path.display() + )) + } + None => { + let candidate = highest_available(bin_dir) + .map(|profile| profile.to_string()) + .unwrap_or_else(|| "none available".to_string()); + Ok(format!( + "Oscan default profile: not set (next 'oscan' selects {candidate})\nState: {}", + state_path.display() + )) + } + } +} + +fn list_profiles(state_path: &Path, bin_dir: &Path) -> Result { + let selected = read_selection(state_path)?; + let mut lines = vec!["Oscan profiles:".to_string()]; + for profile in PROFILE_PRIORITY { + let availability = if profile_command_path(bin_dir, profile).is_file() { + "installed" + } else { + "not installed" + }; + let marker = if selected == Some(Selection::Profile(profile)) { + " [selected]" + } else { + "" + }; + lines.push(format!(" {profile}: {availability}{marker}")); + } + if selected == Some(Selection::Auto) { + lines.push(" mode: auto [selected]".to_string()); + } + Ok(lines.join("\n")) +} + +fn highest_available(bin_dir: &Path) -> Option { + PROFILE_PRIORITY + .iter() + .copied() + .find(|profile| profile_command_path(bin_dir, *profile).is_file()) +} + +fn installed_profile_label(bin_dir: &Path) -> String { + let profiles = installed_profiles(bin_dir); + if profiles.is_empty() { + "none".to_string() + } else { + profiles + .iter() + .map(|profile| profile.as_str()) + .collect::>() + .join(", ") + } +} + +fn no_profiles_error() -> String { + "no Oscan profiles are installed next to this selector".to_string() +} + +fn read_selection(path: &Path) -> Result, String> { + let contents = match fs::read_to_string(path) { + Ok(contents) => contents, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "cannot read Oscan default-profile state '{}': {error}", + path.display() + )); + } + }; + Selection::parse(&contents).map(Some).ok_or_else(|| { + format!( + "invalid Oscan default-profile state '{}' in '{}'; expected auto, full, llvm, \ + cranelift, c, or dev", + contents.trim(), + path.display() + ) + }) +} + +fn write_selection_atomic(path: &Path, selection: Selection) -> Result<(), String> { + let temporary_path = write_temporary_state(path, selection)?; + if let Err(error) = replace_file(&temporary_path, path) { + let _ = fs::remove_file(&temporary_path); + return Err(format!( + "cannot update Oscan default-profile state '{}': {error}", + path.display() + )); + } + Ok(()) +} + +fn write_initial_selection_atomic(path: &Path, selection: Selection) -> Result { + let temporary_path = write_temporary_state(path, selection)?; + match fs::hard_link(&temporary_path, path) { + Ok(()) => { + let _ = fs::remove_file(&temporary_path); + Ok(true) + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let _ = fs::remove_file(&temporary_path); + Ok(false) + } + Err(error) => { + let _ = fs::remove_file(&temporary_path); + Err(format!( + "cannot initialize Oscan default-profile state '{}': {error}", + path.display() + )) + } + } +} + +fn write_temporary_state(path: &Path, selection: Selection) -> Result { + let parent = path.parent().ok_or_else(|| { + format!( + "the Oscan default-profile state path '{}' has no parent directory", + path.display() + ) + })?; + fs::create_dir_all(parent).map_err(|error| { + format!( + "cannot create Oscan configuration directory '{}': {error}", + parent.display() + ) + })?; + + let mut temporary = None; + for _ in 0..100 { + let candidate = temporary_path(path); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + match options.open(&candidate) { + Ok(file) => { + temporary = Some((candidate, file)); + break; + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(format!( + "cannot create temporary Oscan state next to '{}': {error}", + path.display() + )); + } + } + } + + let (temporary_path, mut file) = temporary.ok_or_else(|| { + format!( + "cannot allocate temporary Oscan state next to '{}'", + path.display() + ) + })?; + let result = (|| -> io::Result<()> { + file.write_all(selection.as_str().as_bytes())?; + file.sync_all()?; + Ok(()) + })(); + if let Err(error) = result { + let _ = fs::remove_file(&temporary_path); + return Err(format!( + "cannot update Oscan default-profile state '{}': {error}", + path.display() + )); + } + Ok(temporary_path) +} + +fn temporary_path(path: &Path) -> PathBuf { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let name = path + .file_name() + .unwrap_or_else(|| OsStr::new("default-profile")) + .to_string_lossy(); + path.with_file_name(format!( + ".{name}.tmp-{}-{timestamp}-{counter}", + std::process::id() + )) +} + +#[cfg(not(windows))] +fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { + fs::rename(source, destination) +} + +#[cfg(windows)] +fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source: Vec = source.as_os_str().encode_wide().chain(Some(0)).collect(); + let destination: Vec = destination + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect(); + let succeeded = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if succeeded == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Barrier}; + + struct Sandbox { + root: PathBuf, + } + + impl Sandbox { + fn new(name: &str) -> Self { + let root = env::temp_dir().join(format!( + "oscan-profile-selector-{name}-{}-{}", + std::process::id(), + TEMP_COUNTER.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&root).unwrap(); + Self { root } + } + + fn bin_dir(&self) -> PathBuf { + let path = self.root.join("bin"); + fs::create_dir_all(&path).unwrap(); + path + } + + fn state_path(&self) -> PathBuf { + self.root.join("config").join("default-profile") + } + + fn install(&self, profile: Profile) { + fs::write(profile_command_path(&self.bin_dir(), profile), b"shim").unwrap(); + } + } + + impl Drop for Sandbox { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + #[test] + fn first_use_selects_and_persists_the_highest_available_profile() { + let sandbox = Sandbox::new("bootstrap"); + sandbox.install(Profile::C); + sandbox.install(Profile::Llvm); + + assert_eq!( + resolve_profile(&sandbox.state_path(), &sandbox.bin_dir()).unwrap(), + Profile::Llvm + ); + assert_eq!(fs::read_to_string(sandbox.state_path()).unwrap(), "llvm"); + + sandbox.install(Profile::Full); + assert_eq!( + resolve_profile(&sandbox.state_path(), &sandbox.bin_dir()).unwrap(), + Profile::Llvm + ); + } + + #[test] + fn a_missing_explicit_profile_fails_instead_of_falling_back() { + let sandbox = Sandbox::new("missing-explicit"); + sandbox.install(Profile::C); + write_selection_atomic(&sandbox.state_path(), Selection::Profile(Profile::Full)).unwrap(); + + let error = resolve_profile(&sandbox.state_path(), &sandbox.bin_dir()).unwrap_err(); + assert!(error.contains("explicitly selected")); + assert!(error.contains("oscan-default auto")); + assert_eq!(fs::read_to_string(sandbox.state_path()).unwrap(), "full"); + } + + #[test] + fn auto_mode_tracks_the_highest_available_profile() { + let sandbox = Sandbox::new("auto"); + sandbox.install(Profile::C); + run_default_command( + &[OsString::from("auto")], + &sandbox.state_path(), + &sandbox.bin_dir(), + ) + .unwrap(); + assert_eq!( + resolve_profile(&sandbox.state_path(), &sandbox.bin_dir()).unwrap(), + Profile::C + ); + + sandbox.install(Profile::Full); + assert_eq!( + resolve_profile(&sandbox.state_path(), &sandbox.bin_dir()).unwrap(), + Profile::Full + ); + assert_eq!(fs::read_to_string(sandbox.state_path()).unwrap(), "auto"); + } + + #[test] + fn set_rejects_profiles_that_are_not_installed() { + let sandbox = Sandbox::new("set"); + sandbox.install(Profile::C); + let error = run_default_command( + &[OsString::from("set"), OsString::from("full")], + &sandbox.state_path(), + &sandbox.bin_dir(), + ) + .unwrap_err(); + assert!(error.contains("not installed")); + assert!(!sandbox.state_path().exists()); + } + + #[test] + fn invalid_state_is_reported_without_being_replaced() { + let sandbox = Sandbox::new("invalid"); + sandbox.install(Profile::C); + fs::create_dir_all(sandbox.state_path().parent().unwrap()).unwrap(); + fs::write(sandbox.state_path(), "unexpected").unwrap(); + + let error = resolve_profile(&sandbox.state_path(), &sandbox.bin_dir()).unwrap_err(); + assert!(error.contains("invalid Oscan default-profile state")); + assert_eq!( + fs::read_to_string(sandbox.state_path()).unwrap(), + "unexpected" + ); + } + + #[test] + fn concurrent_initialization_never_overwrites_the_winner() { + let sandbox = Sandbox::new("concurrent-bootstrap"); + let state_path = sandbox.state_path(); + let barrier = Arc::new(Barrier::new(3)); + let mut workers = Vec::new(); + for profile in [Profile::Full, Profile::C] { + let path = state_path.clone(); + let barrier = Arc::clone(&barrier); + workers.push(std::thread::spawn(move || { + barrier.wait(); + write_initial_selection_atomic(&path, Selection::Profile(profile)).unwrap() + })); + } + barrier.wait(); + let outcomes: Vec<_> = workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .collect(); + + assert_eq!(outcomes.iter().filter(|outcome| **outcome).count(), 1); + assert!(matches!( + fs::read_to_string(state_path).unwrap().as_str(), + "full" | "c" + )); + } +} diff --git a/tests/profile_selector_cli.rs b/tests/profile_selector_cli.rs new file mode 100644 index 0000000..05114f5 --- /dev/null +++ b/tests/profile_selector_cli.rs @@ -0,0 +1,194 @@ +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +struct Sandbox { + root: PathBuf, + bin: PathBuf, + config: PathBuf, +} + +impl Sandbox { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "oscan selector cli-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let bin = root.join("bin"); + let config = root.join("config"); + fs::create_dir_all(&bin).unwrap(); + fs::copy( + env!("CARGO_BIN_EXE_oscan-selector"), + bin.join(executable_name("oscan")), + ) + .unwrap(); + fs::copy( + env!("CARGO_BIN_EXE_oscan-default"), + bin.join(executable_name("oscan-default")), + ) + .unwrap(); + Self { root, bin, config } + } + + fn install_profile(&self, profile: &str) { + let command = self.profile_command(profile); + #[cfg(windows)] + { + let executable_name = format!("profile-{profile}.exe"); + fs::copy( + env!("CARGO_BIN_EXE_oscan-default"), + self.bin.join(&executable_name), + ) + .unwrap(); + fs::write( + command, + format!("@echo off\r\n\"%~dp0{executable_name}\" %*\r\n"), + ) + .unwrap(); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::write( + &command, + format!( + "#!/bin/sh\nprintf 'profile={profile}'\nprintf ' %s' \"$@\"\nprintf '\\n'\n" + ), + ) + .unwrap(); + fs::set_permissions(command, fs::Permissions::from_mode(0o755)).unwrap(); + } + } + + #[cfg(windows)] + fn install_exit_profile(&self, profile: &str) { + let executable_name = format!("profile-{profile}.exe"); + let command_shell = + std::env::var_os("ComSpec").unwrap_or_else(|| "C:\\Windows\\System32\\cmd.exe".into()); + fs::copy(command_shell, self.bin.join(&executable_name)).unwrap(); + fs::write( + self.profile_command(profile), + format!("@echo off\r\n\"%~dp0{executable_name}\" %*\r\n"), + ) + .unwrap(); + } + + #[cfg(unix)] + fn install_exit_profile(&self, profile: &str) { + let command = self.profile_command(profile); + fs::write(&command, "#!/bin/sh\nexit \"$2\"\n").unwrap(); + let mut permissions = fs::metadata(&command).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(command, permissions).unwrap(); + } + + fn remove_profile(&self, profile: &str) { + fs::remove_file(self.profile_command(profile)).unwrap(); + } + + fn profile_command(&self, profile: &str) -> PathBuf { + #[cfg(windows)] + let name = format!("oscan-{profile}.cmd"); + #[cfg(not(windows))] + let name = format!("oscan-{profile}"); + self.bin.join(name) + } + + fn run(&self, program: &str, arguments: &[&str]) -> Output { + let mut command = Command::new(self.bin.join(executable_name(program))); + command.args(arguments); + #[cfg(windows)] + command.env("LOCALAPPDATA", &self.config); + #[cfg(not(windows))] + command.env("XDG_CONFIG_HOME", &self.config); + command.output().unwrap() + } +} + +impl Drop for Sandbox { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn executable_name(stem: &str) -> String { + if cfg!(windows) { + format!("{stem}.exe") + } else { + stem.to_string() + } +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n") +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).replace("\r\n", "\n") +} + +#[test] +fn explicit_selection_is_sticky_and_auto_is_deliberate() { + let sandbox = Sandbox::new(); + sandbox.install_profile("c"); + + let selected = sandbox.run("oscan-default", &["set", "c"]); + assert!(selected.status.success(), "{}", stderr(&selected)); + assert!(stdout(&selected).contains("now 'c'")); + + sandbox.install_profile("full"); + let explicit = sandbox.run("oscan", &["show"]); + assert!(explicit.status.success(), "{}", stderr(&explicit)); + assert!(stdout(&explicit).contains("c")); + + sandbox.install_exit_profile("c"); + #[cfg(windows)] + { + let exit = sandbox.run("oscan", &["/D", "/C", "exit", "301"]); + assert_eq!(exit.status.code(), Some(301), "{}", stderr(&exit)); + } + #[cfg(unix)] + { + let exit = sandbox.run("oscan", &["--exit", "37"]); + assert_eq!(exit.status.code(), Some(37), "{}", stderr(&exit)); + } + + let automatic = sandbox.run("oscan-default", &["auto"]); + assert!(automatic.status.success(), "{}", stderr(&automatic)); + let resolved = sandbox.run("oscan", &["show"]); + assert!(resolved.status.success(), "{}", stderr(&resolved)); + assert!(stdout(&resolved).contains("full")); +} + +#[test] +fn removing_the_explicit_profile_does_not_silently_fall_back() { + let sandbox = Sandbox::new(); + sandbox.install_profile("c"); + sandbox.install_profile("full"); + let selected = sandbox.run("oscan-default", &["set", "full"]); + assert!(selected.status.success(), "{}", stderr(&selected)); + + sandbox.remove_profile("full"); + let output = sandbox.run("oscan", &["show"]); + assert!(!output.status.success()); + assert!(stderr(&output).contains("explicitly selected Oscan profile 'full'")); + assert!(!stdout(&output).contains("profile=c")); +} + +#[test] +fn first_use_bootstraps_once_and_then_remains_sticky() { + let sandbox = Sandbox::new(); + sandbox.install_profile("c"); + let first = sandbox.run("oscan", &["show"]); + assert!(first.status.success(), "{}", stderr(&first)); + assert!(stdout(&first).contains("c")); + + sandbox.install_profile("full"); + let second = sandbox.run("oscan", &["show"]); + assert!(second.status.success(), "{}", stderr(&second)); + assert!(stdout(&second).contains("c")); +} From 25e62839987598fd8cdb521136485af2a7eb4f21 Mon Sep 17 00:00:00 2001 From: Luca Bolognese Date: Sun, 9 Aug 2026 15:52:51 +0200 Subject: [PATCH 2/4] Isolate POSIX installer tests from runner config Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 82b392f2-39a3-43ec-a492-8274728239a7 --- scripts/test_installers.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/test_installers.py b/scripts/test_installers.py index d785888..4080ebe 100644 --- a/scripts/test_installers.py +++ b/scripts/test_installers.py @@ -561,9 +561,8 @@ def invoke( *extra, ] run_env = os.environ.copy() if env is None else env.copy() - run_env.setdefault( - "XDG_CONFIG_HOME", str(Path(install_root).parent / "config") - ) + if env is None or "XDG_CONFIG_HOME" not in env: + run_env["XDG_CONFIG_HOME"] = str(Path(install_root).parent / "config") completed = subprocess.run(command, capture_output=True, text=True, env=run_env) if succeeds: self.assertEqual( From b5f8fe7169ccb4d13324c9703aa00e2e2258287f Mon Sep 17 00:00:00 2001 From: Luca Bolognese Date: Sun, 9 Aug 2026 15:56:25 +0200 Subject: [PATCH 3/4] Fix Unix selector test permissions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 82b392f2-39a3-43ec-a492-8274728239a7 --- tests/profile_selector_cli.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/profile_selector_cli.rs b/tests/profile_selector_cli.rs index 05114f5..0fb0c10 100644 --- a/tests/profile_selector_cli.rs +++ b/tests/profile_selector_cli.rs @@ -79,6 +79,8 @@ impl Sandbox { #[cfg(unix)] fn install_exit_profile(&self, profile: &str) { + use std::os::unix::fs::PermissionsExt; + let command = self.profile_command(profile); fs::write(&command, "#!/bin/sh\nexit \"$2\"\n").unwrap(); let mut permissions = fs::metadata(&command).unwrap().permissions(); From d0719142e566570e09b05419f53b1d49082a6ef6 Mon Sep 17 00:00:00 2001 From: Luca Bolognese Date: Sun, 9 Aug 2026 16:23:48 +0200 Subject: [PATCH 4/4] Restore static toolchain linking Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 82b392f2-39a3-43ec-a492-8274728239a7 --- src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main.rs b/src/main.rs index 6ee1549..7c1d0e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,10 @@ mod semantic; mod token; mod types; +// Cargo attaches build-script native link metadata to the package library when +// one exists, so the compiler must link it to receive the static LLVM/LLD libs. +extern crate oscan as _; + use std::collections::HashSet; use std::env; use std::fs;