From 818a5d2a4bce4eac646de9efbfb545c021b997a3 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 12 Jun 2026 08:26:40 -0700 Subject: [PATCH 1/5] LocalRepoRegistry: file-backed registry usable without GVFS.Service Adds LocalRepoRegistration (POCO + source-gen JSON context) and LocalRepoRegistry (instance class) to GVFS.Common. Wire-compatible with the service's on-disk repo-registry format. 18 new unit tests. 836/836 pass (818 prior + 18 new). Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/LocalRepoRegistration.cs | 64 ++++ GVFS/GVFS.Common/LocalRepoRegistry.cs | 348 ++++++++++++++++++ .../Common/LocalRepoRegistryTests.cs | 281 ++++++++++++++ 3 files changed, 693 insertions(+) create mode 100644 GVFS/GVFS.Common/LocalRepoRegistration.cs create mode 100644 GVFS/GVFS.Common/LocalRepoRegistry.cs create mode 100644 GVFS/GVFS.UnitTests/Common/LocalRepoRegistryTests.cs 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.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); + } + } +} From d534637ff9ec9c8371be271657dd5f3359cde67d Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 12 Jun 2026 08:26:52 -0700 Subject: [PATCH 2/5] CLI verbs: fall back to LocalRepoRegistry when GVFS.Service pipe is unavailable MountVerb.RegisterMount, UnmountVerb.UnregisterRepo, and ServiceVerb.TryGetRepoList fall back to LocalRepoRegistry when the service pipe fails to open. Behavior unchanged when service is running. UnmountVerb distinguishes not-found (benign no-op) from I/O errors (propagated to caller) in the fallback path. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- GVFS/GVFS/CommandLine/MountVerb.cs | 29 ++++++++++++++++++++-------- GVFS/GVFS/CommandLine/ServiceVerb.cs | 15 ++++++++++++-- GVFS/GVFS/CommandLine/UnmountVerb.cs | 26 ++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 11 deletions(-) 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; } From 3a25e52faf6b9aaf25fa32ce6bef14b714e4da67 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 12 Jun 2026 08:27:02 -0700 Subject: [PATCH 3/5] GVFSVerb: silent success fallback for PrjFlt attach when service is unavailable Returns true when the service pipe fails to open. In user-level install model the boot-time EnableProjFSOnAllDrives task handles PrjFlt. Removes now-orphaned StartServiceInstructions constant. Assisted-by: Claude Opus 4.8 Signed-off-by: Tyrie Vella --- GVFS/GVFS/CommandLine/GVFSVerb.cs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) 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 From aa26eff6a72503fde5ea018f8f42dda4ee18d347 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 12 Jun 2026 08:27:12 -0700 Subject: [PATCH 4/5] 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 --- GVFS/GVFS.Common/IScheduledTaskInvoker.cs | 37 +++ GVFS/GVFS.Common/LogonTaskRegistration.cs | 255 ++++++++++++++++ .../SchTasksScheduledTaskInvoker.cs | 124 ++++++++ .../Common/LogonTaskRegistrationTests.cs | 288 ++++++++++++++++++ 4 files changed, 704 insertions(+) create mode 100644 GVFS/GVFS.Common/IScheduledTaskInvoker.cs create mode 100644 GVFS/GVFS.Common/LogonTaskRegistration.cs create mode 100644 GVFS/GVFS.Common/SchTasksScheduledTaskInvoker.cs create mode 100644 GVFS/GVFS.UnitTests/Common/LogonTaskRegistrationTests.cs 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/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/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; + } + } + } +} From 38798d2767f9fa281fc5c3a171aea68562b6d908 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 12 Jun 2026 08:27:26 -0700 Subject: [PATCH 5/5] 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 --- scripts/projfs-attach/build-task-xml.ps1 | 103 +++++++++++++ ...ble-projfs-on-all-drives-task.xml.template | 84 +++++++++++ .../enable-projfs-on-all-drives.ps1 | 135 ++++++++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 scripts/projfs-attach/build-task-xml.ps1 create mode 100644 scripts/projfs-attach/enable-projfs-on-all-drives-task.xml.template create mode 100644 scripts/projfs-attach/enable-projfs-on-all-drives.ps1 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 +}