generalize: folderRedirect -- keep the SID generalized, persist user data on D:

The alternative to keepMachineSid for "survives an OS rebuild". Instead of
moving the whole SID-bound profile to D: (which forces a fixed SID), keep
/generalize -- so every machine and every rebuild gets its own random SID --
and redirect only the user's data folders (Desktop, Documents, Downloads, ...)
to the persistent data volume. Files survive a rebuild; per-user registry
settings do not, which is the accepted trade for not touching the SID.

Three pieces. A per-user script calls SHSetKnownFolderPath to point each known
folder at D:\UserData\<folder>; it is registered through Active Setup, which
runs it once per profile at first logon -- including the fresh profile each
generalized rebuild creates. And the onstart SYSTEM boot script grants the
well-known Users group inheritable full control on the data tree, so the
account behind whatever SID this rebuild produced can reach files an earlier
SID created.

The heal/relocation path is now gated on profilesDirectory, so it and
folderRedirect stay mutually exclusive and neither breaks the other's null.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g
This commit is contained in:
Git Sagar 2026-09-13 06:44:08 -03:00
parent d3ac2bf475
commit 60013006d6

View file

@ -52,6 +52,13 @@ in
# Correct only for an image that is always this one machine; a fleet that
# deploys the same image to many hosts wants the default generalization.
keepMachineSid ? false,
# Known-Folder redirection: keep the SID-bound profile on C: (so /generalize
# can still randomize the SID per machine) but point the user's data folders
# at the persistent data disk, so files -- not per-user registry settings --
# survive an OS rebuild. e.g. { base = "D:\\UserData"; folders = [ "Desktop"
# "Documents" "Downloads" ]; }. Needs dataDisk to provide the volume, and is
# mutually exclusive with profilesDirectory.
folderRedirect ? null,
}: let
# Convert "8e8cd8" hex to "142 140 216" decimal RGB for Windows registry
hexToRgbStr = hex: let
@ -194,7 +201,56 @@ in
# temporary-profile fallback), put it back: drop the temp key, rename .bak to
# the live SID, remove the temp directory, and drop a flag so the caller
# knows to reboot.
healProfileScript = pkgs.writeText "vmix-heal-profile.ps1" ''
# Known-Folder GUIDs for the redirectable user folders.
knownFolderGuids = {
Desktop = "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}";
Documents = "{FDD39AD0-238F-46AF-ADB4-6C85480369C7}";
Downloads = "{374DE290-123F-4565-9164-39C4925E467B}";
Pictures = "{33E28130-4E1E-4676-835A-98395C3BC476}";
Music = "{4BD8D571-6D19-48D3-BE97-422220080E43}";
Videos = "{18989B1D-99B5-455B-841C-AB7C74E4DDFC}";
Favorites = "{1777F761-68AD-4D8A-87BD-30B759FA33DD}";
};
redirectFolders = if folderRedirect != null
then (folderRedirect.folders or [ "Desktop" "Documents" "Downloads" "Pictures" "Music" "Videos" ])
else [ ];
redirectBase = if folderRedirect != null then (folderRedirect.base or "D:\\UserData") else "D:\\UserData";
# Per-user, run once per profile via Active Setup: point each known folder at
# its directory under the data volume. SHSetKnownFolderPath updates both the
# registration and the shell-folder registry; it does not move files, so a
# freshly created profile's empty C: folder is simply repointed at the D: one,
# which already holds this user's accumulated files after a rebuild.
folderRedirectPs1 = pkgs.writeText "vmix-redirect-folders.ps1" (lib.optionalString (folderRedirect != null) ''
$sig = @'
[DllImport("shell32.dll", CharSet=CharSet.Unicode)]
public static extern int SHSetKnownFolderPath(ref System.Guid rfid, uint dwFlags, System.IntPtr hToken, string pszPath);
'@
$kf = Add-Type -MemberDefinition $sig -Name KFP -Namespace Vmix -PassThru
$map = @{
${lib.concatMapStringsSep "\n " (f: "'${f}' = '${knownFolderGuids.${f}}'") redirectFolders}
}
foreach ($name in $map.Keys) {
$target = Join-Path '${redirectBase}' $name
New-Item -ItemType Directory -Force -Path $target -ErrorAction SilentlyContinue | Out-Null
$guid = [System.Guid]$map[$name]
[void]$kf::SHSetKnownFolderPath([ref]$guid, 0, [System.IntPtr]::Zero, $target)
}
'');
# Active Setup fires StubPath once per user at first logon -- including the
# fresh profile each generalized rebuild creates -- which is exactly when the
# redirection needs re-applying. Backslashes doubled for .reg.
activeSetupRegistry = lib.optionalString (folderRedirect != null) ''
Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Active Setup\Installed Components\{6f3b8c2a-1d4e-4f9a-b8c1-0a1b2c3d4e5f}]
@="vmix folder redirection"
"StubPath"="powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\\vmix-redirect-folders.ps1"
"Version"="1"
'';
healProfileScript = pkgs.writeText "vmix-heal-profile.ps1" (lib.optionalString (profilesDirectory != null) ''
$pl = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList'
$bak = Get-ChildItem $pl -ErrorAction SilentlyContinue | Where-Object {
$_.PSChildName -like '*.bak' -and
@ -207,29 +263,33 @@ in
Remove-Item '${profilesDirectory}\TEMP' -Recurse -Force -ErrorAction SilentlyContinue
New-Item -Path C:\Windows\Temp\vmix-profile-healed -ItemType File -Force | Out-Null
}
'';
'');
# Ensures D: exists on every boot and heals a profile that went temporary.
#
# generalize strips MountedDevices, so the shipped image carries no drive
# letter for the data disk, and the specialize pass that would re-assert it
# runs only in the build, not on the target. The zvol therefore boots
# letterless, the first autologon cannot find its relocated profile at
# ${profilesDirectory}\${username} (event 1511) and falls back to a temporary
# one, backing the real key up as .bak and making the fault stick.
#
# diskpart assign writes MountedDevices, so once this has run once D: is
# persistent for every later boot. Only the first boot is exposed, and if it
# left a .bak the heal puts it back and reboots -- the next boot, D: now
# persistent and ProfileList clean, logs straight into the real profile.
# One onstart / SYSTEM script for whatever the data disk needs before logon:
# assign its letter, and then either heal a relocated profile that went
# temporary (profilesDirectory) or make the redirected data folders reachable
# by whatever account this rebuild created (folderRedirect). Both cannot apply
# at once -- a profile is either wholly on D: or only its data folders are.
bootDataProfileScript = pkgs.writeText "vmix-data-profile.cmd" ''
@echo off
call C:\vmix-init-data-disk.cmd
${lib.optionalString (dataDisk != null) "call C:\\vmix-init-data-disk.cmd"}
${lib.optionalString (profilesDirectory != null) ''
powershell -NoProfile -ExecutionPolicy Bypass -File C:\vmix-heal-profile.ps1 > C:\Windows\Temp\vmix-data-profile.log 2>&1
if exist C:\Windows\Temp\vmix-profile-healed (
del /q C:\Windows\Temp\vmix-profile-healed
shutdown /r /t 5 /c "vmix: repaired relocated profile, restarting"
)
''}
${lib.optionalString (folderRedirect != null) ''
:: The redirected folders live under a per-user account whose SID changes on
:: every generalized rebuild, so grant the well-known Users group -- which
:: any account joins and which is SID-stable across machines -- inheritable
:: full control, and let the per-user redirect (Active Setup) point the known
:: folders here. Runs as SYSTEM, before any logon.
if not exist "${redirectBase}" mkdir "${redirectBase}"
${lib.concatMapStringsSep "\n " (f: ''if not exist "${redirectBase}\${f}" mkdir "${redirectBase}\${f}"'') redirectFolders}
icacls "${redirectBase}" /grant *S-1-5-32-545:(OI)(CI)F /t > C:\Windows\Temp\vmix-redirect-prep.log 2>&1
''}
'';
folderLocationsXml = lib.optionalString (profilesDirectory != null) ''
@ -469,7 +529,7 @@ in {
# so the profile relocation cannot ride the unattend there. It is written to
# the registry offline instead, before the build's OOBE creates the profile,
# so the account still lands on the data volume. Empty otherwise.
windowsRegistry = profileListRegistry;
windowsRegistry = profileListRegistry + activeSetupRegistry;
# The blank disk is attached for the Audit Mode boot itself, so the disk-init
# command and the profile relocation both happen under OOBE in the build VM.
# That is what makes delayOobeRun unnecessary: nothing is left to do on real
@ -479,11 +539,13 @@ in {
{ source = oobeXml; dest = "/oobe-unattend.xml"; }
{ source = postOobeScript; dest = "/post-oobe.cmd"; }
{ source = masScript; dest = "/MAS_AIO.cmd"; }
] ++ lib.optionals (dataDisk != null) [
{ source = initDataDiskScript; dest = "/vmix-init-data-disk.cmd"; }
{ source = healProfileScript; dest = "/vmix-heal-profile.ps1"; }
{ source = bootDataProfileScript; dest = "/vmix-data-profile.cmd"; }
]
] ++ lib.optionals (dataDisk != null) (
[ { source = initDataDiskScript; dest = "/vmix-init-data-disk.cmd"; }
{ source = bootDataProfileScript; dest = "/vmix-data-profile.cmd"; }
]
++ lib.optional (profilesDirectory != null) { source = healProfileScript; dest = "/vmix-heal-profile.ps1"; }
++ lib.optional (folderRedirect != null) { source = folderRedirectPs1; dest = "/vmix-redirect-folders.ps1"; }
)
++ lib.optional (writeFilter != null) { source = uwfConfigScript; dest = "/vmix-uwf-config.cmd"; }
++ lib.optionals (staticIP != null) [
{ source = staticIPScript; dest = "/vmix-static-ip.cmd"; }