Files
ShadowMountPlus/New-OsfExfatImage.ps1

400 lines
14 KiB
PowerShell

<# 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"