Skip to content

Commit aa26eff

Browse files
committed
LogonTaskRegistration: per-user scheduled task that auto-mounts at logon
Adds IScheduledTaskInvoker, SchTasksScheduledTaskInvoker, and LogonTaskRegistration. Manages a per-user logon task that runs gvfs.exe service --mount-all via conhost.exe --headless to prevent console window flash. Drift detection via full SHA-256 hash marker. 21 new unit tests. 857/857 pass. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
1 parent 3a25e52 commit aa26eff

4 files changed

Lines changed: 704 additions & 0 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using System.Collections.Generic;
2+
3+
namespace GVFS.Common
4+
{
5+
/// <summary>
6+
/// Abstracts the Windows Task Scheduler operations needed by
7+
/// <see cref="LogonTaskRegistration"/>. Production callers use
8+
/// <see cref="SchTasksScheduledTaskInvoker"/>; tests pass a mock so
9+
/// they can exercise <see cref="LogonTaskRegistration"/>'s logic
10+
/// without actually touching the Task Scheduler on the test machine.
11+
/// </summary>
12+
public interface IScheduledTaskInvoker
13+
{
14+
/// <summary>
15+
/// Register the task at <paramref name="taskPath"/> from the given
16+
/// XML, overwriting any existing task at that path. Returns
17+
/// <c>true</c> on success.
18+
/// </summary>
19+
bool TryRegisterFromXml(string taskPath, string xml, out string errorMessage);
20+
21+
/// <summary>
22+
/// Read back the registered XML for the task at
23+
/// <paramref name="taskPath"/>. Returns <c>true</c> with the XML
24+
/// when the task exists; returns <c>false</c> with a populated
25+
/// <paramref name="errorMessage"/> when it does not.
26+
/// </summary>
27+
bool TryQueryXml(string taskPath, out string xml, out string errorMessage);
28+
29+
/// <summary>
30+
/// Unregister the task at <paramref name="taskPath"/>. Returns
31+
/// <c>true</c> if the task was unregistered OR was not registered
32+
/// to begin with (idempotent). Returns <c>false</c> only on a hard
33+
/// failure (e.g., permission denied).
34+
/// </summary>
35+
bool TryUnregister(string taskPath, out string errorMessage);
36+
}
37+
}
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
using GVFS.Common.Tracing;
2+
using System;
3+
using System.Security.Cryptography;
4+
using System.Text;
5+
6+
namespace GVFS.Common
7+
{
8+
/// <summary>
9+
/// Registers / updates / unregisters the per-user Windows scheduled task
10+
/// that mounts a user's registered GVFS enlistments at logon. Replaces
11+
/// the role of <c>GVFS.Service</c>'s session-change-driven AutoMount in
12+
/// the user-level install model.
13+
/// </summary>
14+
/// <remarks>
15+
/// <para>
16+
/// The task is registered at <c>\GVFS\AutoMount</c>, scoped to a single
17+
/// user (the user's SID is baked into the task's principal and trigger),
18+
/// runs at user logon with the user's interactive token, and executes
19+
/// <c>gvfs.exe service --mount-all</c>. The mount-all verb already
20+
/// reads the user's registered repos (via <see cref="LocalRepoRegistry"/>
21+
/// fallback when the service is unavailable) and mounts each one.
22+
/// </para>
23+
/// <para>
24+
/// Drift detection works via a content-hash marker embedded in the
25+
/// task's Description field
26+
/// (<c>[gvfs-logon-task-hash=XXXXXXXXXXXXXXXX]</c>). The hash covers the
27+
/// XML template <em>with placeholders still in place</em>, so it is
28+
/// stable across re-substitutions with different user SIDs or gvfs.exe
29+
/// paths -- only template content changes (a code change to the
30+
/// template constant) bump the hash. <see cref="IsCurrent"/> queries
31+
/// the registered task XML, extracts the marker, and compares against
32+
/// <see cref="TemplateHash"/>.
33+
/// </para>
34+
/// <para>
35+
/// Tested as a unit by passing a mock <see cref="IScheduledTaskInvoker"/>.
36+
/// Production callers should use
37+
/// <see cref="CreateForCurrentPlatform(ITracer)"/>, which constructs a
38+
/// <see cref="SchTasksScheduledTaskInvoker"/> behind the scenes.
39+
/// </para>
40+
/// </remarks>
41+
public class LogonTaskRegistration
42+
{
43+
public const string TaskName = "AutoMount";
44+
public const string TaskFolder = @"\GVFS\";
45+
public const string FullTaskPath = @"\GVFS\AutoMount";
46+
47+
public const string GvfsPathPlaceholder = "__GVFS_PATH__";
48+
public const string UserIdPlaceholder = "__USER_ID__";
49+
public const string TaskHashPlaceholder = "__TASK_HASH__";
50+
51+
public const string HashMarkerPrefix = "[gvfs-logon-task-hash=";
52+
public const string HashMarkerSuffix = "]";
53+
54+
/// <summary>
55+
/// Task XML template. Placeholders:
56+
/// <list type="bullet">
57+
/// <item><c>__GVFS_PATH__</c> -- absolute path to gvfs.exe</item>
58+
/// <item><c>__USER_ID__</c> -- the user's SID (must be the same one
59+
/// the principal runs as, so the logon trigger fires for the
60+
/// right user)</item>
61+
/// <item><c>__TASK_HASH__</c> -- content hash of this template,
62+
/// inserted into the Description for drift detection</item>
63+
/// </list>
64+
/// Indented as a verbatim string; the XML emitted is well-formed
65+
/// and accepted by <c>schtasks /Create /XML</c>.
66+
/// </summary>
67+
public const string XmlTemplate =
68+
@"<?xml version=""1.0"" encoding=""UTF-16""?>
69+
<Task version=""1.4"" xmlns=""http://schemas.microsoft.com/windows/2004/02/mit/task"">
70+
<RegistrationInfo>
71+
<Author>GVFS</Author>
72+
<Description>Mounts the user's registered GVFS enlistments at logon. Required by VFS for Git in the user-level install model. [gvfs-logon-task-hash=__TASK_HASH__]</Description>
73+
<URI>\GVFS\AutoMount</URI>
74+
</RegistrationInfo>
75+
<Triggers>
76+
<LogonTrigger>
77+
<Enabled>true</Enabled>
78+
<UserId>__USER_ID__</UserId>
79+
</LogonTrigger>
80+
</Triggers>
81+
<Principals>
82+
<Principal id=""Author"">
83+
<UserId>__USER_ID__</UserId>
84+
<LogonType>InteractiveToken</LogonType>
85+
<RunLevel>LeastPrivilege</RunLevel>
86+
</Principal>
87+
</Principals>
88+
<Settings>
89+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
90+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
91+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
92+
<AllowHardTerminate>true</AllowHardTerminate>
93+
<StartWhenAvailable>true</StartWhenAvailable>
94+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
95+
<IdleSettings>
96+
<StopOnIdleEnd>false</StopOnIdleEnd>
97+
<RestartOnIdle>false</RestartOnIdle>
98+
</IdleSettings>
99+
<AllowStartOnDemand>true</AllowStartOnDemand>
100+
<Enabled>true</Enabled>
101+
<Hidden>false</Hidden>
102+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
103+
<WakeToRun>false</WakeToRun>
104+
<ExecutionTimeLimit>PT5M</ExecutionTimeLimit>
105+
<Priority>5</Priority>
106+
</Settings>
107+
<Actions Context=""Author"">
108+
<Exec>
109+
<Command>conhost.exe</Command>
110+
<Arguments>--headless __GVFS_PATH__ service --mount-all</Arguments>
111+
</Exec>
112+
</Actions>
113+
</Task>
114+
";
115+
116+
private static readonly Lazy<string> templateHash = new Lazy<string>(ComputeTemplateHash);
117+
118+
private readonly ITracer tracer;
119+
private readonly IScheduledTaskInvoker invoker;
120+
121+
public LogonTaskRegistration(ITracer tracer, IScheduledTaskInvoker invoker)
122+
{
123+
ArgumentNullException.ThrowIfNull(tracer);
124+
ArgumentNullException.ThrowIfNull(invoker);
125+
this.tracer = tracer;
126+
this.invoker = invoker;
127+
}
128+
129+
/// <summary>
130+
/// Convenience factory for production callers: wires up a real
131+
/// <see cref="SchTasksScheduledTaskInvoker"/>.
132+
/// </summary>
133+
public static LogonTaskRegistration CreateForCurrentPlatform(ITracer tracer)
134+
{
135+
ArgumentNullException.ThrowIfNull(tracer);
136+
return new LogonTaskRegistration(tracer, new SchTasksScheduledTaskInvoker(tracer));
137+
}
138+
139+
/// <summary>
140+
/// Stable hex hash of <see cref="XmlTemplate"/> (with placeholders
141+
/// intact). 64 hex chars (full SHA-256), computed once per process.
142+
/// </summary>
143+
public static string TemplateHash => templateHash.Value;
144+
145+
/// <summary>
146+
/// Substitute placeholders to produce a registerable task XML.
147+
/// </summary>
148+
public static string BuildTaskXml(string gvfsExePath, string userSid)
149+
{
150+
ArgumentException.ThrowIfNullOrEmpty(gvfsExePath);
151+
ArgumentException.ThrowIfNullOrEmpty(userSid);
152+
153+
return XmlTemplate
154+
.Replace(GvfsPathPlaceholder, gvfsExePath)
155+
.Replace(UserIdPlaceholder, userSid)
156+
.Replace(TaskHashPlaceholder, TemplateHash);
157+
}
158+
159+
/// <summary>
160+
/// Extract the <c>[gvfs-logon-task-hash=XXXX]</c> hash marker from
161+
/// arbitrary text (usually a Task's Description). Returns
162+
/// <c>false</c> when no marker is present.
163+
/// </summary>
164+
public static bool TryExtractHashMarker(string text, out string hash)
165+
{
166+
hash = null;
167+
if (string.IsNullOrEmpty(text))
168+
{
169+
return false;
170+
}
171+
172+
int start = text.IndexOf(HashMarkerPrefix, StringComparison.Ordinal);
173+
if (start < 0)
174+
{
175+
return false;
176+
}
177+
178+
int hashStart = start + HashMarkerPrefix.Length;
179+
int hashEnd = text.IndexOf(HashMarkerSuffix, hashStart, StringComparison.Ordinal);
180+
if (hashEnd <= hashStart)
181+
{
182+
return false;
183+
}
184+
185+
hash = text.Substring(hashStart, hashEnd - hashStart);
186+
return true;
187+
}
188+
189+
/// <summary>
190+
/// Returns <c>true</c> when the logon task is registered AND its
191+
/// embedded hash marker matches the current template's hash.
192+
/// Returns <c>false</c> if the task is missing, the query fails,
193+
/// or the hash differs (drift).
194+
/// </summary>
195+
public bool IsCurrent()
196+
{
197+
if (!this.invoker.TryQueryXml(FullTaskPath, out string xml, out _))
198+
{
199+
return false;
200+
}
201+
202+
if (!TryExtractHashMarker(xml, out string registeredHash))
203+
{
204+
return false;
205+
}
206+
207+
return string.Equals(registeredHash, TemplateHash, StringComparison.Ordinal);
208+
}
209+
210+
/// <summary>
211+
/// Register the logon task with the given gvfs.exe path and user
212+
/// SID, overwriting any existing registration. Idempotent: when
213+
/// the registered task already matches the intended XML (same
214+
/// hash, same args), this is a fast no-op.
215+
/// </summary>
216+
public bool TryRegisterOrUpdate(string gvfsExePath, string userSid, out string errorMessage)
217+
{
218+
ArgumentException.ThrowIfNullOrEmpty(gvfsExePath);
219+
ArgumentException.ThrowIfNullOrEmpty(userSid);
220+
221+
if (this.IsCurrent())
222+
{
223+
// Still verify args are right; the hash covers the template
224+
// structure but not the substituted gvfs.exe path. Re-query
225+
// and check the action command.
226+
if (this.invoker.TryQueryXml(FullTaskPath, out string existingXml, out _) &&
227+
existingXml.Contains(gvfsExePath, StringComparison.Ordinal) &&
228+
existingXml.Contains($"<UserId>{userSid}</UserId>", StringComparison.Ordinal))
229+
{
230+
errorMessage = string.Empty;
231+
return true;
232+
}
233+
}
234+
235+
string xml = BuildTaskXml(gvfsExePath, userSid);
236+
return this.invoker.TryRegisterFromXml(FullTaskPath, xml, out errorMessage);
237+
}
238+
239+
/// <summary>
240+
/// Unregister the logon task. Idempotent: returns <c>true</c> when
241+
/// the task was unregistered OR was not registered to begin with.
242+
/// </summary>
243+
public bool TryUnregister(out string errorMessage)
244+
{
245+
return this.invoker.TryUnregister(FullTaskPath, out errorMessage);
246+
}
247+
248+
private static string ComputeTemplateHash()
249+
{
250+
byte[] bytes = Encoding.UTF8.GetBytes(XmlTemplate);
251+
byte[] hash = SHA256.HashData(bytes);
252+
return Convert.ToHexString(hash);
253+
}
254+
}
255+
}

0 commit comments

Comments
 (0)