From 60013006d61fa28bf6a03604adaf379830e473d8 Mon Sep 17 00:00:00 2001 From: Git Sagar Date: Sun, 13 Sep 2026 06:44:08 -0300 Subject: [PATCH] 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\; 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 Claude-Session: https://claude.ai/code/session_0117qMyjpuXsjpVAcpJbFD8g --- lib/images/windows/templates/generalize.nix | 106 ++++++++++++++++---- 1 file changed, 84 insertions(+), 22 deletions(-) diff --git a/lib/images/windows/templates/generalize.nix b/lib/images/windows/templates/generalize.nix index 5299d7f..8c1c10b 100644 --- a/lib/images/windows/templates/generalize.nix +++ b/lib/images/windows/templates/generalize.nix @@ -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"; }