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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ winget import apps.json --accept-package-agreements --accept-source-agreements
```

JSON inspection does not resolve package availability or predict installation results.

Bootstrap supports `PackageIdentifier` and an optional exact `Version` for each package. `SourceDetails.Name` selects an already registered source; supplied `Identifier`, `Argument`, and `Type` must match that registration. Source and version remain attached to the request during inventory checks, installation, and user-scope retry. Identical IDs from different sources remain separate requests. Omitting `SourceDetails` keeps WinGet's default source selection. Other installation fields, including `Scope`, `Channel`, and installer override arguments, are rejected before installation.

WinGet must be available through Microsoft App Installer for the current user. An unavailable client produces one prerequisite failure. Bootstrap refreshes its process PATH from the registered machine and user paths so tools installed during setup can be discovered later in the same run.

Package outcomes in `state.json` distinguish `verified`, `unverified`, and `failed`, with the requested source/version, exit code, and diagnostic category. An unverified success is checked again on the next ordinary run; a package that is then discoverable is not reinstalled. Summaries preserve hash-mismatch failures without copying arbitrary installer output. Full native output stays in `install.log`.

[`winget import`](https://learn.microsoft.com/en-us/windows/package-manager/winget/import)
installs applications. `--ignore-versions` installs the latest available versions;
it is not a dry-run option.
Expand Down Expand Up @@ -157,6 +164,9 @@ If you want to test OS tweaks before automation:
```powershell
# 1. Download Sophia Script for Windows 11
Invoke-WebRequest -Uri "https://github.com/farag2/Sophia-Script-for-Windows/releases/download/7.3.0/Sophia.Script.for.Windows.11.v7.3.0.zip" -OutFile "SophiaScript.zip"
if ((Get-FileHash -LiteralPath .\SophiaScript.zip -Algorithm SHA256).Hash -ne 'd342149e13053ea87c6119706a1f9d7d56d08c6e55ced113b1c32a30e7873bf2') {
throw 'Sophia release SHA-256 mismatch'
}

