<# New-OsfExfatImage.ps1 PURPOSE ------- Create a RAW image file, mount it via OSFMount as a logical volume, format it as exFAT, and either: - format + copy + dismount (default), or - create + mount only for manual steps. USAGE (run PowerShell as Administrator) -------------------------------------- 1) Auto-size (recommended): powershell.exe -ExecutionPolicy Bypass -File .\New-OsfExfatImage.ps1 ` -ImagePath "C:\images\data.img" ` -SourceDir "C:\payload" ` -Label "DATA" ` -ForceOverwrite 2) Fixed size: powershell.exe -ExecutionPolicy Bypass -File .\New-OsfExfatImage.ps1 ` -ImagePath "C:\images\data.img" ` -SourceDir "C:\payload" ` -Size 8G ` -Label "DATA" ` -ForceOverwrite 3) Create empty image and keep mounted (manual format/copy): powershell.exe -ExecutionPolicy Bypass -File .\New-OsfExfatImage.ps1 ` -ImagePath "C:\images\data.exfat" ` -SourceDir "C:\payload\APPXXXX" ` -CreateEmptyAndMount ` -ForceOverwrite PARAMETERS ---------- -ImagePath Output image file path. -SourceDir Folder to copy into the new volume. -Size Optional. If omitted, an optimal size is computed to fit all files. Suffixes: K/M/G/T (1024), k/m/g/t (1000), b (512-byte blocks), or bytes. -Label Volume label. -ForceOverwrite Recreate image if it already exists. -CreateEmptyAndMount Create and mount image only. Skip format/copy and leave mounted. NOTES ----- - This script does NOT auto-elevate. Start PowerShell as Administrator. - Filesystem is always exFAT. - Cluster size is auto-selected: - large-file sets: 65536 - small/mixed-file sets: 32768 #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ImagePath, [Parameter(Mandatory = $true)] [string]$SourceDir, [Parameter(Mandatory = $false)] [string]$Size, [string]$Label = "OSFIMG", [switch]$ForceOverwrite, [switch]$CreateEmptyAndMount ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $scriptCulture = [System.Globalization.CultureInfo]::GetCultureInfo("en-US") [System.Threading.Thread]::CurrentThread.CurrentCulture = $scriptCulture [System.Threading.Thread]::CurrentThread.CurrentUICulture = $scriptCulture [System.Globalization.CultureInfo]::DefaultThreadCurrentCulture = $scriptCulture [System.Globalization.CultureInfo]::DefaultThreadCurrentUICulture = $scriptCulture function Test-Admin { $id = [Security.Principal.WindowsIdentity]::GetCurrent() $p = New-Object Security.Principal.WindowsPrincipal($id) return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Find-OSFMountCom { $cmd = Get-Command "osfmount.com" -ErrorAction SilentlyContinue if ($cmd) { return $cmd.Source } $candidates = @( "${env:ProgramFiles}\OSFMount\osfmount.com", "${env:ProgramFiles(x86)}\OSFMount\osfmount.com", "${env:ProgramFiles}\PassMark\OSFMount\osfmount.com", "${env:ProgramFiles(x86)}\PassMark\OSFMount\osfmount.com" ) | Where-Object { $_ -and (Test-Path $_) } $candidates = @($candidates) if ($candidates.Count -gt 0) { return $candidates[0] } throw "osfmount.com not found. Add OSFMount to PATH or install it to a standard location." } function Parse-SizeToBytes([string]$s) { $s = $s.Trim() if ($s -match '^\s*(\d+)\s*([bBkKmMgGtT]?)\s*$') { $num = [Int64]$matches[1] $u = $matches[2] switch ($u) { '' { return $num } 'b' { return $num * 512 } 'B' { return $num * 512 } 'K' { return $num * 1024 } 'M' { return $num * 1024 * 1024 } 'G' { return $num * 1024 * 1024 * 1024 } 'T' { return $num * 1024 * 1024 * 1024 * 1024 } 'k' { return $num * 1000 } 'm' { return $num * 1000 * 1000 } 'g' { return $num * 1000 * 1000 * 1000 } 't' { return $num * 1000 * 1000 * 1000 * 1000 } default { throw "Unknown size suffix: '$u'" } } } throw "Failed to parse size string: '$s'" } function Format-Bytes([Int64]$bytes) { if ($bytes -ge 1TB) { return "{0:N2} TB" -f ($bytes/1TB) } if ($bytes -ge 1GB) { return "{0:N2} GB" -f ($bytes/1GB) } if ($bytes -ge 1MB) { return "{0:N2} MB" -f ($bytes/1MB) } if ($bytes -ge 1KB) { return "{0:N2} KB" -f ($bytes/1KB) } return "$bytes B" } function Get-FreeDriveLetter { $used = (Get-PSDrive -PSProvider FileSystem).Name foreach ($code in 68..90) { $letter = [char]$code if ($used -notcontains [string]$letter) { return [string]$letter } } throw "No free drive letters available (D:..Z:)." } function Get-OptimalImageSizeBytes([string]$dir, [int]$clusterBytes) { $cluster = [Int64]$clusterBytes [Int64]$metaFixed = 32MB [Int64]$minSlack = 64MB [Int64]$spareMin = 64MB [Int64]$spareMax = 512MB [Int64]$entryMetaBytes = 256 $files = @(Get-ChildItem -LiteralPath $dir -Recurse -File -Force) $dirs = @(Get-ChildItem -LiteralPath $dir -Recurse -Directory -Force) [Int64]$rawFileBytes = 0 [Int64]$dataBytes = 0 foreach ($f in $files) { $len = [Int64]$f.Length $rawFileBytes += $len $dataBytes += [Int64]([Math]::Ceiling($len / [double]$cluster) * $cluster) } [Int64]$dataClusters = [Int64]([Math]::Ceiling($dataBytes / [double]$cluster)) [Int64]$fatBytes = $dataClusters * 4 [Int64]$bitmapBytes = [Int64]([Math]::Ceiling($dataClusters / 8.0)) [Int64]$entryBytes = (([Int64]$files.Count + [Int64]$dirs.Count) * $entryMetaBytes) [Int64]$baseTotal = $dataBytes + $fatBytes + $bitmapBytes + $entryBytes + $metaFixed [Int64]$spareBytes = [Int64]([Math]::Ceiling($baseTotal / 200.0)) if ($spareBytes -lt $spareMin) { $spareBytes = $spareMin } if ($spareBytes -gt $spareMax) { $spareBytes = $spareMax } [Int64]$total = $baseTotal + $spareBytes [Int64]$minTotal = $rawFileBytes + $minSlack if ($total -lt $minTotal) { $total = $minTotal } [Int64]$align = 1MB $total = [Int64]([Math]::Ceiling($total / [double]$align) * $align) return $total } function Wait-ForLogicalDrive([string]$driveLetter, [int]$timeoutSeconds = 20) { $target = "${driveLetter}:" $sw = [Diagnostics.Stopwatch]::StartNew() while ($sw.Elapsed.TotalSeconds -lt $timeoutSeconds) { $logical = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DeviceID='$target'" -ErrorAction SilentlyContinue if ($logical) { return $true } Start-Sleep -Milliseconds 300 } return $false } function Get-LogicalDriveFileSystem([string]$driveLetter) { $target = "${driveLetter}:" $logical = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DeviceID='$target'" -ErrorAction SilentlyContinue if ($logical) { return [string]$logical.FileSystem } return "" } function Get-OptimalExfatClusterSize([string]$dir) { [Int64]$largeFileThreshold = 1MB $files = @(Get-ChildItem -LiteralPath $dir -Recurse -File -Force) if ($files.Count -eq 0) { return 32768 } [Int64]$rawFileBytes = 0 foreach ($f in $files) { $rawFileBytes += [Int64]$f.Length } [Int64]$avgFileBytes = [Int64]($rawFileBytes / [Int64]$files.Count) if ($avgFileBytes -ge $largeFileThreshold) { return 65536 } return 32768 } function Format-AllocationUnitArg([int]$clusterSize) { if ($clusterSize -ge 1MB -and ($clusterSize % 1MB) -eq 0) { return "{0}M" -f ($clusterSize / 1MB) } if ($clusterSize -ge 1KB -and ($clusterSize % 1KB) -eq 0) { return "{0}K" -f ($clusterSize / 1KB) } return "$clusterSize" } function Dismount-OsfVolume([string]$osfPath, [string]$mountPoint, [int]$maxAttempts = 6) { if ([string]::IsNullOrWhiteSpace($mountPoint)) { return $false } $targets = @($mountPoint) if (-not $mountPoint.EndsWith("\")) { $targets += "$mountPoint\" } for ($i = 1; $i -le $maxAttempts; $i++) { foreach ($target in $targets) { try { & $osfPath -d -m $target 2>&1 | Out-Null if ($LASTEXITCODE -eq 0) { return $true } } catch { # Retry: volume can remain busy for a short time after format/copy. } } Start-Sleep -Milliseconds 500 } return $false } function Invoke-FormatVolume([string]$driveLetter, [int]$clusterSize, [string]$label) { $target = "${driveLetter}:" $fileSystem = "exFAT" $clusterArg = Format-AllocationUnitArg -clusterSize $clusterSize $attempts = @( @{ Name = "$fileSystem quick with requested allocation unit"; Args = @($target, "/FS:$fileSystem", "/A:$clusterArg", "/Q", "/V:$label", "/X", "/Y") } ) $lastFormatExitCode = -1 foreach ($attempt in $attempts) { Write-Host "[Info] format attempt: $($attempt.Name)" $stdoutPath = [System.IO.Path]::GetTempFileName() $stderrPath = [System.IO.Path]::GetTempFileName() try { $proc = Start-Process -FilePath "format.com" -ArgumentList $attempt.Args -Wait -PassThru -NoNewWindow ` -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath $lastFormatExitCode = [int]$proc.ExitCode # Show native format.com output in current console. if (Test-Path -LiteralPath $stdoutPath) { Get-Content -LiteralPath $stdoutPath -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ } } if (Test-Path -LiteralPath $stderrPath) { Get-Content -LiteralPath $stderrPath -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ } } } finally { Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue } # Some environments may report a non-zero exit code even when formatting completed. # Validate resulting filesystem as the source of truth. if (Wait-ForLogicalDrive -driveLetter $driveLetter -timeoutSeconds 5) { $actualFs = Get-LogicalDriveFileSystem -driveLetter $driveLetter if ($actualFs -and $actualFs.ToUpperInvariant() -eq $fileSystem.ToUpperInvariant()) { Write-Host "[Info] format result: detected filesystem '$actualFs' on $target." return } } Write-Host "[Info] format attempt failed (exit code $lastFormatExitCode), retrying..." } throw "format.com failed for $target after all retry strategies. Last exit code: $lastFormatExitCode" } # -------------------- Main -------------------- if (-not (Test-Admin)) { throw "Please run PowerShell as Administrator." } if (-not (Test-Path $SourceDir -PathType Container)) { throw "Source directory not found: $SourceDir" } if (-not (Test-Path (Join-Path $SourceDir "eboot.bin") -PathType Leaf)) { throw "eboot.bin not found in source directory: $SourceDir" } # Ensure output directory exists $outDir = Split-Path -Parent $ImagePath if ($outDir -and -not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir | Out-Null } if (Test-Path $ImagePath) { if (-not $ForceOverwrite) { throw "Image file already exists: $ImagePath. Use -ForceOverwrite to replace it." } Remove-Item -Force $ImagePath } [Int64]$expectedBytes = 0 [string]$osfSizeArg = $null [int]$ExfatClusterSize = Get-OptimalExfatClusterSize -dir $SourceDir [bool]$sizeProvided = -not [string]::IsNullOrWhiteSpace($Size) [int]$TargetClusterSize = $ExfatClusterSize if (-not $sizeProvided) { Write-Host "[Info] Size not provided. Computing an optimal image size from '$SourceDir'..." $expectedBytes = Get-OptimalImageSizeBytes -dir $SourceDir -clusterBytes $TargetClusterSize $osfSizeArg = "$expectedBytes" } else { $expectedBytes = Parse-SizeToBytes $Size $osfSizeArg = $Size } if (-not $sizeProvided) { Write-Host "[Info] Computed image size: $(Format-Bytes $expectedBytes) ($expectedBytes bytes)." } Write-Host "[Info] Selected filesystem: exFAT (cluster=$TargetClusterSize) for image size $(Format-Bytes $expectedBytes)." $osf = Find-OSFMountCom [string]$DriveLetter = "" [string]$MountPoint = "" [bool]$Mounted = $false [bool]$LeaveMounted = $CreateEmptyAndMount.IsPresent try { $DriveLetter = Get-FreeDriveLetter $MountPoint = "${DriveLetter}:" if ($CreateEmptyAndMount) { Write-Host "[1/2] Creating & mounting the image via OSFMount as a logical volume on $MountPoint ..." } else { Write-Host "[1/4] Creating & mounting the image via OSFMount as a logical volume on $MountPoint ..." } $out = & $osf -a -t file -f $ImagePath -s $osfSizeArg -m $MountPoint -o rw,rem 2>&1 Write-Host ($out | Out-String).Trim() if ($LASTEXITCODE -ne 0) { throw "osfmount.com failed with exit code $LASTEXITCODE." } $Mounted = $true if (-not (Wait-ForLogicalDrive -driveLetter $DriveLetter -timeoutSeconds 20)) { throw "Mounted drive $MountPoint did not appear in time." } $dest = "${DriveLetter}:\" if ($CreateEmptyAndMount) { Write-Host "[2/2] Done. Empty image is mounted at $dest." Write-Host "Manual steps:" Write-Host " 1) Format $dest as exFAT (recommended cluster: 64KB for large-file sets, 32KB for small/mixed sets)." Write-Host " 2) Copy contents of '$SourceDir' to $dest." Write-Host " 3) Dismount: `"$osf`" -d -m $MountPoint" return } Write-Host "[2/4] Formatting $dest as exFAT (cluster=$TargetClusterSize, label='$Label') via format.com ..." Invoke-FormatVolume -driveLetter $DriveLetter -clusterSize $TargetClusterSize -label $Label if (-not (Test-Path $dest)) { throw "Drive $dest is not accessible after formatting." } Write-Host "[3/4] Copying contents of '$SourceDir' -> '$dest' ..." $roboArgs = @( $SourceDir, $dest, "/E", "/COPY:DAT", "/DCOPY:DAT", "/R:1", "/W:1", "/NFL", "/NDL", "/ETA" ) & robocopy.exe @roboArgs $robocopyExitCode = $LASTEXITCODE if ($robocopyExitCode -gt 7) { throw "robocopy failed. Exit code: $robocopyExitCode" } Write-Host "[4/4] Done. Dismounting OSFMount volume..." } finally { if ($Mounted -and -not $LeaveMounted -and -not [string]::IsNullOrWhiteSpace($MountPoint)) { try { $currentPath = (Get-Location).Path if ($currentPath -like "$MountPoint*") { Set-Location "$env:SystemDrive\" } } catch { # Best effort only. } if (-not (Dismount-OsfVolume -osfPath $osf -mountPoint $MountPoint -maxAttempts 6)) { Write-Warning "Failed to dismount OSFMount volume ($MountPoint): access denied or volume busy." } } } Write-Host "OK: Image created at: $ImagePath"