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 +}