# 2. Extract the archive
Expand-Archive -Path "SophiaScript.zip" -DestinationPath ".\SophiaScript" -Force
Expand All @@ -167,6 +177,8 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\modules\Run-SophiaPres
-PresetPath ".\Sophia-Preset.ps1" -CompletionPath ".\sophia-test.completed"
```

Bootstrap verifies this [7.3.0 release digest](https://github.com/farag2/Sophia-Script-for-Windows/releases/tag/7.3.0) before extraction. It retains `.release.zip` and checks cached framework files against that archive before reuse. If an older cache lacks the archive or its files have changed, move that Sophia cache directory aside and rerun setup to download a verified copy.

**Important:**
- Always test in a VM first before running on your main PC
- Review `Sophia-Preset.ps1` and customize it for your needs
Expand Down
47 changes: 47 additions & 0 deletions bootstrap.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ $ProgressFile = Join-Path $SetupPath "progress.json"
$CanonicalRepoPath = Join-Path ([Environment]::GetFolderPath("MyDocuments")) "declarative-windows"
$CanonicalBootstrap = Join-Path $CanonicalRepoPath "bootstrap.ps1"
$SophiaVersion = '7.3.0'
# GitHub's release asset digest, verified from releases/tag/7.3.0.
$SophiaArchiveSha256 = 'd342149e13053ea87c6119706a1f9d7d56d08c6e55ced113b1c32a30e7873bf2'
$SophiaDir = Join-Path $SetupPath "Sophia-Script-$SophiaVersion"
$SophiaScript = Join-Path $SophiaDir "Sophia.ps1"
$SophiaZipName = "Sophia.Script.for.Windows.11.v$SophiaVersion.zip"
Expand Down Expand Up @@ -414,13 +416,42 @@ function Test-SophiaFramework {
return $manifest.ModuleVersion -eq $SophiaVersion
}

function Assert-SophiaReleaseIntegrity {
param([string]$ArchivePath, [string]$FrameworkPath)

$actualHash = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256 -ErrorAction Stop).Hash
if ($actualHash -ne $SophiaArchiveSha256) { throw 'Sophia archive SHA-256 mismatch. Refusing to execute downloaded code.' }
if ($FrameworkPath) {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [IO.Compression.ZipFile]::OpenRead($ArchivePath)
try {
$prefix = "Sophia_Script_for_Windows_11_v$SophiaVersion/"
foreach ($entry in $archive.Entries) {
if (-not $entry.Name -or -not $entry.FullName.StartsWith($prefix, [StringComparison]::Ordinal)) { continue }
$relativePath = $entry.FullName.Substring($prefix.Length)
$filePath = Join-Path $FrameworkPath $relativePath
$stream = $entry.Open()
$sha256 = [Security.Cryptography.SHA256]::Create()
try { $expected = [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '') }
finally { $sha256.Dispose(); $stream.Dispose() }
if ((Get-FileHash -LiteralPath $filePath -Algorithm SHA256 -ErrorAction Stop).Hash -ne $expected) {
throw "Sophia release file differs from the verified archive: $relativePath. Move the cached framework aside before retrying."
}
}
}
finally { $archive.Dispose() }
}
Write-Log "Verified Sophia $SophiaVersion artifact SHA256 $actualHash" -Level INFO
}

function Get-SophiaScript {
$stagingPath = $null
try {
if (Test-Path -LiteralPath $SophiaDir) {
if (-not (Test-SophiaFramework -Path $SophiaDir)) {
throw "Existing Sophia directory is incomplete or has the wrong version: $SophiaDir. Move it aside before retrying."
}
Assert-SophiaReleaseIntegrity -ArchivePath (Join-Path $SophiaDir '.release.zip') -FrameworkPath $SophiaDir
Write-Log "Sophia Script already extracted at $SophiaDir" -Level INFO
return $SophiaScript
}
Expand All @@ -431,12 +462,14 @@ function Get-SophiaScript {
$zipPath = Join-Path $stagingPath $SophiaZipName
Write-Log "Downloading Sophia Script v$SophiaVersion..." -Level INFO
Invoke-WebRequest -Uri $SophiaDownloadUrl -OutFile $zipPath -UseBasicParsing -ErrorAction Stop
Assert-SophiaReleaseIntegrity -ArchivePath $zipPath
Expand-Archive -LiteralPath $zipPath -DestinationPath $stagingPath -ErrorAction Stop

$extractedDir = Join-Path $stagingPath "Sophia_Script_for_Windows_11_v$SophiaVersion"
if (-not (Test-SophiaFramework -Path $extractedDir)) {
throw 'The pinned Sophia release is missing required framework files or has the wrong version.'
}
Move-Item -LiteralPath $zipPath -Destination (Join-Path $extractedDir '.release.zip') -ErrorAction Stop
# Publish only the validated release, without searching or removing neighboring setup files.
Move-Item -LiteralPath $extractedDir -Destination $SophiaDir -ErrorAction Stop
Write-Log "Sophia Script extracted to $SophiaDir" -Level SUCCESS
Expand Down Expand Up @@ -1253,6 +1286,7 @@ function Ensure-CanonicalRepo {
return $false
}

Update-SetupToolPath
$gitCommand = Get-Command git -ErrorAction SilentlyContinue
if (-not $gitCommand) {
Write-Log "Git is not available yet; skipping canonical repo clone" -Level WARNING
Expand Down Expand Up @@ -1344,6 +1378,16 @@ foreach ($moduleName in @("BootstrapRun.ps1", "BackupManifest.ps1", "WinGetInsta
}
}

$stateLock = $null
if (-not $DryRun -and (Test-Path -LiteralPath $SetupPath -PathType Container)) {
try { $stateLock = Enter-BootstrapStateLock -StatePath $StateFile }
catch {
# A competing run must not overwrite the owner's logs, state or reports.
Write-Error $_.Exception.Message
exit 1
}
}

try {
Write-Log "========================================" -Level INFO
Write-Log "Windows Setup Bootstrap - Starting" -Level INFO
Expand Down Expand Up @@ -1715,5 +1759,8 @@ catch {
$null = Complete-BootstrapRun -DesktopPath ([Environment]::GetFolderPath("Desktop")) -FailureMessage $_.Exception.Message
exit 1
}
finally {
if ($stateLock) { $stateLock.Dispose() }
}

exit $runResult.ExitCode
34 changes: 25 additions & 9 deletions build-iso.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
.PARAMETER KeepTemp
If specified, keeps the temporary working directory for debugging.

.PARAMETER OscdimgSha256
Expected SHA-256 of the optional oscdimg download, obtained from a trusted source independently of the download.

.EXAMPLE
.\build-iso.ps1 -SourceISO "Win11_English_x64.iso" -OutputISO "Win11_Custom.iso"

Expand Down Expand Up @@ -48,6 +51,10 @@ param(
[Parameter(Mandatory = $false)]
[string]$OscdimgDownloadUrl,

[Parameter(Mandatory = $false)]
[ValidatePattern('^[a-fA-F0-9]{64}$')]
[string]$OscdimgSha256,

[Parameter(Mandatory = $false)]
[switch]$KeepTemp
)
Expand Down Expand Up @@ -111,7 +118,7 @@ if (Test-Path $StagedSetupPayloadModule) {

# Function to find oscdimg.exe from Windows ADK
function Find-OscdImg {
param([string]$DownloadUrl)
param([string]$DownloadUrl, [string]$ExpectedHash)

Write-Step "Locating oscdimg.exe from Windows ADK"

Expand Down Expand Up @@ -143,9 +150,12 @@ function Find-OscdImg {
}

if ($DownloadUrl) {
if ($ExpectedHash -notmatch '^[a-fA-F0-9]{64}$') {
throw 'An oscdimg download requires -OscdimgSha256 from a trusted, independent source.'
}
Write-Step "Downloading oscdimg.exe"

$cacheDir = Join-Path $env:TEMP "declarative-windows-tools"
$cacheDir = Join-Path $TempDir 'tools'
if (-not (Test-Path $cacheDir)) {
New-Item -Path $cacheDir -ItemType Directory -Force | Out-Null
}
Expand All @@ -155,18 +165,24 @@ function Find-OscdImg {

if ($extension -eq ".zip") {
$downloadPath = Join-Path $cacheDir "oscdimg.zip"
Invoke-WebRequest -Uri $DownloadUrl -OutFile $downloadPath
Invoke-WebRequest -Uri $DownloadUrl -OutFile $downloadPath -ErrorAction Stop
$actualHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256 -ErrorAction Stop).Hash
if ($actualHash -ne $ExpectedHash) { throw 'oscdimg download SHA-256 mismatch.' }
Write-Info "Verified oscdimg archive SHA256 $actualHash"
Expand-Archive -Path $downloadPath -DestinationPath $cacheDir -Force

$oscdimgFile = Get-ChildItem -Path $cacheDir -Filter "oscdimg.exe" -Recurse | Select-Object -First 1
if ($oscdimgFile) {
Write-Success "Downloaded oscdimg.exe to: $($oscdimgFile.FullName)"
return $oscdimgFile.FullName
$oscdimgFiles = @(Get-ChildItem -Path $cacheDir -Filter "oscdimg.exe" -Recurse -File)
if ($oscdimgFiles.Count -eq 1) {
Write-Success "Downloaded oscdimg.exe to: $($oscdimgFiles[0].FullName)"
return $oscdimgFiles[0].FullName
}
}
else {
$downloadPath = Join-Path $cacheDir "oscdimg.exe"
Invoke-WebRequest -Uri $DownloadUrl -OutFile $downloadPath
Invoke-WebRequest -Uri $DownloadUrl -OutFile $downloadPath -ErrorAction Stop
$actualHash = (Get-FileHash -LiteralPath $downloadPath -Algorithm SHA256 -ErrorAction Stop).Hash
if ($actualHash -ne $ExpectedHash) { throw 'oscdimg download SHA-256 mismatch.' }
Write-Info "Verified oscdimg executable SHA256 $actualHash"
if (Test-Path $downloadPath) {
Write-Success "Downloaded oscdimg.exe to: $downloadPath"
return $downloadPath
Expand Down Expand Up @@ -225,7 +241,7 @@ try {
Write-Success "autounattend.xml validation passed"

# Find oscdimg.exe
$oscdimgPath = Find-OscdImg -DownloadUrl $OscdimgDownloadUrl
$oscdimgPath = Find-OscdImg -DownloadUrl $OscdimgDownloadUrl -ExpectedHash $OscdimgSha256

# Mount source ISO
Write-Step "Mounting source ISO"
Expand Down
5 changes: 5 additions & 0 deletions docs/ISO-GENERATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ This must complete successfully. The validator's `-SchemaDllPath` option support
- `-SourceIsoHash` (optional): Expected SHA256 hash for the source ISO.
- `-IsoLabel` (optional): ISO label passed to `oscdimg`.
- `-OscdimgDownloadUrl` (optional): Direct URL to `oscdimg.exe` or a ZIP containing it, used only if an installed copy is not found. It does not supply Windows SIM or bypass schema validation; ADK Deployment Tools remain required.
- `-OscdimgSha256` (required when downloading oscdimg): Expected SHA-256 of the downloaded EXE or ZIP, obtained from a trusted source independently of the download. The builder rejects a mismatch before extraction or execution and logs the verified digest. ZIP downloads must contain exactly one `oscdimg.exe`.
- `-KeepTemp` (optional): Retain temporary extraction files for debugging.

## ISO Contents
Expand Down Expand Up @@ -108,6 +109,10 @@ After first login, bootstrap attempts to clone the original repo remote into `%U
- `C:\Setup\state.json`: step resume state
- `C:\Users\<User>\Desktop\Setup Summary.txt`: summary report

