Skip to content

Commit 38798d2

Browse files
committed
EnableProjFSOnAllDrives: source artifacts for boot-time PrjFlt attach task
Three files under scripts/projfs-attach/: the PS1 script body, the task XML template with placeholders, and build-task-xml.ps1 that base64-encodes the script into the template. None deployed to disk; the installer embeds via -EncodedCommand. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
1 parent aa26eff commit 38798d2

3 files changed

Lines changed: 322 additions & 0 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# build-task-xml.ps1
2+
#
3+
# Produces the final EnableProjFSOnAllDrives scheduled task XML by
4+
# base64-encoding enable-projfs-on-all-drives.ps1 and substituting it
5+
# (along with a content hash) into enable-projfs-on-all-drives-task.xml.template.
6+
#
7+
# Inputs and output are passed by parameter so this script is callable
8+
# from layout.bat, MSBuild, or directly during development.
9+
#
10+
# The hash embedded in the task Description (via __TASK_HASH__) is
11+
# SHA-256 over the un-encoded inputs (template + script body, in that
12+
# order, separated by a NUL byte). Stable across re-runs with
13+
# unchanged inputs; changes the moment either input's content
14+
# changes. This is what the installer's drift detection compares
15+
# against the registered task's Description marker to decide whether
16+
# re-registration is needed.
17+
#
18+
# Output XML is UTF-16 LE with BOM (required by Task Scheduler's
19+
# /XML import).
20+
21+
[CmdletBinding()]
22+
param(
23+
[Parameter(Mandatory = $true)]
24+
[string]$ScriptPath,
25+
26+
[Parameter(Mandatory = $true)]
27+
[string]$TemplatePath,
28+
29+
[Parameter(Mandatory = $true)]
30+
[string]$OutputPath
31+
)
32+
33+
$ErrorActionPreference = 'Stop'
34+
35+
if (-not (Test-Path $ScriptPath)) { throw "Script not found: $ScriptPath" }
36+
if (-not (Test-Path $TemplatePath)) { throw "Template not found: $TemplatePath" }
37+
38+
# Read raw bytes so the hash and the base64 are computed over exactly
39+
# what's on disk, regardless of line-ending or BOM conventions.
40+
$scriptBytes = [System.IO.File]::ReadAllBytes($ScriptPath)
41+
42+
# Read the template as text (UTF-8 or UTF-16 with BOM both work for
43+
# Get-Content; the template is checked in as UTF-16 to match the XML
44+
# encoding declaration but we re-emit as UTF-16 with BOM either way).
45+
$templateText = [System.IO.File]::ReadAllText($TemplatePath)
46+
$templateBytes = [System.Text.Encoding]::UTF8.GetBytes($templateText)
47+
48+
# PowerShell -EncodedCommand expects UTF-16 LE bytes, base64 encoded.
49+
$scriptUtf16 = [System.Text.Encoding]::Unicode.GetString($scriptBytes)
50+
# If the source script was UTF-8 (typical for files checked into git),
51+
# the line above produces garbage. Detect by checking for a UTF-8 BOM
52+
# or by attempting a UTF-8 decode and re-encoding to UTF-16.
53+
$scriptText =
54+
if ($scriptBytes.Length -ge 3 -and $scriptBytes[0] -eq 0xEF -and $scriptBytes[1] -eq 0xBB -and $scriptBytes[2] -eq 0xBF) {
55+
[System.Text.Encoding]::UTF8.GetString($scriptBytes, 3, $scriptBytes.Length - 3)
56+
}
57+
elseif ($scriptBytes.Length -ge 2 -and $scriptBytes[0] -eq 0xFF -and $scriptBytes[1] -eq 0xFE) {
58+
[System.Text.Encoding]::Unicode.GetString($scriptBytes, 2, $scriptBytes.Length - 2)
59+
}
60+
else {
61+
# Assume UTF-8 without BOM (git's default for text)
62+
[System.Text.Encoding]::UTF8.GetString($scriptBytes)
63+
}
64+
65+
$scriptUtf16Bytes = [System.Text.Encoding]::Unicode.GetBytes($scriptText)
66+
$encodedCommand = [System.Convert]::ToBase64String($scriptUtf16Bytes)
67+
68+
# Hash inputs: template bytes + NUL + script bytes (the raw bytes,
69+
# not re-encoded, so the hash is reproducible even if the encoding
70+
# detection logic is changed in a future revision of this script).
71+
$hasher = [System.Security.Cryptography.SHA256]::Create()
72+
try {
73+
$combined = New-Object byte[] ($templateBytes.Length + 1 + $scriptBytes.Length)
74+
[System.Buffer]::BlockCopy($templateBytes, 0, $combined, 0, $templateBytes.Length)
75+
$combined[$templateBytes.Length] = 0
76+
[System.Buffer]::BlockCopy($scriptBytes, 0, $combined, $templateBytes.Length + 1, $scriptBytes.Length)
77+
$hashBytes = $hasher.ComputeHash($combined)
78+
$hashHex = ([System.BitConverter]::ToString($hashBytes)).Replace('-', '')
79+
}
80+
finally {
81+
$hasher.Dispose()
82+
}
83+
84+
# Substitute placeholders. Order matters only because __SCRIPT_BASE64__
85+
# could in theory contain the __TASK_HASH__ literal -- highly unlikely
86+
# but trivially defended by substituting hash first.
87+
$finalXml = $templateText.
88+
Replace('__TASK_HASH__', $hashHex).
89+
Replace('__SCRIPT_BASE64__', $encodedCommand)
90+
91+
# Ensure output directory exists.
92+
$outputDir = Split-Path -Parent $OutputPath
93+
if ($outputDir -and -not (Test-Path $outputDir)) {
94+
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
95+
}
96+
97+
# Write UTF-16 LE with BOM (required by schtasks /Create /XML).
98+
[System.IO.File]::WriteAllText(
99+
$OutputPath,
100+
$finalXml,
101+
(New-Object System.Text.UnicodeEncoding $false, $true))
102+
103+
Write-Host "Wrote $OutputPath ($([System.IO.File]::ReadAllBytes($OutputPath).Length) bytes, hash=$hashHex)"
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?xml version="1.0" encoding="UTF-16"?>
2+
<!--
3+
enable-projfs-on-all-drives-task.xml.template
4+
5+
Scheduled task definition for the user-level GVFS install model.
6+
7+
This is a TEMPLATE: build-task-xml.ps1 reads it, substitutes
8+
__SCRIPT_BASE64__ with the base64-encoded contents of
9+
enable-projfs-on-all-drives.ps1 and __TASK_HASH__ with the SHA-256
10+
of the un-substituted template + script combined (so the hash is
11+
stable across re-substitutions but changes when either input
12+
content changes).
13+
14+
Registered once per machine at install time (admin elevation
15+
required). Once registered, runs as LocalSystem with no further
16+
UAC. No on-disk script file is deployed -- the body of the
17+
PowerShell script is embedded in the <Exec><Arguments> as
18+
-EncodedCommand <base64>.
19+
20+
Two triggers:
21+
1. BootTrigger - fires at OS startup. Re-attaches prjflt to every
22+
NTFS/ReFS volume after reboot (FilterAttach is not persistent).
23+
2. EventTrigger - subscribes to the Microsoft-Windows-Partition
24+
Diagnostic event log, Event ID 1006 (partition online). This
25+
event fires when a new partition becomes available, including
26+
USB plug-in, VHD mount, and new partition creation.
27+
28+
The Microsoft-Windows-Partition channel is enabled by default on
29+
Windows 10+ and fires reliably for both fixed and removable media.
30+
31+
The Description contains the marker [gvfs-task-hash=__TASK_HASH__]
32+
which the installer's drift-detect uses to decide whether the
33+
registered task still matches the intended one (no rewrite needed)
34+
or has drifted (re-register required). Re-registration is the only
35+
operation that requires UAC; per-user GVFS upgrades that don't
36+
change this task body never need elevation.
37+
-->
38+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
39+
<RegistrationInfo>
40+
<Author>Microsoft\GVFS</Author>
41+
<Description>Re-attaches the ProjFS filter (prjflt) to NTFS/ReFS volumes after reboot or volume mount. Required by VFS for Git in the user-level install model. [gvfs-task-hash=__TASK_HASH__]</Description>
42+
<URI>\GVFS\EnableProjFSOnAllDrives</URI>
43+
</RegistrationInfo>
44+
<Triggers>
45+
<BootTrigger>
46+
<Enabled>true</Enabled>
47+
</BootTrigger>
48+
<EventTrigger>
49+
<Enabled>true</Enabled>
50+
<Subscription>&lt;QueryList&gt;&lt;Query Id="0" Path="Microsoft-Windows-Partition/Diagnostic"&gt;&lt;Select Path="Microsoft-Windows-Partition/Diagnostic"&gt;*[System[Provider[@Name='Microsoft-Windows-Partition'] and (EventID=1006)]]&lt;/Select&gt;&lt;/Query&gt;&lt;/QueryList&gt;</Subscription>
51+
</EventTrigger>
52+
</Triggers>
53+
<Principals>
54+
<Principal id="Author">
55+
<UserId>S-1-5-18</UserId>
56+
<RunLevel>HighestAvailable</RunLevel>
57+
</Principal>
58+
</Principals>
59+
<Settings>
60+
<MultipleInstancesPolicy>Queue</MultipleInstancesPolicy>
61+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
62+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
63+
<AllowHardTerminate>true</AllowHardTerminate>
64+
<StartWhenAvailable>true</StartWhenAvailable>
65+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
66+
<IdleSettings>
67+
<StopOnIdleEnd>false</StopOnIdleEnd>
68+
<RestartOnIdle>false</RestartOnIdle>
69+
</IdleSettings>
70+
<AllowStartOnDemand>true</AllowStartOnDemand>
71+
<Enabled>true</Enabled>
72+
<Hidden>false</Hidden>
73+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
74+
<WakeToRun>false</WakeToRun>
75+
<ExecutionTimeLimit>PT5M</ExecutionTimeLimit>
76+
<Priority>5</Priority>
77+
</Settings>
78+
<Actions Context="Author">
79+
<Exec>
80+
<Command>powershell.exe</Command>
81+
<Arguments>-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand __SCRIPT_BASE64__</Arguments>
82+
</Exec>
83+
</Actions>
84+
</Task>
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# enable-projfs-on-all-drives.ps1
2+
#
3+
# Source of truth for the EnableProjFSOnAllDrives scheduled task body.
4+
# This script is NOT deployed to disk in the user-mode install model;
5+
# instead, build-task-xml.ps1 base64-encodes the contents and embeds
6+
# them in the task XML's <Exec><Arguments> as -EncodedCommand. The
7+
# task then runs as: powershell.exe -EncodedCommand <base64-of-this>.
8+
#
9+
# Runs as LocalSystem (configured by the scheduled task) so it has
10+
# SE_LOAD_DRIVER_PRIVILEGE for FilterAttach and HKLM write access
11+
# for the Dev Drive allowed-filters registry.
12+
#
13+
# Two invocation modes (selected by the task's triggers):
14+
# 1. AT_SYSTEM_START - no DriveLetter argument. Reconciles the Dev
15+
# Drive allow-list (machine-wide) and attaches prjflt to every
16+
# eligible NTFS/ReFS volume. FilterAttach is not persistent
17+
# across reboots, so this is required every boot.
18+
# 2. Event 1006 from Microsoft-Windows-Partition/Diagnostic -
19+
# DriveLetter argument is the drive of the newly-mounted volume.
20+
# Attaches prjflt to just that one drive. Avoids work on every
21+
# USB plug-in / VHD mount.
22+
#
23+
# Logs to %ProgramData%\GVFS\enable-projfs-on-all-drives.log
24+
# (HKLM-writable from SYSTEM, persistent across reboots).
25+
#
26+
# Idempotent everywhere: fltmc NameCollision is treated as success,
27+
# fsutil devdrv setFiltersAllowed is a no-op if already set. Safe to
28+
# run repeatedly.
29+
30+
[CmdletBinding()]
31+
param(
32+
# If provided, only attempt to attach to this single drive letter.
33+
# Used by the volume-mount trigger to scope work narrowly. When
34+
# absent, all NTFS/ReFS volumes are processed (boot trigger path),
35+
# and the Dev Drive allow-list is also reconciled.
36+
[string]$DriveLetter
37+
)
38+
39+
$ErrorActionPreference = 'Stop'
40+
41+
$logDir = Join-Path $env:ProgramData 'GVFS'
42+
$logPath = Join-Path $logDir 'enable-projfs-on-all-drives.log'
43+
if (-not (Test-Path $logDir)) {
44+
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
45+
}
46+
47+
function Write-Log([string]$msg) {
48+
$line = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $msg"
49+
Add-Content -Path $logPath -Value $line -Encoding UTF8
50+
}
51+
52+
function Set-PrjFltDevDriveAllowed {
53+
# Dev Drives consult a machine-wide allow-list at mount time to
54+
# decide which minifilters may attach. Without PrjFlt in the list,
55+
# GVFS cannot work on Dev Drives even if we call FilterAttach.
56+
# Set unconditionally; fsutil is a no-op if already set.
57+
try {
58+
$out = (& fsutil.exe devdrv setFiltersAllowed PrjFlt 2>&1 | Out-String).Trim()
59+
if ($LASTEXITCODE -eq 0) {
60+
Write-Log "DevDrive allow-list: PrjFlt allowed (output: $out)"
61+
}
62+
else {
63+
# Non-fatal: on older Windows builds without Dev Drive
64+
# support, fsutil devdrv may fail. Log and continue.
65+
Write-Log "DevDrive allow-list: fsutil exit=$LASTEXITCODE (likely no Dev Drive support on this OS) output=$out"
66+
}
67+
}
68+
catch {
69+
Write-Log "DevDrive allow-list: exception (likely no Dev Drive support): $_"
70+
}
71+
}
72+
73+
function Add-PrjFltToVolume([string]$drive) {
74+
$output = (& fltmc.exe attach PrjFlt "${drive}:" 2>&1 | Out-String).Trim()
75+
$exit = $LASTEXITCODE
76+
# NameCollision is success-equivalent: filter is already attached.
77+
# Check the output BEFORE the exit code because fltmc returns exit
78+
# 1 for NameCollision (despite it being benign).
79+
if ($output -match 'instance already exists' -or
80+
$output -match 'instance name collision' -or
81+
$output -match '0x801f0012') {
82+
Write-Log "OK ${drive}: already attached (NameCollision)"
83+
return $true
84+
}
85+
if ($exit -ne 0) {
86+
Write-Log "FAIL ${drive}: exit=$exit output=$output"
87+
return $false
88+
}
89+
Write-Log "OK ${drive}: attached (output: $output)"
90+
return $true
91+
}
92+
93+
try {
94+
Write-Log "===== enable-projfs-on-all-drives.ps1 starting (DriveLetter='$DriveLetter') ====="
95+
96+
if ($DriveLetter) {
97+
# Single-volume mode (volume-mount trigger)
98+
$drive = $DriveLetter.TrimEnd(':').TrimEnd('\').ToUpperInvariant()
99+
if ($drive.Length -ne 1) {
100+
Write-Log "ERROR: invalid DriveLetter '$DriveLetter' (parsed='$drive')"
101+
exit 2
102+
}
103+
$vol = Get-Volume -DriveLetter $drive -ErrorAction SilentlyContinue
104+
if (-not $vol) {
105+
Write-Log "SKIP ${drive}: volume not found"
106+
exit 0
107+
}
108+
if ($vol.FileSystemType -notin @('NTFS', 'ReFS')) {
109+
Write-Log "SKIP ${drive}: filesystem=$($vol.FileSystemType) (not NTFS/ReFS)"
110+
exit 0
111+
}
112+
Add-PrjFltToVolume $drive | Out-Null
113+
}
114+
else {
115+
# All-volumes mode (boot trigger). Reconcile both the Dev Drive
116+
# allow-list AND per-volume attachments. Cheap; idempotent.
117+
Set-PrjFltDevDriveAllowed
118+
$volumes = Get-Volume |
119+
Where-Object {
120+
$_.DriveLetter -and
121+
$_.FileSystemType -in @('NTFS', 'ReFS')
122+
}
123+
Write-Log "Found $(@($volumes).Count) eligible volume(s)"
124+
foreach ($v in $volumes) {
125+
Add-PrjFltToVolume ([string]$v.DriveLetter) | Out-Null
126+
}
127+
}
128+
129+
Write-Log "===== enable-projfs-on-all-drives.ps1 done ====="
130+
}
131+
catch {
132+
Write-Log "EXCEPTION: $_"
133+
Write-Log $_.ScriptStackTrace
134+
exit 3
135+
}

0 commit comments

Comments
 (0)