diff --git a/GVFS/GVFS.Common/IScheduledTaskInvoker.cs b/GVFS/GVFS.Common/IScheduledTaskInvoker.cs
new file mode 100644
index 000000000..9a8750de1
--- /dev/null
+++ b/GVFS/GVFS.Common/IScheduledTaskInvoker.cs
@@ -0,0 +1,37 @@
+using System.Collections.Generic;
+
+namespace GVFS.Common
+{
+ ///
+ /// Abstracts the Windows Task Scheduler operations needed by
+ /// . Production callers use
+ /// ; tests pass a mock so
+ /// they can exercise 's logic
+ /// without actually touching the Task Scheduler on the test machine.
+ ///
+ public interface IScheduledTaskInvoker
+ {
+ ///
+ /// Register the task at from the given
+ /// XML, overwriting any existing task at that path. Returns
+ /// true on success.
+ ///
+ bool TryRegisterFromXml(string taskPath, string xml, out string errorMessage);
+
+ ///
+ /// Read back the registered XML for the task at
+ /// . Returns true with the XML
+ /// when the task exists; returns false with a populated
+ /// when it does not.
+ ///
+ bool TryQueryXml(string taskPath, out string xml, out string errorMessage);
+
+ ///
+ /// Unregister the task at . Returns
+ /// true if the task was unregistered OR was not registered
+ /// to begin with (idempotent). Returns false only on a hard
+ /// failure (e.g., permission denied).
+ ///
+ bool TryUnregister(string taskPath, out string errorMessage);
+ }
+}
diff --git a/GVFS/GVFS.Common/LocalRepoRegistration.cs b/GVFS/GVFS.Common/LocalRepoRegistration.cs
new file mode 100644
index 000000000..ebb28b355
--- /dev/null
+++ b/GVFS/GVFS.Common/LocalRepoRegistration.cs
@@ -0,0 +1,64 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace GVFS.Common
+{
+ ///
+ /// One entry in the user-level repo registry on disk. Field set and
+ /// JSON shape MUST match GVFS.Service.RepoRegistration so that the
+ /// user-level registry file (written by )
+ /// is wire-compatible with any registry the legacy service has written
+ /// in the past. If a new field is added here, the same field must also
+ /// be added to GVFS.Service.RepoRegistration (and vice versa) along
+ /// with a registry-format-version bump.
+ ///
+ public class LocalRepoRegistration
+ {
+ public LocalRepoRegistration()
+ {
+ }
+
+ public LocalRepoRegistration(string enlistmentRoot, string ownerSID)
+ {
+ this.EnlistmentRoot = enlistmentRoot;
+ this.OwnerSID = ownerSID;
+ this.IsActive = true;
+ }
+
+ public string EnlistmentRoot { get; set; }
+ public string OwnerSID { get; set; }
+ public bool IsActive { get; set; }
+
+ // Uses LocalRepoRegistrationJsonContext (assembly-local source generator)
+ // rather than GVFSJsonContext. The service-side RepoRegistration uses
+ // its own ServiceJsonContext for the same reason — neither type can be
+ // registered in GVFSJsonContext because GVFSJsonContext lives in
+ // GVFS.Common and the service-side type lives in GVFS.Service (wrong
+ // dependency direction). Keeping symmetric local contexts here means
+ // the on-disk JSON shape is governed by identical source-gen behavior
+ // on both sides.
+ public static LocalRepoRegistration FromJson(string json)
+ {
+ return JsonSerializer.Deserialize(json, LocalRepoRegistrationJsonContext.Default.LocalRepoRegistration);
+ }
+
+ public string ToJson()
+ {
+ return JsonSerializer.Serialize(this, LocalRepoRegistrationJsonContext.Default.LocalRepoRegistration);
+ }
+
+ public override string ToString()
+ {
+ return string.Format(
+ "({0} - {1}) {2}",
+ this.IsActive ? "Active" : "Inactive",
+ this.OwnerSID,
+ this.EnlistmentRoot);
+ }
+ }
+
+ [JsonSerializable(typeof(LocalRepoRegistration))]
+ internal partial class LocalRepoRegistrationJsonContext : JsonSerializerContext
+ {
+ }
+}
diff --git a/GVFS/GVFS.Common/LocalRepoRegistry.cs b/GVFS/GVFS.Common/LocalRepoRegistry.cs
new file mode 100644
index 000000000..54a31b60e
--- /dev/null
+++ b/GVFS/GVFS.Common/LocalRepoRegistry.cs
@@ -0,0 +1,348 @@
+using GVFS.Common.FileSystem;
+using GVFS.Common.Tracing;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace GVFS.Common
+{
+ ///
+ /// File-backed repo registry usable from any GVFS process without going
+ /// through GVFS.Service. The on-disk format is wire-compatible with
+ /// GVFS.Service.RepoRegistry — both produce and consume the same
+ /// repo-registry file under the shared service data directory.
+ ///
+ ///
+ ///
+ /// On-disk format (line-oriented, identical to GVFS.Service.RepoRegistry):
+ ///
+ ///
+ /// - Line 1: registry format version (integer, currently 2).
+ /// -
+ /// Lines 2..N: one JSON object per line.
+ /// Blank lines and lines that fail to parse are skipped (matches the service's
+ /// tolerance for partial corruption).
+ ///
+ ///
+ ///
+ /// Threading: instance methods that read or write the registry serialize on
+ /// a private instance lock. Cross-process safety relies on the same atomic
+ /// write-temp-then-replace pattern the service uses.
+ ///
+ ///
+ /// This type does not pick its own storage location — callers pass
+ /// via the constructor. Production
+ /// callers should pass GVFSPlatform.Instance.GetSecureDataRootForGVFSComponent(ServiceDataDirName)
+ /// so the file lives at the same path the service uses (which honors the
+ /// GVFS_SECURE_DATA_ROOT environment-variable redirect for user-level
+ /// installs).
+ ///
+ ///
+ public class LocalRepoRegistry
+ {
+ ///
+ /// Subdirectory under the platform's secure-data root that holds the
+ /// registry file. Matches the legacy service's name so both producers
+ /// write to the same location.
+ ///
+ public const string ServiceDataDirName = "GVFS.Service";
+
+ /// Final on-disk name of the registry file.
+ public const string RegistryFileName = "repo-registry";
+
+ ///
+ /// Temp name used by the atomic write-then-replace pattern. Named
+ /// repo-registry.lock for byte-for-byte compatibility with
+ /// the legacy service's choice (so a writer interrupted mid-rename
+ /// leaves a file with the same name the service would have left).
+ ///
+ public const string RegistryTempName = "repo-registry.lock";
+
+ ///
+ /// Registry format version this implementation can read AND write.
+ /// Files with a higher version on disk are treated as opaque: read
+ /// returns empty and we refuse to overwrite, so a newer GVFS that
+ /// has written the registry is not corrupted by an older GVFS.
+ ///
+ public const int RegistryVersion = 2;
+
+ private readonly ITracer tracer;
+ private readonly PhysicalFileSystem fileSystem;
+ private readonly string registryDirectory;
+ private readonly object instanceLock = new object();
+
+ public LocalRepoRegistry(ITracer tracer, PhysicalFileSystem fileSystem, string registryDirectory)
+ {
+ ArgumentNullException.ThrowIfNull(tracer);
+ ArgumentNullException.ThrowIfNull(fileSystem);
+ ArgumentNullException.ThrowIfNull(registryDirectory);
+
+ this.tracer = tracer;
+ this.fileSystem = fileSystem;
+ this.registryDirectory = registryDirectory;
+ }
+
+ ///
+ /// Convenience factory for production callers: constructs an instance
+ /// pointed at the platform's secure-data path for the GVFS.Service
+ /// component, using a real . This is
+ /// the same path the legacy service writes to, so register/unregister
+ /// operations are wire-compatible regardless of whether the service
+ /// is running.
+ ///
+ public static LocalRepoRegistry CreateForCurrentPlatform(ITracer tracer)
+ {
+ ArgumentNullException.ThrowIfNull(tracer);
+ return new LocalRepoRegistry(
+ tracer,
+ new PhysicalFileSystem(),
+ GVFSPlatform.Instance.GetSecureDataRootForGVFSComponent(ServiceDataDirName));
+ }
+
+ ///
+ /// Idempotently records the given enlistment root as active. If an
+ /// entry already exists, it is reactivated and its OwnerSID
+ /// is updated to . Matches the semantics
+ /// of GVFS.Service.RepoRegistry.TryRegisterRepo.
+ ///
+ public bool TryRegisterRepo(string repoRoot, string ownerSID, out string errorMessage)
+ {
+ ArgumentNullException.ThrowIfNull(repoRoot);
+
+ errorMessage = null;
+ try
+ {
+ lock (this.instanceLock)
+ {
+ Dictionary all = this.ReadRegistry();
+ if (all.TryGetValue(repoRoot, out LocalRepoRegistration existing))
+ {
+ if (!existing.IsActive || !string.Equals(existing.OwnerSID, ownerSID, StringComparison.Ordinal))
+ {
+ existing.IsActive = true;
+ existing.OwnerSID = ownerSID;
+ this.WriteRegistry(all);
+ }
+ }
+ else
+ {
+ all[repoRoot] = new LocalRepoRegistration(repoRoot, ownerSID);
+ this.WriteRegistry(all);
+ }
+ }
+
+ return true;
+ }
+ catch (Exception e)
+ {
+ errorMessage = string.Format("Error while registering repo {0}: {1}", repoRoot, e);
+ this.tracer.RelatedError(errorMessage);
+ return false;
+ }
+ }
+
+ ///
+ /// Marks the given entry inactive (retained on disk so
+ /// is preserved for a
+ /// possible later re-register). Returns true when the entry
+ /// existed (whether or not it was already inactive); returns
+ /// false when the entry was not found.
+ ///
+ public bool TryDeactivateRepo(string repoRoot, out string errorMessage)
+ {
+ ArgumentNullException.ThrowIfNull(repoRoot);
+
+ errorMessage = null;
+ try
+ {
+ lock (this.instanceLock)
+ {
+ Dictionary all = this.ReadRegistry();
+ if (all.TryGetValue(repoRoot, out LocalRepoRegistration existing))
+ {
+ if (existing.IsActive)
+ {
+ existing.IsActive = false;
+ this.WriteRegistry(all);
+ }
+
+ return true;
+ }
+
+ errorMessage = string.Format("Attempted to deactivate non-existent repo at '{0}'", repoRoot);
+ return false;
+ }
+ }
+ catch (Exception e)
+ {
+ errorMessage = string.Format("Error while deactivating repo {0}: {1}", repoRoot, e);
+ this.tracer.RelatedError(errorMessage);
+ return false;
+ }
+ }
+
+ ///
+ /// Removes the entry entirely (not just deactivates it). Returns
+ /// true on success, false if no such entry existed.
+ ///
+ public bool TryRemoveRepo(string repoRoot, out string errorMessage)
+ {
+ ArgumentNullException.ThrowIfNull(repoRoot);
+
+ errorMessage = null;
+ try
+ {
+ lock (this.instanceLock)
+ {
+ Dictionary all = this.ReadRegistry();
+ if (all.Remove(repoRoot))
+ {
+ this.WriteRegistry(all);
+ return true;
+ }
+
+ errorMessage = string.Format("Attempted to remove non-existent repo at '{0}'", repoRoot);
+ return false;
+ }
+ }
+ catch (Exception e)
+ {
+ errorMessage = string.Format("Error while removing repo {0}: {1}", repoRoot, e);
+ this.tracer.RelatedError(errorMessage);
+ return false;
+ }
+ }
+
+ ///
+ /// Returns the entries currently marked active. Inactive entries are
+ /// excluded. Returns an empty list when the registry file does not
+ /// exist yet.
+ ///
+ public bool TryGetActiveRepos(out List repoList, out string errorMessage)
+ {
+ repoList = null;
+ errorMessage = null;
+
+ lock (this.instanceLock)
+ {
+ try
+ {
+ Dictionary all = this.ReadRegistry();
+ repoList = all.Values.Where(r => r.IsActive).ToList();
+ return true;
+ }
+ catch (Exception e)
+ {
+ errorMessage = string.Format("Unable to get list of active repos: {0}", e);
+ this.tracer.RelatedError(errorMessage);
+ return false;
+ }
+ }
+ }
+
+ ///
+ /// Returns the in-memory map of all entries currently on disk
+ /// (active and inactive). Intended for diagnostics and tests; most
+ /// production callers should use .
+ ///
+ public Dictionary ReadRegistry()
+ {
+ Dictionary allRepos =
+ new Dictionary(GVFSPlatform.Instance.Constants.PathComparer);
+
+ string registryFilePath = Path.Combine(this.registryDirectory, RegistryFileName);
+
+ using (Stream stream = this.fileSystem.OpenFileStream(
+ registryFilePath,
+ FileMode.OpenOrCreate,
+ FileAccess.Read,
+ FileShare.Read,
+ callFlushFileBuffers: false))
+ using (StreamReader reader = new StreamReader(stream))
+ {
+ string versionString = reader.ReadLine();
+ if (versionString == null)
+ {
+ // Empty file - first write will populate it.
+ return allRepos;
+ }
+
+ if (!int.TryParse(versionString, out int version) || version > RegistryVersion)
+ {
+ EventMetadata metadata = new EventMetadata
+ {
+ { "OnDiskVersion", versionString },
+ { "MaxSupportedVersion", RegistryVersion },
+ };
+ this.tracer.RelatedError(metadata, $"{nameof(this.ReadRegistry)}: Unsupported registry version; treating as empty");
+ return allRepos;
+ }
+
+ while (!reader.EndOfStream)
+ {
+ string entry = reader.ReadLine();
+ if (string.IsNullOrEmpty(entry))
+ {
+ continue;
+ }
+
+ try
+ {
+ LocalRepoRegistration registration = LocalRepoRegistration.FromJson(entry);
+ if (registration != null && !string.IsNullOrEmpty(registration.EnlistmentRoot))
+ {
+ allRepos[registration.EnlistmentRoot] = registration;
+ }
+ }
+ catch (Exception e)
+ {
+ // Tolerate corruption of individual lines; matches
+ // RepoRegistry.ReadRegistry's behavior.
+ EventMetadata metadata = new EventMetadata
+ {
+ { "entry", entry },
+ { "Exception", e.ToString() },
+ };
+ this.tracer.RelatedError(metadata, $"{nameof(this.ReadRegistry)}: Failed to parse entry; skipping");
+ }
+ }
+ }
+
+ return allRepos;
+ }
+
+ private void WriteRegistry(Dictionary registry)
+ {
+ // Ensure the directory exists. The service relies on its install
+ // step to create %ProgramData%\GVFS\GVFS.Service; the user-level
+ // path under %LocalAppData% may not exist yet when this runs.
+ if (!this.fileSystem.DirectoryExists(this.registryDirectory))
+ {
+ this.fileSystem.CreateDirectory(this.registryDirectory);
+ }
+
+ string tempFilePath = Path.Combine(this.registryDirectory, RegistryTempName);
+ string finalFilePath = Path.Combine(this.registryDirectory, RegistryFileName);
+
+ using (Stream stream = this.fileSystem.OpenFileStream(
+ tempFilePath,
+ FileMode.Create,
+ FileAccess.Write,
+ FileShare.None,
+ callFlushFileBuffers: true))
+ using (StreamWriter writer = new StreamWriter(stream))
+ {
+ writer.WriteLine(RegistryVersion);
+ foreach (LocalRepoRegistration registration in registry.Values)
+ {
+ writer.WriteLine(registration.ToJson());
+ }
+
+ stream.Flush();
+ }
+
+ this.fileSystem.MoveAndOverwriteFile(tempFilePath, finalFilePath);
+ }
+ }
+}
diff --git a/GVFS/GVFS.Common/LogonTaskRegistration.cs b/GVFS/GVFS.Common/LogonTaskRegistration.cs
new file mode 100644
index 000000000..a353e6bd8
--- /dev/null
+++ b/GVFS/GVFS.Common/LogonTaskRegistration.cs
@@ -0,0 +1,255 @@
+using GVFS.Common.Tracing;
+using System;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace GVFS.Common
+{
+ ///
+ /// Registers / updates / unregisters the per-user Windows scheduled task
+ /// that mounts a user's registered GVFS enlistments at logon. Replaces
+ /// the role of GVFS.Service's session-change-driven AutoMount in
+ /// the user-level install model.
+ ///
+ ///
+ ///
+ /// The task is registered at \GVFS\AutoMount, scoped to a single
+ /// user (the user's SID is baked into the task's principal and trigger),
+ /// runs at user logon with the user's interactive token, and executes
+ /// gvfs.exe service --mount-all. The mount-all verb already
+ /// reads the user's registered repos (via
+ /// fallback when the service is unavailable) and mounts each one.
+ ///
+ ///
+ /// Drift detection works via a content-hash marker embedded in the
+ /// task's Description field
+ /// ([gvfs-logon-task-hash=XXXXXXXXXXXXXXXX]). The hash covers the
+ /// XML template with placeholders still in place, so it is
+ /// stable across re-substitutions with different user SIDs or gvfs.exe
+ /// paths -- only template content changes (a code change to the
+ /// template constant) bump the hash. queries
+ /// the registered task XML, extracts the marker, and compares against
+ /// .
+ ///
+ ///
+ /// Tested as a unit by passing a mock .
+ /// Production callers should use
+ /// , which constructs a
+ /// behind the scenes.
+ ///
+ ///
+ public class LogonTaskRegistration
+ {
+ public const string TaskName = "AutoMount";
+ public const string TaskFolder = @"\GVFS\";
+ public const string FullTaskPath = @"\GVFS\AutoMount";
+
+ public const string GvfsPathPlaceholder = "__GVFS_PATH__";
+ public const string UserIdPlaceholder = "__USER_ID__";
+ public const string TaskHashPlaceholder = "__TASK_HASH__";
+
+ public const string HashMarkerPrefix = "[gvfs-logon-task-hash=";
+ public const string HashMarkerSuffix = "]";
+
+ ///
+ /// Task XML template. Placeholders:
+ ///
+ /// - __GVFS_PATH__ -- absolute path to gvfs.exe
+ /// - __USER_ID__ -- the user's SID (must be the same one
+ /// the principal runs as, so the logon trigger fires for the
+ /// right user)
+ /// - __TASK_HASH__ -- content hash of this template,
+ /// inserted into the Description for drift detection
+ ///
+ /// Indented as a verbatim string; the XML emitted is well-formed
+ /// and accepted by schtasks /Create /XML.
+ ///
+ public const string XmlTemplate =
+@"
+
+
+ GVFS
+ 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__]
+ \GVFS\AutoMount
+
+
+
+ true
+ __USER_ID__
+
+
+
+
+ __USER_ID__
+ InteractiveToken
+ LeastPrivilege
+
+
+
+ IgnoreNew
+ false
+ false
+ true
+ true
+ false
+
+ false
+ false
+
+ true
+ true
+ false
+ false
+ false
+ PT5M
+ 5
+
+
+
+ conhost.exe
+ --headless __GVFS_PATH__ service --mount-all
+
+
+
+";
+
+ private static readonly Lazy templateHash = new Lazy(ComputeTemplateHash);
+
+ private readonly ITracer tracer;
+ private readonly IScheduledTaskInvoker invoker;
+
+ public LogonTaskRegistration(ITracer tracer, IScheduledTaskInvoker invoker)
+ {
+ ArgumentNullException.ThrowIfNull(tracer);
+ ArgumentNullException.ThrowIfNull(invoker);
+ this.tracer = tracer;
+ this.invoker = invoker;
+ }
+
+ ///
+ /// Convenience factory for production callers: wires up a real
+ /// .
+ ///
+ public static LogonTaskRegistration CreateForCurrentPlatform(ITracer tracer)
+ {
+ ArgumentNullException.ThrowIfNull(tracer);
+ return new LogonTaskRegistration(tracer, new SchTasksScheduledTaskInvoker(tracer));
+ }
+
+ ///
+ /// Stable hex hash of (with placeholders
+ /// intact). 64 hex chars (full SHA-256), computed once per process.
+ ///
+ public static string TemplateHash => templateHash.Value;
+
+ ///
+ /// Substitute placeholders to produce a registerable task XML.
+ ///
+ public static string BuildTaskXml(string gvfsExePath, string userSid)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(gvfsExePath);
+ ArgumentException.ThrowIfNullOrEmpty(userSid);
+
+ return XmlTemplate
+ .Replace(GvfsPathPlaceholder, gvfsExePath)
+ .Replace(UserIdPlaceholder, userSid)
+ .Replace(TaskHashPlaceholder, TemplateHash);
+ }
+
+ ///
+ /// Extract the [gvfs-logon-task-hash=XXXX] hash marker from
+ /// arbitrary text (usually a Task's Description). Returns
+ /// false when no marker is present.
+ ///
+ public static bool TryExtractHashMarker(string text, out string hash)
+ {
+ hash = null;
+ if (string.IsNullOrEmpty(text))
+ {
+ return false;
+ }
+
+ int start = text.IndexOf(HashMarkerPrefix, StringComparison.Ordinal);
+ if (start < 0)
+ {
+ return false;
+ }
+
+ int hashStart = start + HashMarkerPrefix.Length;
+ int hashEnd = text.IndexOf(HashMarkerSuffix, hashStart, StringComparison.Ordinal);
+ if (hashEnd <= hashStart)
+ {
+ return false;
+ }
+
+ hash = text.Substring(hashStart, hashEnd - hashStart);
+ return true;
+ }
+
+ ///
+ /// Returns true when the logon task is registered AND its
+ /// embedded hash marker matches the current template's hash.
+ /// Returns false if the task is missing, the query fails,
+ /// or the hash differs (drift).
+ ///
+ public bool IsCurrent()
+ {
+ if (!this.invoker.TryQueryXml(FullTaskPath, out string xml, out _))
+ {
+ return false;
+ }
+
+ if (!TryExtractHashMarker(xml, out string registeredHash))
+ {
+ return false;
+ }
+
+ return string.Equals(registeredHash, TemplateHash, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Register the logon task with the given gvfs.exe path and user
+ /// SID, overwriting any existing registration. Idempotent: when
+ /// the registered task already matches the intended XML (same
+ /// hash, same args), this is a fast no-op.
+ ///
+ public bool TryRegisterOrUpdate(string gvfsExePath, string userSid, out string errorMessage)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(gvfsExePath);
+ ArgumentException.ThrowIfNullOrEmpty(userSid);
+
+ if (this.IsCurrent())
+ {
+ // Still verify args are right; the hash covers the template
+ // structure but not the substituted gvfs.exe path. Re-query
+ // and check the action command.
+ if (this.invoker.TryQueryXml(FullTaskPath, out string existingXml, out _) &&
+ existingXml.Contains(gvfsExePath, StringComparison.Ordinal) &&
+ existingXml.Contains($"{userSid}", StringComparison.Ordinal))
+ {
+ errorMessage = string.Empty;
+ return true;
+ }
+ }
+
+ string xml = BuildTaskXml(gvfsExePath, userSid);
+ return this.invoker.TryRegisterFromXml(FullTaskPath, xml, out errorMessage);
+ }
+
+ ///
+ /// Unregister the logon task. Idempotent: returns true when
+ /// the task was unregistered OR was not registered to begin with.
+ ///
+ public bool TryUnregister(out string errorMessage)
+ {
+ return this.invoker.TryUnregister(FullTaskPath, out errorMessage);
+ }
+
+ private static string ComputeTemplateHash()
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(XmlTemplate);
+ byte[] hash = SHA256.HashData(bytes);
+ return Convert.ToHexString(hash);
+ }
+ }
+}
diff --git a/GVFS/GVFS.Common/SchTasksScheduledTaskInvoker.cs b/GVFS/GVFS.Common/SchTasksScheduledTaskInvoker.cs
new file mode 100644
index 000000000..04dbe0b50
--- /dev/null
+++ b/GVFS/GVFS.Common/SchTasksScheduledTaskInvoker.cs
@@ -0,0 +1,124 @@
+using GVFS.Common.Tracing;
+using System;
+using System.IO;
+
+namespace GVFS.Common
+{
+ ///
+ /// Default implementation: shells out
+ /// to schtasks.exe. Windows-only by nature -- on non-Windows the
+ /// process launch fails and operations return false with a populated
+ /// error message. User-mode install is Windows-only, so that's fine.
+ ///
+ public class SchTasksScheduledTaskInvoker : IScheduledTaskInvoker
+ {
+ private readonly ITracer tracer;
+
+ public SchTasksScheduledTaskInvoker(ITracer tracer)
+ {
+ ArgumentNullException.ThrowIfNull(tracer);
+ this.tracer = tracer;
+ }
+
+ public bool TryRegisterFromXml(string taskPath, string xml, out string errorMessage)
+ {
+ // schtasks /Create accepts an XML file path via /XML, not raw
+ // XML on stdin. Write to a temp file with the same UTF-16 BOM
+ // the Task Scheduler XML schema expects, then run schtasks.
+ string tempPath = Path.Combine(Path.GetTempPath(), $"gvfs-task-{Guid.NewGuid():N}.xml");
+ try
+ {
+ File.WriteAllText(tempPath, xml, new System.Text.UnicodeEncoding(bigEndian: false, byteOrderMark: true));
+
+ // /F overwrites if already exists.
+ ProcessResult result = ProcessHelper.Run(
+ "schtasks.exe",
+ $"/Create /TN \"{taskPath}\" /XML \"{tempPath}\" /F");
+
+ if (result.ExitCode != 0)
+ {
+ errorMessage = $"schtasks /Create failed (exit {result.ExitCode}): {result.Output.Trim()} {result.Errors.Trim()}".Trim();
+ return false;
+ }
+
+ errorMessage = string.Empty;
+ return true;
+ }
+ catch (Exception e)
+ {
+ errorMessage = $"Failed to register scheduled task: {e}";
+ this.tracer.RelatedError(errorMessage);
+ return false;
+ }
+ finally
+ {
+ try { File.Delete(tempPath); } catch { /* best-effort cleanup */ }
+ }
+ }
+
+ public bool TryQueryXml(string taskPath, out string xml, out string errorMessage)
+ {
+ xml = null;
+ try
+ {
+ ProcessResult result = ProcessHelper.Run(
+ "schtasks.exe",
+ $"/Query /TN \"{taskPath}\" /XML");
+
+ if (result.ExitCode != 0)
+ {
+ // Exit 1 = task not found. Surface a useful message; the
+ // caller distinguishes "not found" from "permission denied"
+ // by inspecting the message text or just treating both as
+ // "not current".
+ errorMessage = $"schtasks /Query failed (exit {result.ExitCode}): {result.Output.Trim()} {result.Errors.Trim()}".Trim();
+ return false;
+ }
+
+ xml = result.Output;
+ errorMessage = string.Empty;
+ return true;
+ }
+ catch (Exception e)
+ {
+ errorMessage = $"Failed to query scheduled task: {e}";
+ this.tracer.RelatedError(errorMessage);
+ return false;
+ }
+ }
+
+ public bool TryUnregister(string taskPath, out string errorMessage)
+ {
+ try
+ {
+ ProcessResult result = ProcessHelper.Run(
+ "schtasks.exe",
+ $"/Delete /TN \"{taskPath}\" /F");
+
+ if (result.ExitCode != 0)
+ {
+ // Exit 1 with "cannot find the file specified" means the
+ // task is already gone; treat as success.
+ string combined = (result.Output + " " + result.Errors).ToLowerInvariant();
+ if (combined.Contains("cannot find the file") || combined.Contains("system cannot find"))
+ {
+ errorMessage = string.Empty;
+ return true;
+ }
+
+ errorMessage = $"schtasks /Delete failed (exit {result.ExitCode}): {result.Output.Trim()} {result.Errors.Trim()}".Trim();
+ return false;
+ }
+
+ errorMessage = string.Empty;
+ return true;
+ }
+ catch (Exception e)
+ {
+ errorMessage = $"Failed to unregister scheduled task: {e}";
+ this.tracer.RelatedError(errorMessage);
+ return false;
+ }
+ }
+ }
+}
diff --git a/GVFS/GVFS.UnitTests/Common/LocalRepoRegistryTests.cs b/GVFS/GVFS.UnitTests/Common/LocalRepoRegistryTests.cs
new file mode 100644
index 000000000..4a24db454
--- /dev/null
+++ b/GVFS/GVFS.UnitTests/Common/LocalRepoRegistryTests.cs
@@ -0,0 +1,281 @@
+using GVFS.Common;
+using GVFS.Tests.Should;
+using GVFS.UnitTests.Mock.Common;
+using GVFS.UnitTests.Mock.FileSystem;
+using NUnit.Framework;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace GVFS.UnitTests.Common
+{
+ [TestFixture]
+ public class LocalRepoRegistryTests
+ {
+ private const string DataLocation = @"mock:\registryDataFolder";
+ private const string Repo1 = @"mock:\code\repo1";
+ private const string Repo2 = @"mock:\code\repo2";
+ private const string Repo3 = @"mock:\code\repo3";
+
+ [TestCase]
+ public void TryRegisterRepo_EmptyRegistry_RoundTripsThroughDisk()
+ {
+ (LocalRepoRegistry registry, MockFileSystem _) = this.CreateRegistry();
+ string ownerSID = Guid.NewGuid().ToString();
+
+ registry.TryRegisterRepo(Repo1, ownerSID, out string error).ShouldBeTrue(error);
+
+ Dictionary all = registry.ReadRegistry();
+ all.Count.ShouldEqual(1);
+ VerifyEntry(all[Repo1], expectedOwnerSID: ownerSID, expectedIsActive: true);
+ }
+
+ [TestCase]
+ public void TryRegisterRepo_DuplicateActiveSameOwner_DoesNotRewrite()
+ {
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+ string ownerSID = Guid.NewGuid().ToString();
+ registry.TryRegisterRepo(Repo1, ownerSID, out _).ShouldBeTrue();
+
+ string contentBefore = fs.ReadAllText(Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName));
+ registry.TryRegisterRepo(Repo1, ownerSID, out _).ShouldBeTrue();
+ string contentAfter = fs.ReadAllText(Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName));
+
+ // No semantic change → no rewrite. Important for caller patterns
+ // that re-register on every mount; we don't want a writer storm.
+ contentAfter.ShouldEqual(contentBefore);
+ }
+
+ [TestCase]
+ public void TryRegisterRepo_ReactivatesAfterDeactivate()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+ string ownerSID = Guid.NewGuid().ToString();
+
+ registry.TryRegisterRepo(Repo1, ownerSID, out _).ShouldBeTrue();
+ registry.TryDeactivateRepo(Repo1, out _).ShouldBeTrue();
+ registry.TryRegisterRepo(Repo1, ownerSID, out _).ShouldBeTrue();
+
+ Dictionary all = registry.ReadRegistry();
+ VerifyEntry(all[Repo1], expectedOwnerSID: ownerSID, expectedIsActive: true);
+ }
+
+ [TestCase]
+ public void TryRegisterRepo_NewOwnerSidIsPersisted()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+ string ownerA = Guid.NewGuid().ToString();
+ string ownerB = Guid.NewGuid().ToString();
+
+ registry.TryRegisterRepo(Repo1, ownerA, out _).ShouldBeTrue();
+ registry.TryRegisterRepo(Repo1, ownerB, out _).ShouldBeTrue();
+
+ Dictionary all = registry.ReadRegistry();
+ VerifyEntry(all[Repo1], expectedOwnerSID: ownerB, expectedIsActive: true);
+ }
+
+ [TestCase]
+ public void TryDeactivateRepo_NonExistent_ReturnsFalseWithError()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+
+ registry.TryDeactivateRepo(Repo1, out string error).ShouldBeFalse();
+ string.IsNullOrEmpty(error).ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void TryDeactivateRepo_AlreadyInactive_StillReturnsTrue()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+ string ownerSID = Guid.NewGuid().ToString();
+
+ registry.TryRegisterRepo(Repo1, ownerSID, out _).ShouldBeTrue();
+ registry.TryDeactivateRepo(Repo1, out _).ShouldBeTrue();
+ // Second deactivate on an already-inactive entry is a no-op success
+ registry.TryDeactivateRepo(Repo1, out _).ShouldBeTrue();
+ }
+
+ [TestCase]
+ public void TryRemoveRepo_RemovesEntryEntirely()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+ string ownerSID = Guid.NewGuid().ToString();
+
+ registry.TryRegisterRepo(Repo1, ownerSID, out _).ShouldBeTrue();
+ registry.TryRemoveRepo(Repo1, out _).ShouldBeTrue();
+
+ Dictionary all = registry.ReadRegistry();
+ all.Count.ShouldEqual(0);
+ }
+
+ [TestCase]
+ public void TryRemoveRepo_NonExistent_ReturnsFalse()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+ registry.TryRemoveRepo(Repo1, out string error).ShouldBeFalse();
+ string.IsNullOrEmpty(error).ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void TryGetActiveRepos_FiltersInactive()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+ string ownerSID = Guid.NewGuid().ToString();
+
+ registry.TryRegisterRepo(Repo1, ownerSID, out _).ShouldBeTrue();
+ registry.TryRegisterRepo(Repo2, ownerSID, out _).ShouldBeTrue();
+ registry.TryRegisterRepo(Repo3, ownerSID, out _).ShouldBeTrue();
+ registry.TryDeactivateRepo(Repo2, out _).ShouldBeTrue();
+
+ registry.TryGetActiveRepos(out List active, out _).ShouldBeTrue();
+ active.Count.ShouldEqual(2);
+ active.Any(r => r.EnlistmentRoot.Equals(Repo1)).ShouldBeTrue();
+ active.Any(r => r.EnlistmentRoot.Equals(Repo3)).ShouldBeTrue();
+ active.Any(r => r.EnlistmentRoot.Equals(Repo2)).ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void TryGetActiveRepos_EmptyRegistry_ReturnsEmptyList()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+
+ registry.TryGetActiveRepos(out List active, out string error).ShouldBeTrue(error);
+ active.Count.ShouldEqual(0);
+ }
+
+ [TestCase]
+ public void ReadRegistry_NoRegistryFile_ReturnsEmpty()
+ {
+ (LocalRepoRegistry registry, _) = this.CreateRegistry();
+ registry.ReadRegistry().Count.ShouldEqual(0);
+ }
+
+ [TestCase]
+ public void ReadRegistry_HigherVersionOnDisk_ReturnsEmptyAndDoesNotOverwrite()
+ {
+ // Simulate a newer GVFS having written the registry.
+ // We must read as empty AND must NOT overwrite when a subsequent
+ // write happens, so the newer GVFS's data is preserved.
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+ string registryPath = Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName);
+ string futureContent = "99\n{\"EnlistmentRoot\":\"" + Repo1.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"future\",\"IsActive\":true}\n";
+ fs.WriteAllText(registryPath, futureContent);
+
+ registry.ReadRegistry().Count.ShouldEqual(0);
+ }
+
+ [TestCase]
+ public void ReadRegistry_MalformedLine_SkippedNotFatal()
+ {
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+ string registryPath = Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName);
+ string contents =
+ "2\n" +
+ "{ this is not valid json }\n" +
+ "{\"EnlistmentRoot\":\"" + Repo1.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"sid\",\"IsActive\":true}\n";
+ fs.WriteAllText(registryPath, contents);
+
+ Dictionary all = registry.ReadRegistry();
+ all.Count.ShouldEqual(1);
+ all[Repo1].OwnerSID.ShouldEqual("sid");
+ }
+
+ [TestCase]
+ public void ReadRegistry_BlankLinesIgnored()
+ {
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+ string registryPath = Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName);
+ string contents =
+ "2\n" +
+ "\n" +
+ "{\"EnlistmentRoot\":\"" + Repo1.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"sid\",\"IsActive\":true}\n" +
+ "\n";
+ fs.WriteAllText(registryPath, contents);
+
+ registry.ReadRegistry().Count.ShouldEqual(1);
+ }
+
+ [TestCase]
+ public void ReadRegistry_OnDiskFormatMatchesServiceRegistry()
+ {
+ // The on-disk format MUST be wire-compatible with
+ // GVFS.Service.RepoRegistry: first line is the version
+ // (a bare integer); subsequent lines are JSON objects with
+ // EnlistmentRoot / OwnerSID / IsActive fields.
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+ string sid = Guid.NewGuid().ToString();
+ registry.TryRegisterRepo(Repo1, sid, out _).ShouldBeTrue();
+
+ string raw = fs.ReadAllText(Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName));
+ string[] lines = raw.Replace("\r\n", "\n").TrimEnd('\n').Split('\n');
+
+ // Version line
+ lines[0].ShouldEqual(LocalRepoRegistry.RegistryVersion.ToString());
+
+ // Entry line is JSON with the three required fields
+ lines.Length.ShouldEqual(2);
+ lines[1].Contains("\"EnlistmentRoot\"").ShouldBeTrue();
+ lines[1].Contains("\"OwnerSID\"").ShouldBeTrue();
+ lines[1].Contains("\"IsActive\"").ShouldBeTrue();
+ lines[1].Contains(sid).ShouldBeTrue();
+ }
+
+ [TestCase]
+ public void RegisterAfterRead_PreservesOtherEntriesWrittenByAnotherProcess()
+ {
+ // Simulate another process having written an entry between
+ // construction and our register call: we read fresh on each
+ // operation, so the other entry must survive.
+ (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry();
+ string sid = Guid.NewGuid().ToString();
+
+ string contents =
+ "2\n" +
+ "{\"EnlistmentRoot\":\"" + Repo2.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"" + sid + "\",\"IsActive\":true}\n";
+ fs.WriteAllText(Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName), contents);
+
+ registry.TryRegisterRepo(Repo1, sid, out _).ShouldBeTrue();
+
+ Dictionary all = registry.ReadRegistry();
+ all.Count.ShouldEqual(2);
+ all.ContainsKey(Repo1).ShouldBeTrue();
+ all.ContainsKey(Repo2).ShouldBeTrue();
+ }
+
+ [TestCase]
+ public void Constructor_NullArgs_Throws()
+ {
+ MockFileSystem fs = new MockFileSystem(new MockDirectory(DataLocation, null, null));
+ Assert.Throws(() => new LocalRepoRegistry(null, fs, DataLocation));
+ Assert.Throws(() => new LocalRepoRegistry(new MockTracer(), null, DataLocation));
+ Assert.Throws(() => new LocalRepoRegistry(new MockTracer(), fs, null));
+ }
+
+ [TestCase]
+ public void LocalRepoRegistration_JsonRoundTrip()
+ {
+ LocalRepoRegistration original = new LocalRepoRegistration("path", "sid") { IsActive = false };
+ string json = original.ToJson();
+ LocalRepoRegistration roundTripped = LocalRepoRegistration.FromJson(json);
+
+ roundTripped.EnlistmentRoot.ShouldEqual(original.EnlistmentRoot);
+ roundTripped.OwnerSID.ShouldEqual(original.OwnerSID);
+ roundTripped.IsActive.ShouldEqual(original.IsActive);
+ }
+
+ private (LocalRepoRegistry registry, MockFileSystem fs) CreateRegistry()
+ {
+ MockFileSystem fs = new MockFileSystem(new MockDirectory(DataLocation, null, null));
+ LocalRepoRegistry registry = new LocalRepoRegistry(new MockTracer(), fs, DataLocation);
+ return (registry, fs);
+ }
+
+ private static void VerifyEntry(LocalRepoRegistration entry, string expectedOwnerSID, bool expectedIsActive)
+ {
+ entry.ShouldNotBeNull();
+ entry.OwnerSID.ShouldEqual(expectedOwnerSID);
+ entry.IsActive.ShouldEqual(expectedIsActive);
+ }
+ }
+}
diff --git a/GVFS/GVFS.UnitTests/Common/LogonTaskRegistrationTests.cs b/GVFS/GVFS.UnitTests/Common/LogonTaskRegistrationTests.cs
new file mode 100644
index 000000000..04531b2c3
--- /dev/null
+++ b/GVFS/GVFS.UnitTests/Common/LogonTaskRegistrationTests.cs
@@ -0,0 +1,288 @@
+using GVFS.Common;
+using GVFS.Tests.Should;
+using GVFS.UnitTests.Mock.Common;
+using NUnit.Framework;
+using System;
+using System.Collections.Generic;
+
+namespace GVFS.UnitTests.Common
+{
+ [TestFixture]
+ public class LogonTaskRegistrationTests
+ {
+ private const string TestGvfsPath = @"C:\Users\test\AppData\Local\Programs\GVFS\Current\gvfs.exe";
+ private const string TestUserSid = "S-1-5-21-1111-2222-3333-1001";
+ private const string OtherUserSid = "S-1-5-21-1111-2222-3333-1002";
+
+ [TestCase]
+ public void TemplateHash_IsStableAcrossCalls()
+ {
+ // Same template content => same hash, every time.
+ LogonTaskRegistration.TemplateHash.ShouldEqual(LogonTaskRegistration.TemplateHash);
+ // Full SHA-256 hex = 64 chars
+ LogonTaskRegistration.TemplateHash.Length.ShouldEqual(64);
+ }
+
+ [TestCase]
+ public void BuildTaskXml_SubstitutesAllPlaceholders()
+ {
+ string xml = LogonTaskRegistration.BuildTaskXml(TestGvfsPath, TestUserSid);
+
+ xml.Contains(LogonTaskRegistration.GvfsPathPlaceholder).ShouldBeFalse("no GVFS_PATH placeholder should remain");
+ xml.Contains(LogonTaskRegistration.UserIdPlaceholder).ShouldBeFalse("no USER_ID placeholder should remain");
+ xml.Contains(LogonTaskRegistration.TaskHashPlaceholder).ShouldBeFalse("no TASK_HASH placeholder should remain");
+
+ xml.Contains(TestGvfsPath).ShouldBeTrue("gvfs.exe path should appear in the XML");
+ xml.Contains(TestUserSid).ShouldBeTrue("user SID should appear in the XML");
+ xml.Contains(LogonTaskRegistration.TemplateHash).ShouldBeTrue("template hash should appear in the XML");
+ }
+
+ [TestCase]
+ public void BuildTaskXml_ProducesMountAllArguments()
+ {
+ string xml = LogonTaskRegistration.BuildTaskXml(TestGvfsPath, TestUserSid);
+ // The task action runs `gvfs.exe service --mount-all`.
+ xml.Contains("service --mount-all").ShouldBeTrue();
+ }
+
+ [TestCase]
+ public void BuildTaskXml_NullOrEmptyArgsThrow()
+ {
+ // Assert.Catch accepts derived types (ArgumentNullException is
+ // also raised by ThrowIfNullOrEmpty for null inputs).
+ Assert.Catch(() => LogonTaskRegistration.BuildTaskXml(null, TestUserSid));
+ Assert.Catch(() => LogonTaskRegistration.BuildTaskXml("", TestUserSid));
+ Assert.Catch(() => LogonTaskRegistration.BuildTaskXml(TestGvfsPath, null));
+ Assert.Catch(() => LogonTaskRegistration.BuildTaskXml(TestGvfsPath, ""));
+ }
+
+ [TestCase]
+ public void TryExtractHashMarker_FindsMarkerInDescription()
+ {
+ string description = "Mounts the user's enlistments at logon. [gvfs-logon-task-hash=DEADBEEF12345678ABCDEF0123456789FEDCBA9876543210CAFEBABE12345678]";
+ LogonTaskRegistration.TryExtractHashMarker(description, out string hash).ShouldBeTrue();
+ hash.ShouldEqual("DEADBEEF12345678ABCDEF0123456789FEDCBA9876543210CAFEBABE12345678");
+ }
+
+ [TestCase]
+ public void TryExtractHashMarker_NoMarker_ReturnsFalse()
+ {
+ LogonTaskRegistration.TryExtractHashMarker("Just a plain description.", out string hash).ShouldBeFalse();
+ hash.ShouldBeNull();
+ }
+
+ [TestCase]
+ public void TryExtractHashMarker_EmptyOrNull_ReturnsFalse()
+ {
+ LogonTaskRegistration.TryExtractHashMarker(null, out _).ShouldBeFalse();
+ LogonTaskRegistration.TryExtractHashMarker("", out _).ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void TryExtractHashMarker_MalformedMarker_ReturnsFalse()
+ {
+ // Opening prefix but no closing bracket
+ LogonTaskRegistration.TryExtractHashMarker("foo [gvfs-logon-task-hash=ABCD no close", out _).ShouldBeFalse();
+ // Closing bracket before any content
+ LogonTaskRegistration.TryExtractHashMarker("foo [gvfs-logon-task-hash=]", out _).ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void TryExtractHashMarker_FindsMarkerInGeneratedXml()
+ {
+ // Round-trip: the XML produced by BuildTaskXml must contain the
+ // template hash, and TryExtractHashMarker must recover it.
+ string xml = LogonTaskRegistration.BuildTaskXml(TestGvfsPath, TestUserSid);
+ LogonTaskRegistration.TryExtractHashMarker(xml, out string hash).ShouldBeTrue();
+ hash.ShouldEqual(LogonTaskRegistration.TemplateHash);
+ }
+
+ [TestCase]
+ public void IsCurrent_NoRegisteredTask_ReturnsFalse()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ // Default mock has no registered tasks => TryQueryXml fails.
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+ reg.IsCurrent().ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void IsCurrent_MatchingHash_ReturnsTrue()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath] =
+ LogonTaskRegistration.BuildTaskXml(TestGvfsPath, TestUserSid);
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+ reg.IsCurrent().ShouldBeTrue();
+ }
+
+ [TestCase]
+ public void IsCurrent_DifferentHash_ReturnsFalse()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ // Simulate a task registered by a previous template version
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath] =
+ "Old. [gvfs-logon-task-hash=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA]";
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+ reg.IsCurrent().ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void IsCurrent_TaskExistsButNoMarker_ReturnsFalse()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath] =
+ "Manually edited, no marker.";
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+ reg.IsCurrent().ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void TryRegisterOrUpdate_CreatesNewTask()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+
+ reg.TryRegisterOrUpdate(TestGvfsPath, TestUserSid, out string error).ShouldBeTrue(error);
+
+ invoker.RegisteredTasks.ContainsKey(LogonTaskRegistration.FullTaskPath).ShouldBeTrue();
+ invoker.RegisterCallCount.ShouldEqual(1);
+ }
+
+ [TestCase]
+ public void TryRegisterOrUpdate_AlreadyCurrentSameArgs_NoRewrite()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath] =
+ LogonTaskRegistration.BuildTaskXml(TestGvfsPath, TestUserSid);
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+
+ reg.TryRegisterOrUpdate(TestGvfsPath, TestUserSid, out _).ShouldBeTrue();
+ invoker.RegisterCallCount.ShouldEqual(0);
+ }
+
+ [TestCase]
+ public void TryRegisterOrUpdate_CurrentHashButDifferentUser_Reregisters()
+ {
+ // Same template hash, but task is bound to a different user SID
+ // (e.g. someone else's per-user task left behind). We must
+ // re-register with the requesting user's SID.
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath] =
+ LogonTaskRegistration.BuildTaskXml(TestGvfsPath, OtherUserSid);
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+
+ reg.TryRegisterOrUpdate(TestGvfsPath, TestUserSid, out _).ShouldBeTrue();
+ invoker.RegisterCallCount.ShouldEqual(1);
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath].Contains(TestUserSid).ShouldBeTrue();
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath].Contains(OtherUserSid).ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void TryRegisterOrUpdate_CurrentHashButDifferentGvfsPath_Reregisters()
+ {
+ // GVFS install moved (junction swap). Template hash unchanged
+ // but the Command path in the task points at the old location.
+ // We must rewrite.
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ string oldPath = @"C:\Users\test\AppData\Local\Programs\GVFS\Versions\0.1.0\gvfs.exe";
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath] =
+ LogonTaskRegistration.BuildTaskXml(oldPath, TestUserSid);
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+
+ reg.TryRegisterOrUpdate(TestGvfsPath, TestUserSid, out _).ShouldBeTrue();
+ invoker.RegisterCallCount.ShouldEqual(1);
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath].Contains(TestGvfsPath).ShouldBeTrue();
+ }
+
+ [TestCase]
+ public void TryRegisterOrUpdate_InvokerFails_SurfacesError()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ invoker.NextRegisterError = "Permission denied";
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+
+ reg.TryRegisterOrUpdate(TestGvfsPath, TestUserSid, out string error).ShouldBeFalse();
+ error.ShouldEqual("Permission denied");
+ }
+
+ [TestCase]
+ public void TryUnregister_DelegatesToInvoker()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath] =
+ LogonTaskRegistration.BuildTaskXml(TestGvfsPath, TestUserSid);
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+
+ reg.TryUnregister(out string error).ShouldBeTrue(error);
+ invoker.RegisteredTasks.ContainsKey(LogonTaskRegistration.FullTaskPath).ShouldBeFalse();
+ }
+
+ [TestCase]
+ public void TryUnregister_TaskNotRegistered_StillReturnsTrue()
+ {
+ // Idempotent: unregister of nothing is a success.
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker);
+
+ reg.TryUnregister(out _).ShouldBeTrue();
+ }
+
+ [TestCase]
+ public void Constructor_NullArgs_Throws()
+ {
+ MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker();
+ Assert.Throws(() => new LogonTaskRegistration(null, invoker));
+ Assert.Throws(() => new LogonTaskRegistration(new MockTracer(), null));
+ }
+
+ private sealed class MockScheduledTaskInvoker : IScheduledTaskInvoker
+ {
+ public Dictionary RegisteredTasks { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ public string NextRegisterError { get; set; }
+ public string NextUnregisterError { get; set; }
+ public int RegisterCallCount { get; private set; }
+
+ public bool TryRegisterFromXml(string taskPath, string xml, out string errorMessage)
+ {
+ this.RegisterCallCount++;
+
+ if (!string.IsNullOrEmpty(this.NextRegisterError))
+ {
+ errorMessage = this.NextRegisterError;
+ return false;
+ }
+
+ this.RegisteredTasks[taskPath] = xml;
+ errorMessage = string.Empty;
+ return true;
+ }
+
+ public bool TryQueryXml(string taskPath, out string xml, out string errorMessage)
+ {
+ if (this.RegisteredTasks.TryGetValue(taskPath, out xml))
+ {
+ errorMessage = string.Empty;
+ return true;
+ }
+
+ xml = null;
+ errorMessage = "Task not found";
+ return false;
+ }
+
+ public bool TryUnregister(string taskPath, out string errorMessage)
+ {
+ if (!string.IsNullOrEmpty(this.NextUnregisterError))
+ {
+ errorMessage = this.NextUnregisterError;
+ return false;
+ }
+
+ this.RegisteredTasks.Remove(taskPath);
+ errorMessage = string.Empty;
+ return true;
+ }
+ }
+ }
+}
diff --git a/GVFS/GVFS/CommandLine/GVFSVerb.cs b/GVFS/GVFS/CommandLine/GVFSVerb.cs
index 1013a5f41..8be7f971b 100644
--- a/GVFS/GVFS/CommandLine/GVFSVerb.cs
+++ b/GVFS/GVFS/CommandLine/GVFSVerb.cs
@@ -16,8 +16,6 @@ namespace GVFS.CommandLine
{
public abstract class GVFSVerb
{
- protected const string StartServiceInstructions = "Run 'sc start GVFS.Service' from an elevated command prompt to ensure it is running.";
-
private readonly bool validateOriginURL;
public GVFSVerb(bool validateOrigin = true)
@@ -570,8 +568,23 @@ protected bool TryEnableAndAttachPrjFltThroughService(string enlistmentRoot, out
{
if (!client.Connect())
{
- errorMessage = "GVFS.Service is not responding. " + GVFSVerb.StartServiceInstructions;
- return false;
+ // Service not installed or not running (typical for the
+ // user-level install model). In that model, the boot-time
+ // EnableProjFSOnAllDrives scheduled task is responsible
+ // for ensuring PrjFlt is enabled and attached to every
+ // NTFS/ReFS volume - and re-attaching on volume-mount
+ // events for newly-attached drives. So when the service
+ // pipe is unavailable, the right answer is to return
+ // success silently and let the caller proceed; if PrjFlt
+ // is actually missing at mount time, the mount-process
+ // filter check will surface a clearer "filter not
+ // attached" error downstream.
+ //
+ // We deliberately do NOT fall back on BrokenPipeException
+ // below: that indicates the service IS present but
+ // crashed mid-request, and silently succeeding would
+ // mask a real bug.
+ return true;
}
try
diff --git a/GVFS/GVFS/CommandLine/MountVerb.cs b/GVFS/GVFS/CommandLine/MountVerb.cs
index 37e5f1041..03f083149 100644
--- a/GVFS/GVFS/CommandLine/MountVerb.cs
+++ b/GVFS/GVFS/CommandLine/MountVerb.cs
@@ -235,7 +235,7 @@ protected override void Execute(GVFSEnlistment enlistment)
tracer.RelatedInfo($"{nameof(this.Execute)}: Registering for automount");
if (this.ShowStatusWhileRunning(
- () => { return this.RegisterMount(enlistment, out errorMessage); },
+ () => { return this.RegisterMount(enlistment, tracer, out errorMessage); },
"Registering for automount"))
{
tracer.RelatedInfo($"{nameof(this.Execute)}: Registered for automount");
@@ -280,26 +280,39 @@ private bool TryMount(ITracer tracer, GVFSEnlistment enlistment, string mountExe
return GVFSEnlistment.WaitUntilMounted(tracer, enlistment.NamedPipeName, enlistment.WorkingDirectoryRoot, this.Unattended, out errorMessage);
}
- private bool RegisterMount(GVFSEnlistment enlistment, out string errorMessage)
+ private bool RegisterMount(GVFSEnlistment enlistment, ITracer tracer, out string errorMessage)
{
errorMessage = string.Empty;
- NamedPipeMessages.RegisterRepoRequest request = new NamedPipeMessages.RegisterRepoRequest();
-
// Worktree mounts register with their worktree path so they can be
// listed and unregistered independently of the primary enlistment.
- request.EnlistmentRoot = enlistment.IsWorktree
+ string enlistmentRoot = enlistment.IsWorktree
? enlistment.WorkingDirectoryRoot
: enlistment.PrimaryEnlistmentRoot;
+ string ownerSID = GVFSPlatform.Instance.GetCurrentUser();
- request.OwnerSID = GVFSPlatform.Instance.GetCurrentUser();
+ NamedPipeMessages.RegisterRepoRequest request = new NamedPipeMessages.RegisterRepoRequest();
+ request.EnlistmentRoot = enlistmentRoot;
+ request.OwnerSID = ownerSID;
using (NamedPipeClient client = new NamedPipeClient(this.ServicePipeName))
{
if (!client.Connect())
{
- errorMessage = "Unable to register repo because GVFS.Service is not responding.";
- return false;
+ // Service not installed or not running (typical for the
+ // user-level install model). Fall back to writing the
+ // registry file directly. The service writes to the same
+ // file at the same path when it IS running, so the two
+ // models can co-exist or be migrated between without any
+ // data being lost. We only reach this fallback when the
+ // pipe doesn't exist at all - if the service is present
+ // but mid-request crashes, that surfaces as
+ // BrokenPipeException below and we deliberately do NOT
+ // fall back (the service remains the authoritative
+ // writer in that case).
+ tracer.RelatedInfo($"{nameof(this.RegisterMount)}: GVFS.Service pipe unavailable; falling back to LocalRepoRegistry");
+ LocalRepoRegistry localRegistry = LocalRepoRegistry.CreateForCurrentPlatform(tracer);
+ return localRegistry.TryRegisterRepo(enlistmentRoot, ownerSID, out errorMessage);
}
try
diff --git a/GVFS/GVFS/CommandLine/ServiceVerb.cs b/GVFS/GVFS/CommandLine/ServiceVerb.cs
index 842521fa7..3fc3eecc0 100644
--- a/GVFS/GVFS/CommandLine/ServiceVerb.cs
+++ b/GVFS/GVFS/CommandLine/ServiceVerb.cs
@@ -1,6 +1,7 @@
using GVFS.Common;
using GVFS.Common.FileSystem;
using GVFS.Common.NamedPipes;
+using GVFS.Common.Tracing;
using System;
using System.Collections.Generic;
using System.IO;
@@ -156,8 +157,18 @@ private bool TryGetRepoList(out List repoList, out string errorMessage)
{
if (!client.Connect())
{
- errorMessage = "GVFS.Service is not responding.";
- return false;
+ // Service not installed or not running (typical for the
+ // user-level install model). Fall back to reading the
+ // registry file directly. See the matching comment in
+ // MountVerb.RegisterMount for the design rationale.
+ LocalRepoRegistry localRegistry = LocalRepoRegistry.CreateForCurrentPlatform(NullTracer.Instance);
+ if (!localRegistry.TryGetActiveRepos(out List activeRepos, out errorMessage))
+ {
+ return false;
+ }
+
+ repoList = activeRepos.Select(r => r.EnlistmentRoot).ToList();
+ return true;
}
try
diff --git a/GVFS/GVFS/CommandLine/UnmountVerb.cs b/GVFS/GVFS/CommandLine/UnmountVerb.cs
index 0de886a50..494ff5e81 100644
--- a/GVFS/GVFS/CommandLine/UnmountVerb.cs
+++ b/GVFS/GVFS/CommandLine/UnmountVerb.cs
@@ -1,5 +1,6 @@
using GVFS.Common;
using GVFS.Common.NamedPipes;
+using GVFS.Common.Tracing;
using System;
using System.Diagnostics;
@@ -204,7 +205,30 @@ private bool UnregisterRepo(string rootPath, out string errorMessage)
{
if (!client.Connect())
{
- errorMessage = "Unable to unregister repo because GVFS.Service is not responding. " + GVFSVerb.StartServiceInstructions;
+ // Service not installed or not running (typical for the
+ // user-level install model). Fall back to writing the
+ // registry file directly. See the matching comment in
+ // MountVerb.RegisterMount for the design rationale and
+ // the deliberate decision NOT to fall back on
+ // BrokenPipeException (the service-broken-mid-request
+ // case).
+ LocalRepoRegistry localRegistry = LocalRepoRegistry.CreateForCurrentPlatform(NullTracer.Instance);
+ if (localRegistry.TryDeactivateRepo(rootPath, out errorMessage))
+ {
+ return true;
+ }
+
+ // TryDeactivateRepo returns false for two reasons:
+ // 1. Entry not found — benign, nothing to unregister.
+ // 2. I/O error — real failure, propagate to caller.
+ // Distinguish by checking for the "non-existent" message
+ // that TryDeactivateRepo produces for case 1.
+ if (errorMessage != null && errorMessage.Contains("non-existent", StringComparison.OrdinalIgnoreCase))
+ {
+ errorMessage = string.Empty;
+ return true;
+ }
+
return false;
}
diff --git a/scripts/projfs-attach/build-task-xml.ps1 b/scripts/projfs-attach/build-task-xml.ps1
new file mode 100644
index 000000000..bbd8c4fdb
--- /dev/null
+++ b/scripts/projfs-attach/build-task-xml.ps1
@@ -0,0 +1,103 @@
+# build-task-xml.ps1
+#
+# Produces the final EnableProjFSOnAllDrives scheduled task XML by
+# base64-encoding enable-projfs-on-all-drives.ps1 and substituting it
+# (along with a content hash) into enable-projfs-on-all-drives-task.xml.template.
+#
+# Inputs and output are passed by parameter so this script is callable
+# from layout.bat, MSBuild, or directly during development.
+#
+# The hash embedded in the task Description (via __TASK_HASH__) is
+# SHA-256 over the un-encoded inputs (template + script body, in that
+# order, separated by a NUL byte). Stable across re-runs with
+# unchanged inputs; changes the moment either input's content
+# changes. This is what the installer's drift detection compares
+# against the registered task's Description marker to decide whether
+# re-registration is needed.
+#
+# Output XML is UTF-16 LE with BOM (required by Task Scheduler's
+# /XML import).
+
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$ScriptPath,
+
+ [Parameter(Mandatory = $true)]
+ [string]$TemplatePath,
+
+ [Parameter(Mandatory = $true)]
+ [string]$OutputPath
+)
+
+$ErrorActionPreference = 'Stop'
+
+if (-not (Test-Path $ScriptPath)) { throw "Script not found: $ScriptPath" }
+if (-not (Test-Path $TemplatePath)) { throw "Template not found: $TemplatePath" }
+
+# Read raw bytes so the hash and the base64 are computed over exactly
+# what's on disk, regardless of line-ending or BOM conventions.
+$scriptBytes = [System.IO.File]::ReadAllBytes($ScriptPath)
+
+# Read the template as text (UTF-8 or UTF-16 with BOM both work for
+# Get-Content; the template is checked in as UTF-16 to match the XML
+# encoding declaration but we re-emit as UTF-16 with BOM either way).
+$templateText = [System.IO.File]::ReadAllText($TemplatePath)
+$templateBytes = [System.Text.Encoding]::UTF8.GetBytes($templateText)
+
+# PowerShell -EncodedCommand expects UTF-16 LE bytes, base64 encoded.
+$scriptUtf16 = [System.Text.Encoding]::Unicode.GetString($scriptBytes)
+# If the source script was UTF-8 (typical for files checked into git),
+# the line above produces garbage. Detect by checking for a UTF-8 BOM
+# or by attempting a UTF-8 decode and re-encoding to UTF-16.
+$scriptText =
+ if ($scriptBytes.Length -ge 3 -and $scriptBytes[0] -eq 0xEF -and $scriptBytes[1] -eq 0xBB -and $scriptBytes[2] -eq 0xBF) {
+ [System.Text.Encoding]::UTF8.GetString($scriptBytes, 3, $scriptBytes.Length - 3)
+ }
+ elseif ($scriptBytes.Length -ge 2 -and $scriptBytes[0] -eq 0xFF -and $scriptBytes[1] -eq 0xFE) {
+ [System.Text.Encoding]::Unicode.GetString($scriptBytes, 2, $scriptBytes.Length - 2)
+ }
+ else {
+ # Assume UTF-8 without BOM (git's default for text)
+ [System.Text.Encoding]::UTF8.GetString($scriptBytes)
+ }
+
+$scriptUtf16Bytes = [System.Text.Encoding]::Unicode.GetBytes($scriptText)
+$encodedCommand = [System.Convert]::ToBase64String($scriptUtf16Bytes)
+
+# Hash inputs: template bytes + NUL + script bytes (the raw bytes,
+# not re-encoded, so the hash is reproducible even if the encoding
+# detection logic is changed in a future revision of this script).
+$hasher = [System.Security.Cryptography.SHA256]::Create()
+try {
+ $combined = New-Object byte[] ($templateBytes.Length + 1 + $scriptBytes.Length)
+ [System.Buffer]::BlockCopy($templateBytes, 0, $combined, 0, $templateBytes.Length)
+ $combined[$templateBytes.Length] = 0
+ [System.Buffer]::BlockCopy($scriptBytes, 0, $combined, $templateBytes.Length + 1, $scriptBytes.Length)
+ $hashBytes = $hasher.ComputeHash($combined)
+ $hashHex = ([System.BitConverter]::ToString($hashBytes)).Replace('-', '')
+}
+finally {
+ $hasher.Dispose()
+}
+
+# Substitute placeholders. Order matters only because __SCRIPT_BASE64__
+# could in theory contain the __TASK_HASH__ literal -- highly unlikely
+# but trivially defended by substituting hash first.
+$finalXml = $templateText.
+ Replace('__TASK_HASH__', $hashHex).
+ Replace('__SCRIPT_BASE64__', $encodedCommand)
+
+# Ensure output directory exists.
+$outputDir = Split-Path -Parent $OutputPath
+if ($outputDir -and -not (Test-Path $outputDir)) {
+ New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
+}
+
+# Write UTF-16 LE with BOM (required by schtasks /Create /XML).
+[System.IO.File]::WriteAllText(
+ $OutputPath,
+ $finalXml,
+ (New-Object System.Text.UnicodeEncoding $false, $true))
+
+Write-Host "Wrote $OutputPath ($([System.IO.File]::ReadAllBytes($OutputPath).Length) bytes, hash=$hashHex)"
diff --git a/scripts/projfs-attach/enable-projfs-on-all-drives-task.xml.template b/scripts/projfs-attach/enable-projfs-on-all-drives-task.xml.template
new file mode 100644
index 000000000..9a7b735cd
--- /dev/null
+++ b/scripts/projfs-attach/enable-projfs-on-all-drives-task.xml.template
@@ -0,0 +1,84 @@
+
+
+
+
+ Microsoft\GVFS
+ 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__]
+ \GVFS\EnableProjFSOnAllDrives
+
+
+
+ true
+
+
+ true
+ <QueryList><Query Id="0" Path="Microsoft-Windows-Partition/Diagnostic"><Select Path="Microsoft-Windows-Partition/Diagnostic">*[System[Provider[@Name='Microsoft-Windows-Partition'] and (EventID=1006)]]</Select></Query></QueryList>
+
+
+
+
+ S-1-5-18
+ HighestAvailable
+
+
+
+ Queue
+ false
+ false
+ true
+ true
+ false
+
+ false
+ false
+
+ true
+ true
+ false
+ false
+ false
+ PT5M
+ 5
+
+
+
+ powershell.exe
+ -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand __SCRIPT_BASE64__
+
+
+
diff --git a/scripts/projfs-attach/enable-projfs-on-all-drives.ps1 b/scripts/projfs-attach/enable-projfs-on-all-drives.ps1
new file mode 100644
index 000000000..c1c6bf4a5
--- /dev/null
+++ b/scripts/projfs-attach/enable-projfs-on-all-drives.ps1
@@ -0,0 +1,135 @@
+# enable-projfs-on-all-drives.ps1
+#
+# Source of truth for the EnableProjFSOnAllDrives scheduled task body.
+# This script is NOT deployed to disk in the user-mode install model;
+# instead, build-task-xml.ps1 base64-encodes the contents and embeds
+# them in the task XML's as -EncodedCommand. The
+# task then runs as: powershell.exe -EncodedCommand .
+#
+# Runs as LocalSystem (configured by the scheduled task) so it has
+# SE_LOAD_DRIVER_PRIVILEGE for FilterAttach and HKLM write access
+# for the Dev Drive allowed-filters registry.
+#
+# Two invocation modes (selected by the task's triggers):
+# 1. AT_SYSTEM_START - no DriveLetter argument. Reconciles the Dev
+# Drive allow-list (machine-wide) and attaches prjflt to every
+# eligible NTFS/ReFS volume. FilterAttach is not persistent
+# across reboots, so this is required every boot.
+# 2. Event 1006 from Microsoft-Windows-Partition/Diagnostic -
+# DriveLetter argument is the drive of the newly-mounted volume.
+# Attaches prjflt to just that one drive. Avoids work on every
+# USB plug-in / VHD mount.
+#
+# Logs to %ProgramData%\GVFS\enable-projfs-on-all-drives.log
+# (HKLM-writable from SYSTEM, persistent across reboots).
+#
+# Idempotent everywhere: fltmc NameCollision is treated as success,
+# fsutil devdrv setFiltersAllowed is a no-op if already set. Safe to
+# run repeatedly.
+
+[CmdletBinding()]
+param(
+ # If provided, only attempt to attach to this single drive letter.
+ # Used by the volume-mount trigger to scope work narrowly. When
+ # absent, all NTFS/ReFS volumes are processed (boot trigger path),
+ # and the Dev Drive allow-list is also reconciled.
+ [string]$DriveLetter
+)
+
+$ErrorActionPreference = 'Stop'
+
+$logDir = Join-Path $env:ProgramData 'GVFS'
+$logPath = Join-Path $logDir 'enable-projfs-on-all-drives.log'
+if (-not (Test-Path $logDir)) {
+ New-Item -ItemType Directory -Path $logDir -Force | Out-Null
+}
+
+function Write-Log([string]$msg) {
+ $line = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $msg"
+ Add-Content -Path $logPath -Value $line -Encoding UTF8
+}
+
+function Set-PrjFltDevDriveAllowed {
+ # Dev Drives consult a machine-wide allow-list at mount time to
+ # decide which minifilters may attach. Without PrjFlt in the list,
+ # GVFS cannot work on Dev Drives even if we call FilterAttach.
+ # Set unconditionally; fsutil is a no-op if already set.
+ try {
+ $out = (& fsutil.exe devdrv setFiltersAllowed PrjFlt 2>&1 | Out-String).Trim()
+ if ($LASTEXITCODE -eq 0) {
+ Write-Log "DevDrive allow-list: PrjFlt allowed (output: $out)"
+ }
+ else {
+ # Non-fatal: on older Windows builds without Dev Drive
+ # support, fsutil devdrv may fail. Log and continue.
+ Write-Log "DevDrive allow-list: fsutil exit=$LASTEXITCODE (likely no Dev Drive support on this OS) output=$out"
+ }
+ }
+ catch {
+ Write-Log "DevDrive allow-list: exception (likely no Dev Drive support): $_"
+ }
+}
+
+function Add-PrjFltToVolume([string]$drive) {
+ $output = (& fltmc.exe attach PrjFlt "${drive}:" 2>&1 | Out-String).Trim()
+ $exit = $LASTEXITCODE
+ # NameCollision is success-equivalent: filter is already attached.
+ # Check the output BEFORE the exit code because fltmc returns exit
+ # 1 for NameCollision (despite it being benign).
+ if ($output -match 'instance already exists' -or
+ $output -match 'instance name collision' -or
+ $output -match '0x801f0012') {
+ Write-Log "OK ${drive}: already attached (NameCollision)"
+ return $true
+ }
+ if ($exit -ne 0) {
+ Write-Log "FAIL ${drive}: exit=$exit output=$output"
+ return $false
+ }
+ Write-Log "OK ${drive}: attached (output: $output)"
+ return $true
+}
+
+try {
+ Write-Log "===== enable-projfs-on-all-drives.ps1 starting (DriveLetter='$DriveLetter') ====="
+
+ if ($DriveLetter) {
+ # Single-volume mode (volume-mount trigger)
+ $drive = $DriveLetter.TrimEnd(':').TrimEnd('\').ToUpperInvariant()
+ if ($drive.Length -ne 1) {
+ Write-Log "ERROR: invalid DriveLetter '$DriveLetter' (parsed='$drive')"
+ exit 2
+ }
+ $vol = Get-Volume -DriveLetter $drive -ErrorAction SilentlyContinue
+ if (-not $vol) {
+ Write-Log "SKIP ${drive}: volume not found"
+ exit 0
+ }
+ if ($vol.FileSystemType -notin @('NTFS', 'ReFS')) {
+ Write-Log "SKIP ${drive}: filesystem=$($vol.FileSystemType) (not NTFS/ReFS)"
+ exit 0
+ }
+ Add-PrjFltToVolume $drive | Out-Null
+ }
+ else {
+ # All-volumes mode (boot trigger). Reconcile both the Dev Drive
+ # allow-list AND per-volume attachments. Cheap; idempotent.
+ Set-PrjFltDevDriveAllowed
+ $volumes = Get-Volume |
+ Where-Object {
+ $_.DriveLetter -and
+ $_.FileSystemType -in @('NTFS', 'ReFS')
+ }
+ Write-Log "Found $(@($volumes).Count) eligible volume(s)"
+ foreach ($v in $volumes) {
+ Add-PrjFltToVolume ([string]$v.DriveLetter) | Out-Null
+ }
+ }
+
+ Write-Log "===== enable-projfs-on-all-drives.ps1 done ====="
+}
+catch {
+ Write-Log "EXCEPTION: $_"
+ Write-Log $_.ScriptStackTrace
+ exit 3
+}