Only one bootstrap run may own `C:\Setup\state.json` at a time. A competing launch exits without rewriting the owner's reports. State publication is atomic and retains the previous committed file as `state.json.previous`. Corrupt state stops setup and remains untouched. Review the saved state and its previous copy before recovering it; deleting state can repeat completed actions.

User-scope retries publish a complete JSON result before the parent reads it. On timeout or cancellation, bootstrap stops the scheduled task and checks its state before deleting runner files. If termination cannot be confirmed, it retains the task and files and reports their identity in `install.log`; settle that task before retrying setup.

## Manual Re-run

Use the desktop shortcut or run:
Expand Down
51 changes: 48 additions & 3 deletions modules/BootstrapRun.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,24 @@ function Initialize-State {
$state = $null
if (Test-Path $StatePath) {
try {
$state = Get-Content -Path $StatePath -Raw | ConvertFrom-Json
$state = Get-Content -LiteralPath $StatePath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
if ($state -isnot [pscustomobject] -or $state.version -ne '1' -or
$state.steps -isnot [pscustomobject]) {
throw 'Expected version 1 state with a steps object.'
}
$state.steps = Convert-StepsToHashtable -Steps $state.steps
foreach ($id in $state.steps.Keys) {
$step = $state.steps[$id]
if ($step -isnot [pscustomobject] -or
$step.status -notin @('pending', 'done', 'failed', 'skipped', 'unverified') -or
$null -eq $step.PSObject.Properties['message'] -or
$null -eq $step.PSObject.Properties['lastRun']) {
throw "Invalid record for step '$id'."
}
}
}
catch {
$state = $null
throw "Cannot safely resume state at '${StatePath}': $($_.Exception.Message) The original file is preserved. Recover it from a known-good copy before retrying; empty state could repeat completed actions."
}
}

Expand All @@ -60,6 +74,18 @@ function Initialize-State {
return $state
}

function Enter-BootstrapStateLock {
param([Parameter(Mandatory)][string]$StatePath)

try {
# Keep the handle open for the whole run. The OS releases it after a crash.
return [IO.File]::Open("$StatePath.lock", [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None)
}
catch {
throw "Cannot own bootstrap state at '${StatePath}'. Another setup run may be active: $($_.Exception.Message)"
}
}

function Save-State {
param(
[Parameter(Mandatory)]
Expand All @@ -72,7 +98,26 @@ function Save-State {
if ($DryRun) { return }

$State.lastUpdated = (Get-Date).ToString("o")
$State | ConvertTo-Json -Depth 6 | Set-Content -Path $StatePath -Force
$destination = [IO.Path]::GetFullPath($StatePath)
$temporaryPath = "$destination.$([guid]::NewGuid().ToString('N')).tmp"
try {
$bytes = [Text.UTF8Encoding]::new($false).GetBytes(($State | ConvertTo-Json -Depth 6))
$stream = [IO.File]::Open($temporaryPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
try {
$stream.Write($bytes, 0, $bytes.Length)
$stream.Flush($true)
}
finally { $stream.Dispose() }
if ([IO.File]::Exists($destination)) {
[IO.File]::Replace($temporaryPath, $destination, "$destination.previous")
}
else {
[IO.File]::Move($temporaryPath, $destination)
}
}
finally {
if ([IO.File]::Exists($temporaryPath)) { [IO.File]::Delete($temporaryPath) }
}
}

function Set-StepState {
Expand Down
Loading
Loading