diff --git a/.github/workflows/upgrade-tests.yaml b/.github/workflows/upgrade-tests.yaml index a3e47429c..1f62b61d5 100644 --- a/.github/workflows/upgrade-tests.yaml +++ b/.github/workflows/upgrade-tests.yaml @@ -34,6 +34,9 @@ jobs: - staging-then-clean - mount-safety-deferral - unmount-all-triggers-upgrade + - versioned-fresh-install + - versioned-upgrade + - flat-to-versioned-upgrade fail-fast: false steps: @@ -363,6 +366,140 @@ jobs: Write-Host "PASS: unmount-all triggers staged upgrade via process monitor" } + "versioned-fresh-install" { + Write-Host "=== Scenario: Fresh install with versioned layout ===" + # Install new build directly (no LKG) + Install-GVFS $newInstaller + Assert-ServiceRunning + + # Verify versioned layout structure + $appDir = "C:\Program Files\VFS for Git" + $currentJunction = Join-Path $appDir "Current" + if (-not (Test-Path $currentJunction)) { + throw "Current junction does not exist" + } + # Check it's actually a junction + $item = Get-Item $currentJunction + if ($item.LinkType -ne 'Junction') { + throw "Current is not a junction: LinkType = $($item.LinkType)" + } + Write-Host "Current junction exists: $currentJunction -> $($item.Target)" + + # Verify gvfs.exe exists in versioned path + $versionDirs = Get-ChildItem (Join-Path $appDir "Versions") -Directory + if ($versionDirs.Count -eq 0) { + throw "No version directories found" + } + $versionDir = $versionDirs[0].FullName + $gvfsExe = Join-Path $versionDir "gvfs.exe" + if (-not (Test-Path $gvfsExe)) { + throw "gvfs.exe not found at $gvfsExe" + } + Write-Host "Found gvfs.exe at $gvfsExe" + + # Verify gvfs version works + & "$installDir\gvfs.exe" version 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { throw "gvfs version failed" } + + # Clone and mount test repo + Mount-TestRepo | Write-Host + Unmount-TestRepo + Write-Host "PASS: Versioned fresh install completed" + } + + "versioned-upgrade" { + Write-Host "=== Scenario: Upgrade from versioned to versioned (junction swap) ===" + # First install (versioned) + Install-GVFS $newInstaller + Assert-ServiceRunning + $mountPid = Mount-TestRepo + + # Second install with SAME installer (simulating same-version reinstall) + # This tests that the junction swap is atomic and doesn't break mounts + Install-GVFS $newInstaller + Assert-ServiceRunning + + # Verify mount still works + Assert-MountAlive $mountPid + Write-Host "Mount still alive after reinstall" + + # Unmount and verify we can remount + Unmount-TestRepo + $null = Mount-TestRepo + Unmount-TestRepo + + # Verify version folder structure - should have kept 1 old version (if any) + $versionsDir = "C:\Program Files\VFS for Git\Versions" + $versionDirs = Get-ChildItem $versionsDir -Directory + Write-Host "Version directories after upgrade: $($versionDirs.Count)" + if ($versionDirs.Count -eq 0) { + throw "No version directories found after upgrade" + } + Write-Host "PASS: Versioned-to-versioned upgrade completed" + } + + "flat-to-versioned-upgrade" { + Write-Host "=== Scenario: Upgrade from flat layout (LKG) to versioned layout ===" + # Install LKG (flat layout) + Install-GVFS $lkgInstaller + Assert-ServiceRunning + + # Verify LKG works (clone + mount) + $mountPid = Mount-TestRepo + + # Get LKG version to verify PATH cleanup later + $lkgVersion = & "$installDir\gvfs.exe" version 2>&1 + Write-Host "LKG version: $lkgVersion" + + # Unmount before upgrade + Unmount-TestRepo + + # Upgrade to versioned layout + Install-GVFS $newInstaller + Assert-ServiceRunning + + # Verify Current junction was created + $currentJunction = Join-Path $installDir "Current" + if (-not (Test-Path $currentJunction)) { + throw "Current junction was not created during upgrade" + } + $item = Get-Item $currentJunction + if ($item.LinkType -ne 'Junction') { + throw "Current is not a junction after upgrade" + } + Write-Host "Current junction created: $currentJunction -> $($item.Target)" + + # Verify PATH was updated (should contain {app}\Current, NOT {app} alone) + $pathKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' + $systemPath = (Get-ItemProperty $pathKey).Path + $appDir = "C:\Program Files\VFS for Git" + if ($systemPath -notlike "*$appDir\Current*") { + throw "PATH does not contain versioned entry: $appDir\Current" + } + Write-Host "PATH contains versioned entry: $appDir\Current" + + # Verify old flat PATH entry was cleaned up + # Match standalone {app} entry (not followed by \Current or \Versions) + # Use regex to detect {app} NOT followed by \Current or \Versions + $flatPathPattern = [regex]::Escape($appDir) + '(?!\\(Current|Versions))' + if ($systemPath -match $flatPathPattern) { + Write-Host "WARNING: Flat PATH entry still present: $appDir" + Write-Host "Full PATH: $systemPath" + # Note: This is expected to fail until Fix #3 is applied + } else { + Write-Host "Flat PATH entry successfully removed" + } + + # Verify gvfs version shows new version + $newVersion = & "$installDir\gvfs.exe" version 2>&1 + Write-Host "New version: $newVersion" + + # Remount and verify it works + $null = Mount-TestRepo + Unmount-TestRepo + Write-Host "PASS: Flat-to-versioned upgrade completed" + } + default { throw "Unknown scenario: ${{ matrix.scenario }}" } 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..c9c99dc6d --- /dev/null +++ b/GVFS/GVFS.Common/LocalRepoRegistry.cs @@ -0,0 +1,515 @@ +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. + /// + /// + /// When running as SYSTEM (e.g., CI agents), this method always uses + /// the machine-wide ProgramData location to avoid consuming leaked + /// user-specific environment variables. For normal user accounts, the + /// platform's default path is used (which respects environment variable + /// overrides for user-level installs). + /// + public static LocalRepoRegistry CreateForCurrentPlatform(ITracer tracer) + { + ArgumentNullException.ThrowIfNull(tracer); + + string registryDirectory; + if (IsRunningAsSystem()) + { + // SYSTEM account (CI agents) always uses the machine-wide ProgramData location + // to avoid consuming leaked user-specific environment variables. + registryDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "GVFS", + ServiceDataDirName); + } + else + { + registryDirectory = GVFSPlatform.Instance.GetSecureDataRootForGVFSComponent(ServiceDataDirName); + } + + LocalRepoRegistry registry = new LocalRepoRegistry( + tracer, + new PhysicalFileSystem(), + registryDirectory); + + // Seed from system registry if this is the first time a non-SYSTEM user is + // using the registry and a system-wide registry already exists. + if (!IsRunningAsSystem()) + { + registry.SeedFromSystemRegistryIfNeeded(); + } + + return registry; + } + + /// + /// Returns true if the current process is running under the SYSTEM account + /// (S-1-5-18). CI agents and other automated tasks typically run as SYSTEM. + /// + private static bool IsRunningAsSystem() + { + using (System.Security.Principal.WindowsIdentity identity = System.Security.Principal.WindowsIdentity.GetCurrent()) + { + return identity.IsSystem; + } + } + + /// + /// 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; + } + } + } + + /// + /// Seeds the user registry from the system-wide ProgramData registry if the user + /// registry doesn't exist yet. Filters entries to only those whose repo root directory + /// exists and is accessible to the current user. This supports migration scenarios + /// where repos were previously registered by GVFS.Service (system-wide) and are now + /// transitioning to user-level registration. + /// + /// + /// This method is only intended for non-SYSTEM accounts. SYSTEM processes use the + /// ProgramData registry directly and never seed from it. + /// + public void SeedFromSystemRegistryIfNeeded() + { + string registryPath = Path.Combine(this.registryDirectory, RegistryFileName); + if (this.fileSystem.FileExists(registryPath)) + { + return; // User registry already exists, nothing to seed + } + + string systemRegistryDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "GVFS", + ServiceDataDirName); + string systemRegistryPath = Path.Combine(systemRegistryDir, RegistryFileName); + + if (!this.fileSystem.FileExists(systemRegistryPath)) + { + return; // No system registry to seed from + } + + try + { + // Read system registry entries + Dictionary systemRepos = this.ReadRegistryFrom(systemRegistryDir); + if (systemRepos.Count == 0) + { + return; // System registry is empty, nothing to seed + } + + // Filter to repos whose directories exist and are accessible + Dictionary accessibleRepos = + new Dictionary(GVFSPlatform.Instance.Constants.PathComparer); + + foreach (LocalRepoRegistration repo in systemRepos.Values) + { + if (string.IsNullOrEmpty(repo.EnlistmentRoot)) + { + continue; + } + + try + { + if (this.fileSystem.DirectoryExists(repo.EnlistmentRoot)) + { + accessibleRepos[repo.EnlistmentRoot] = repo; + } + } + catch (Exception) + { + // If we can't check the directory (permission denied, etc.), + // treat it as inaccessible and skip. + } + } + + // Write accessible repos to user registry. Re-check whether the file + // was created by another process (TOCTOU race) and merge if so. + if (accessibleRepos.Count > 0) + { + if (this.fileSystem.FileExists(registryPath)) + { + // Another process created the registry between our check and now. + // Merge: read what's there, add our seeded entries (don't overwrite). + Dictionary existingRepos = this.ReadRegistryFrom(this.registryDirectory); + foreach (KeyValuePair entry in accessibleRepos) + { + if (!existingRepos.ContainsKey(entry.Key)) + { + existingRepos[entry.Key] = entry.Value; + } + } + + this.WriteRegistry(existingRepos); + } + else + { + this.WriteRegistry(accessibleRepos); + } + + EventMetadata metadata = new EventMetadata + { + { "SystemRegistryPath", systemRegistryPath }, + { "UserRegistryPath", registryPath }, + { "SeededCount", accessibleRepos.Count }, + { "TotalSystemCount", systemRepos.Count }, + }; + this.tracer.RelatedEvent( + EventLevel.Informational, + nameof(this.SeedFromSystemRegistryIfNeeded), + metadata); + } + } + catch (Exception e) + { + EventMetadata metadata = new EventMetadata + { + { "SystemRegistryPath", systemRegistryPath }, + { "Exception", e.ToString() }, + }; + this.tracer.RelatedError( + metadata, + $"{nameof(this.SeedFromSystemRegistryIfNeeded)}: Failed to seed from system registry; continuing with empty user registry"); + } + } + + /// + /// 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() + { + return this.ReadRegistryFrom(this.registryDirectory); + } + + /// + /// Reads the registry from the specified directory. Used internally for + /// both the normal registry path and for seeding from the system registry. + /// + private Dictionary ReadRegistryFrom(string registryDirectory) + { + Dictionary allRepos = + new Dictionary(GVFSPlatform.Instance.Constants.PathComparer); + + string registryFilePath = Path.Combine(registryDirectory, RegistryFileName); + + using (Stream stream = this.fileSystem.OpenFileStream( + registryFilePath, + FileMode.OpenOrCreate, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + 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..5565cb0de --- /dev/null +++ b/GVFS/GVFS.Common/LogonTaskRegistration.cs @@ -0,0 +1,244 @@ +using GVFS.Common.Tracing; +using System; +using System.Security.Cryptography; +using System.Text; + +namespace GVFS.Common +{ + /// + /// Registers / updates / unregisters the machine-wide Windows scheduled task + /// that mounts registered GVFS enlistments at logon for each interactive user. 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 all + /// interactive users (GroupId S-1-5-4), runs at user logon with the + /// user's interactive token, and executes gvfs.exe service --mount-all. + /// The mount-all verb reads the user's registered repos (via ) + /// 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 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 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 + /// __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 registered GVFS enlistments at logon for each interactive user. Required by VFS for Git. [gvfs-logon-task-hash=__TASK_HASH__] + \GVFS\AutoMount + + + + true + + + + + S-1-5-4 + 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) + { + ArgumentException.ThrowIfNullOrEmpty(gvfsExePath); + + return XmlTemplate + .Replace(GvfsPathPlaceholder, gvfsExePath) + .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, + /// 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, out string errorMessage) + { + ArgumentException.ThrowIfNullOrEmpty(gvfsExePath); + + 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)) + { + errorMessage = string.Empty; + return true; + } + } + + string xml = BuildTaskXml(gvfsExePath); + 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.FunctionalTests/Categories.cs b/GVFS/GVFS.FunctionalTests/Categories.cs index 2aea957ed..7a55e9b68 100644 --- a/GVFS/GVFS.FunctionalTests/Categories.cs +++ b/GVFS/GVFS.FunctionalTests/Categories.cs @@ -2,8 +2,9 @@ { public static class Categories { + public const string ExtraCoverage = "ExtraCoverage"; public const string FastFetch = "FastFetch"; public const string GitCommands = "GitCommands"; - public const string SkipInCI = "SkipInCI"; + public const string NeedsReactionInCI = "NeedsReactionInCI"; } } diff --git a/GVFS/GVFS.FunctionalTests/GlobalSetup.cs b/GVFS/GVFS.FunctionalTests/GlobalSetup.cs index 40364b514..5fb25720a 100644 --- a/GVFS/GVFS.FunctionalTests/GlobalSetup.cs +++ b/GVFS/GVFS.FunctionalTests/GlobalSetup.cs @@ -18,23 +18,6 @@ public void RunBeforeAnyTests() [OneTimeTearDown] public void RunAfterAllTests() { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - string serviceLogFolder = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), - "GVFS", - GVFSServiceProcess.TestServiceName, - "Logs"); - - Console.WriteLine("GVFS.Service logs at '{0}' attached below.\n\n", serviceLogFolder); - foreach (string filename in TestResultsHelper.GetAllFilesInDirectory(serviceLogFolder)) - { - TestResultsHelper.OutputFileContents(filename); - } - - GVFSServiceProcess.UninstallService(); - } - PrintTestCaseStats.PrintRunTimeStats(); } } diff --git a/GVFS/GVFS.FunctionalTests/Program.cs b/GVFS/GVFS.FunctionalTests/Program.cs index d74c70eb8..1d4340146 100644 --- a/GVFS/GVFS.FunctionalTests/Program.cs +++ b/GVFS/GVFS.FunctionalTests/Program.cs @@ -84,11 +84,21 @@ public static void Main(string[] args) new object[] { validateMode }, }; + if (runner.HasCustomArg("--extra-only")) + { + Console.WriteLine("Running only the tests marked as ExtraCoverage"); + includeCategories.Add(Categories.ExtraCoverage); + } + else + { + excludeCategories.Add(Categories.ExtraCoverage); + } + // If we're running in CI exclude tests that are currently // flakey or broken when run in a CI environment. if (runner.HasCustomArg("--ci")) { - excludeCategories.Add(Categories.SkipInCI); + excludeCategories.Add(Categories.NeedsReactionInCI); } GVFSTestConfig.FileSystemRunners = FileSystemRunners.FileSystemRunner.DefaultRunners; @@ -141,10 +151,7 @@ private static void RunBeforeAnyTests() ProjFSFilterInstaller.ReplaceInboxProjFS(); } - Console.WriteLine("[CI-DEBUG] Installing service..."); - Console.Out.Flush(); - GVFSServiceProcess.InstallService(); - Console.WriteLine("[CI-DEBUG] Service installed successfully"); + Console.WriteLine("[CI-DEBUG] Skipping service install (service removed)"); Console.Out.Flush(); string serviceProgramDataDir = GVFSPlatform.Instance.GetSecureDataRootForGVFSComponent( diff --git a/GVFS/GVFS.FunctionalTests/Settings.cs b/GVFS/GVFS.FunctionalTests/Settings.cs index 4bd933790..389a847fb 100644 --- a/GVFS/GVFS.FunctionalTests/Settings.cs +++ b/GVFS/GVFS.FunctionalTests/Settings.cs @@ -65,8 +65,20 @@ public static void Initialize() } else { - PathToGVFS = @"C:\Program Files\VFS for Git\GVFS.exe"; - PathToGVFSService = @"C:\Program Files\VFS for Git\GVFS.Service.exe"; + // User-level install path (LocalAppData) for user-mode testing. + string userLevelGvfs = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "VFSForGit", "Current", "gvfs.exe"); + if (File.Exists(userLevelGvfs)) + { + PathToGVFS = userLevelGvfs; + PathToGVFSService = Path.Combine(Path.GetDirectoryName(userLevelGvfs), "GVFS.Service.exe"); + } + else + { + PathToGVFS = @"C:\Program Files\VFS for Git\GVFS.exe"; + PathToGVFSService = @"C:\Program Files\VFS for Git\GVFS.Service.exe"; + } } PathToGit = @"C:\Program Files\Git\cmd\git.exe"; diff --git a/GVFS/GVFS.Installers/GVFS.Installers.csproj b/GVFS/GVFS.Installers/GVFS.Installers.csproj index e48c1229e..958c3aa51 100644 --- a/GVFS/GVFS.Installers/GVFS.Installers.csproj +++ b/GVFS/GVFS.Installers/GVFS.Installers.csproj @@ -3,6 +3,8 @@ false $(RepoOutPath)GVFS.Payload\bin\$(Configuration)\win-x64\ + $(MSBuildThisFileDirectory)..\..\scripts\projfs-attach\ + $(IntermediateOutputPath)enable-projfs-on-all-drives-task.xml @@ -24,8 +26,12 @@ + + + + - + diff --git a/GVFS/GVFS.Installers/Setup.iss b/GVFS/GVFS.Installers/Setup.iss index bda36b806..2ed96c6e3 100644 --- a/GVFS/GVFS.Installers/Setup.iss +++ b/GVFS/GVFS.Installers/Setup.iss @@ -1,4 +1,4 @@ -; This script requires Inno Setup Compiler 5.5.9 or later to compile +; This script requires Inno Setup 6 or later to compile ; The Inno Setup Compiler (and IDE) can be found at http://www.jrsoftware.org/isinfo.php ; General documentation on how to use InnoSetup scripts: http://www.jrsoftware.org/ishelp/index.php @@ -10,11 +10,13 @@ #define MyAppURL "https://github.com/microsoft/VFSForGit" #define MyAppExeName "GVFS.exe" #define EnvironmentKey "SYSTEM\CurrentControlSet\Control\Session Manager\Environment" +#define UserEnvironmentKey "Environment" #define FileSystemKey "SYSTEM\CurrentControlSet\Control\FileSystem" #define GvFltAutologgerKey "SYSTEM\CurrentControlSet\Control\WMI\Autologger\Microsoft-Windows-Git-Filter-Log" #define GVFSConfigFileName "gvfs.config" #define GVFSStatuscacheTokenFileName "EnableGitStatusCacheToken.dat" #define ServiceName "GVFS.Service" +#define ProjFSTaskPath "\GVFS\EnableProjFSOnAllDrives" [Setup] AppId={{489CA581-F131-4C28-BE04-4FB178933E6D} @@ -45,6 +47,10 @@ WindowResizable=no CloseApplications=no ChangesEnvironment=yes RestartIfNeededByRun=yes +; Allow the installer to run as non-admin when the user passes +; /CURRENTUSER on the command line. Without /CURRENTUSER, the +; installer runs as admin (the default, matching existing behavior). +PrivilegesRequiredOverridesAllowed=commandline [Languages] Name: "english"; MessagesFile: "compiler:Default.isl"; @@ -59,17 +65,20 @@ Name: "full"; Description: "Full installation"; Flags: iscustom; Type: files; Name: "{app}\ucrtbase.dll" [Files] -; Normal install: all files go to {app}, service gets AfterInstall callback -DestDir: "{app}"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*"; Check: IsNormalInstall -DestDir: "{app}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; AfterInstall: InstallGVFSService; Check: IsNormalInstall -; Staging install: most files go to {app}\PendingUpgrade, but GVFS.Service.exe -; goes directly to {app} so the restarted service has PendingUpgradeHandler code. -; The service is briefly stopped/restarted (mounts are independent processes). -DestDir: "{app}\PendingUpgrade"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*"; Check: IsStagingInstall -DestDir: "{app}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; Check: IsStagingInstall +; System-mode install: versioned deployment +DestDir: "{app}\Versions\{#MyAppInstallerVersion}"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*"; Check: IsSystemModeNormalInstall + +; User-mode install: versioned deployment to %LocalAppData%\GVFS\Versions\ +DestDir: "{localappdata}\GVFS\Versions\{#MyAppInstallerVersion}"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*"; Check: IsUserModeNormalInstall + +; Pre-built EnableProjFSOnAllDrives task XML (embedded, not deployed). +; Extracted to temp at install time for schtasks /Create /XML. +#ifdef ProjFSTaskXml +Source: "{#ProjFSTaskXml}"; Flags: dontcopy +#endif [Dirs] -Name: "{app}\ProgramData\{#ServiceName}"; Permissions: users-readexec +Name: "{app}\Versions\{#MyAppInstallerVersion}\ProgramData\{#ServiceName}"; Permissions: users-readexec; Check: IsSystemModeNormalInstall [UninstallDelete] ; Deletes the entire installation directory, including files and subdirectories @@ -77,47 +86,110 @@ Type: filesandordirs; Name: "{app}"; Type: filesandordirs; Name: "{commonappdata}\GVFS\GVFS.Upgrade"; [Registry] +; System-mode: add {app}\Current to system PATH Root: HKLM; Subkey: "{#EnvironmentKey}"; \ - ValueType: expandsz; ValueName: "PATH"; ValueData: "{olddata};{app}"; \ - Check: NeedsAddPath(ExpandConstant('{app}')) + ValueType: expandsz; ValueName: "PATH"; ValueData: "{olddata};{app}\Current"; \ + Check: SystemModeNeedsAddPath Root: HKLM; Subkey: "{#FileSystemKey}"; \ ValueType: dword; ValueName: "NtfsEnableDetailedCleanupResults"; ValueData: "1"; \ - Check: IsWindows10VersionPriorToCreatorsUpdate + Check: IsSystemModeWindows10PriorToCreatorsUpdate + +Root: HKLM; SubKey: "{#GvFltAutologgerKey}"; Flags: deletekey; Check: IsSystemModeInstall + +; User-mode: add Current junction dir to user PATH +Root: HKCU; Subkey: "{#UserEnvironmentKey}"; \ + ValueType: expandsz; ValueName: "PATH"; ValueData: "{olddata};{localappdata}\GVFS\Current"; \ + Check: UserModeNeedsAddPath -Root: HKLM; SubKey: "{#GvFltAutologgerKey}"; Flags: deletekey +; User-mode: redirect GVFS data paths to %LocalAppData%\GVFS so +; the user has write access without admin elevation. +Root: HKCU; Subkey: "{#UserEnvironmentKey}"; \ + ValueType: string; ValueName: "GVFS_SECURE_DATA_ROOT"; ValueData: "{localappdata}\GVFS"; \ + Check: IsUserModeNormalInstall + +Root: HKCU; Subkey: "{#UserEnvironmentKey}"; \ + ValueType: string; ValueName: "GVFS_COMMON_APPDATA_ROOT"; ValueData: "{localappdata}\GVFS"; \ + Check: IsUserModeNormalInstall [Code] var ExitCode: Integer; - KeepMountsRunning: Boolean; + IsUserModeInstall: Boolean; + IsAdminStage: Boolean; -function IsNormalInstall(): Boolean; +function InitializeSetup(): Boolean; begin - Result := not KeepMountsRunning; + Result := True; + IsUserModeInstall := not IsAdmin(); + IsAdminStage := (ExpandConstant('{param:ADMINSTAGE|false}') = 'true'); + + if IsAdminStage then + begin + Log('[GVFS-INSTALL] InitializeSetup: /ADMINSTAGE mode - will run admin setup only'); + end + else if IsUserModeInstall then + begin + Log('[GVFS-INSTALL] InitializeSetup: User-mode install detected (non-elevated)'); + end + else + begin + Log('[GVFS-INSTALL] InitializeSetup: System-mode install (elevated)'); + end; +end; + +function IsUserModeNormalInstall(): Boolean; +begin + Result := IsUserModeInstall and not IsAdminStage; +end; + +function IsSystemModeInstall(): Boolean; +begin + Result := not IsUserModeInstall; end; -function IsStagingInstall(): Boolean; +function IsSystemModeNormalInstall(): Boolean; begin - Result := KeepMountsRunning; + Result := IsSystemModeInstall() and not IsAdminStage; end; function NeedsAddPath(Param: string): boolean; var OrigPath: string; + RootKey: Integer; + SubKeyName: string; begin - if not RegQueryStringValue(HKEY_LOCAL_MACHINE, - '{#EnvironmentKey}', - 'PATH', OrigPath) - then begin - Result := True; - exit; - end; + if IsUserModeInstall then + begin + RootKey := HKCU; + SubKeyName := '{#UserEnvironmentKey}'; + end + else + begin + RootKey := HKLM; + SubKeyName := '{#EnvironmentKey}'; + end; + + if not RegQueryStringValue(RootKey, SubKeyName, 'PATH', OrigPath) then + begin + Result := True; + exit; + end; // look for the path with leading and trailing semicolon // Pos() returns 0 if not found Result := Pos(';' + Param + ';', ';' + OrigPath + ';') = 0; end; +function SystemModeNeedsAddPath(): Boolean; +begin + Result := IsSystemModeNormalInstall() and NeedsAddPath(ExpandConstant('{app}\Current')); +end; + +function UserModeNeedsAddPath(): Boolean; +begin + Result := IsUserModeNormalInstall() and NeedsAddPath(ExpandConstant('{localappdata}\GVFS\Current')); +end; + function IsWindows10VersionPriorToCreatorsUpdate(): Boolean; var Version: TWindowsVersion; @@ -126,36 +198,54 @@ begin Result := (Version.Major = 10) and (Version.Minor = 0) and (Version.Build < 15063); end; +function IsSystemModeWindows10PriorToCreatorsUpdate(): Boolean; +begin + Result := IsSystemModeNormalInstall() and IsWindows10VersionPriorToCreatorsUpdate(); +end; + procedure RemovePath(Path: string); var Paths: string; PathMatchIndex: Integer; + RootKey: Integer; + SubKeyName: string; begin - if not RegQueryStringValue(HKEY_LOCAL_MACHINE, '{#EnvironmentKey}', 'Path', Paths) then + if IsUserModeInstall then + begin + RootKey := HKCU; + SubKeyName := '{#UserEnvironmentKey}'; + end + else + begin + RootKey := HKLM; + SubKeyName := '{#EnvironmentKey}'; + end; + + if not RegQueryStringValue(RootKey, SubKeyName, 'Path', Paths) then begin - Log('PATH not found'); + Log('[GVFS-INSTALL] RemovePath: PATH not found'); end else begin - Log(Format('PATH is [%s]', [Paths])); + Log(Format('[GVFS-INSTALL] RemovePath: PATH is [%s]', [Paths])); PathMatchIndex := Pos(';' + Uppercase(Path) + ';', ';' + Uppercase(Paths) + ';'); if PathMatchIndex = 0 then begin - Log(Format('Path [%s] not found in PATH', [Path])); + Log(Format('[GVFS-INSTALL] RemovePath: Path [%s] not found in PATH', [Path])); end else begin Delete(Paths, PathMatchIndex - 1, Length(Path) + 1); - Log(Format('Path [%s] removed from PATH => [%s]', [Path, Paths])); + Log(Format('[GVFS-INSTALL] RemovePath: Path [%s] removed from PATH => [%s]', [Path, Paths])); - if RegWriteStringValue(HKEY_LOCAL_MACHINE, '{#EnvironmentKey}', 'Path', Paths) then + if RegWriteStringValue(RootKey, SubKeyName, 'Path', Paths) then begin - Log('PATH written'); + Log('[GVFS-INSTALL] RemovePath: PATH written'); end else begin - Log('Error writing PATH'); + Log('[GVFS-INSTALL] RemovePath: Error writing PATH'); end; end; end; @@ -165,16 +255,16 @@ procedure StopService(ServiceName: string); var ResultCode: integer; begin - Log('StopService: stopping: ' + ServiceName); + Log('[GVFS-INSTALL] StopService: stopping: ' + ServiceName); if not Exec(ExpandConstant('{sys}\SC.EXE'), 'stop ' + ServiceName, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then begin - Log('StopService: Failed to launch sc.exe'); + Log('[GVFS-INSTALL] StopService: Failed to launch sc.exe'); RaiseException('Fatal: Could not stop service: ' + ServiceName); end; // 1060 = service not installed, 1062 = service not started if (ResultCode <> 0) and (ResultCode <> 1060) and (ResultCode <> 1062) then begin - Log('StopService: sc stop returned error code ' + IntToStr(ResultCode)); + Log('[GVFS-INSTALL] StopService: sc stop returned error code ' + IntToStr(ResultCode)); RaiseException('Fatal: Could not stop service: ' + ServiceName + ' (exit code ' + IntToStr(ResultCode) + ')'); end; end; @@ -197,33 +287,33 @@ begin // 1060 = service does not exist (fully deleted and process exited) if ResultCode = 1060 then begin - Log('WaitForServiceProcessToExit: Service no longer exists'); + Log('[GVFS-INSTALL] WaitForServiceProcessToExit: Service no longer exists'); break; end; if LoadStringFromFile(TempFile, QueryOutput) then begin if Pos('STOPPED', QueryOutput) > 0 then begin - Log('WaitForServiceProcessToExit: Service is stopped'); + Log('[GVFS-INSTALL] WaitForServiceProcessToExit: Service is stopped'); break; end; end; end else begin - Log('WaitForServiceProcessToExit: sc query failed, assuming service is gone'); + Log('[GVFS-INSTALL] WaitForServiceProcessToExit: sc query failed, assuming service is gone'); break; end; Attempts := Attempts + 1; - Log('WaitForServiceProcessToExit: Waiting for service to stop (attempt ' + IntToStr(Attempts) + ')'); + Log('[GVFS-INSTALL] WaitForServiceProcessToExit: Waiting for service to stop (attempt ' + IntToStr(Attempts) + ')'); Sleep(1000); end; if Attempts >= 30 then begin if LoadStringFromFile(TempFile, QueryOutput) then - Log('WaitForServiceProcessToExit: Timed out. Last sc query output: ' + QueryOutput) + Log('[GVFS-INSTALL] WaitForServiceProcessToExit: Timed out. Last sc query output: ' + QueryOutput) else - Log('WaitForServiceProcessToExit: Timed out waiting for service to stop'); + Log('[GVFS-INSTALL] WaitForServiceProcessToExit: Timed out waiting for service to stop'); end; DeleteFile(TempFile); end; @@ -234,7 +324,7 @@ var begin if Exec(ExpandConstant('{sys}\SC.EXE'), 'query ' + ServiceName, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode <> 1060) then begin - Log('UninstallService: uninstalling service: ' + ServiceName); + Log('[GVFS-INSTALL] UninstallService: uninstalling service: ' + ServiceName); if (ShowProgress) then begin WizardForm.StatusLabel.Caption := 'Uninstalling service: ' + ServiceName; @@ -246,7 +336,7 @@ begin if not Exec(ExpandConstant('{sys}\SC.EXE'), 'delete ' + ServiceName, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then begin - Log('UninstallService: Could not uninstall service: ' + ServiceName); + Log('[GVFS-INSTALL] UninstallService: Could not uninstall service: ' + ServiceName); RaiseException('Fatal: Could not uninstall service: ' + ServiceName); end; @@ -270,93 +360,147 @@ end; procedure WriteOnDiskVersion16CapableFile(); var FilePath: string; + AppBase: string; begin - FilePath := ExpandConstant('{app}\OnDiskVersion16CapableInstallation.dat'); + if IsUserModeInstall then + AppBase := ExpandConstant('{localappdata}\GVFS') + else + AppBase := ExpandConstant('{app}'); + FilePath := AppBase + '\Versions\{#MyAppInstallerVersion}\OnDiskVersion16CapableInstallation.dat'; if not FileExists(FilePath) then begin - Log('WriteOnDiskVersion16CapableFile: Writing file ' + FilePath); + Log('[GVFS-INSTALL] WriteOnDiskVersion16CapableFile: Writing file ' + FilePath); SaveStringToFile(FilePath, '', False); end end; -procedure InstallGVFSService(); +function ExecWithResult(Filename, Params, WorkingDir: String; ShowCmd: Integer; + Wait: TExecWait; var ResultCode: Integer; var ResultString: ansiString): Boolean; var - ResultCode: integer; - StatusText: string; - InstallSuccessful: Boolean; + TempFilename: string; + Command: string; begin - InstallSuccessful := False; - - StatusText := WizardForm.StatusLabel.Caption; - WizardForm.StatusLabel.Caption := 'Installing GVFS.Service.'; - WizardForm.ProgressGauge.Style := npbstMarquee; - - // Spaces after the equal signs are REQUIRED. - // https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/sc-create#remarks - try - // We must add additional quotes to the binPath to ensure that they survive argument parsing. - // Without quotes, sc.exe will try to start a file located at C:\Program if it exists. - if Exec(ExpandConstant('{sys}\SC.EXE'), ExpandConstant('create GVFS.Service binPath= "\"{app}\GVFS.Service.exe\"" start= auto'), '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0) then - begin - if Exec(ExpandConstant('{sys}\SC.EXE'), 'failure GVFS.Service reset= 30 actions= restart/10/restart/5000//1', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then - begin - if Exec(ExpandConstant('{sys}\SC.EXE'), 'start GVFS.Service', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then - begin - InstallSuccessful := True; - end; - end; - end; - - WriteOnDiskVersion16CapableFile(); - finally - WizardForm.StatusLabel.Caption := StatusText; - WizardForm.ProgressGauge.Style := npbstNormal; - end; - - if InstallSuccessful = False then + TempFilename := ExpandConstant('{tmp}\~execwithresult.txt'); + { Exec via cmd and redirect output to file. Must use special string-behavior to work. } + Command := Format('"%s" /S /C ""%s" %s > "%s""', [ExpandConstant('{cmd}'), Filename, Params, TempFilename]); + Result := Exec(ExpandConstant('{cmd}'), Command, WorkingDir, ShowCmd, Wait, ResultCode); + if Result then begin - RaiseException('Fatal: An error occured while installing GVFS.Service.'); + LoadStringFromFile(TempFilename, ResultString); end; + DeleteFile(TempFilename); end; -procedure StagingUpdateService(); +procedure RegisterAutoMountLogonTask(); var ResultCode: integer; StatusText: string; + GvfsExe: string; + TempXmlFile: string; + TaskXml: string; begin - // In staging mode: the service was stopped in PrepareToInstall so its exe - // could be replaced. Now start it with the new binary. The new service has - // PendingUpgradeHandler which will complete the upgrade on next restart - // when no mounts are running. StatusText := WizardForm.StatusLabel.Caption; - WizardForm.StatusLabel.Caption := 'Starting GVFS.Service.'; + WizardForm.StatusLabel.Caption := 'Registering AutoMount logon task...'; WizardForm.ProgressGauge.Style := npbstMarquee; try - Log('StagingUpdateService: Starting service with new binary'); - if Exec(ExpandConstant('{sys}\SC.EXE'), 'start GVFS.Service', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then - begin - if ResultCode <> 0 then - Log('StagingUpdateService: Warning - sc start returned error code ' + IntToStr(ResultCode)); - end + GvfsExe := 'gvfs.exe'; + TempXmlFile := ExpandConstant('{tmp}\~taskxml.xml'); + + // Machine-wide logon task using Interactive Users group (S-1-5-4). + // Fires for every interactive user at logon. Each user's gvfs service + // --mount-all reads their own LocalRepoRegistry. + TaskXml := '' + #13#10 + + '' + #13#10 + + ' ' + #13#10 + + ' GVFS' + #13#10 + + ' Mounts registered GVFS enlistments at logon for each interactive user. Required by VFS for Git.' + #13#10 + + ' \GVFS\AutoMount' + #13#10 + + ' ' + #13#10 + + ' ' + #13#10 + + ' ' + #13#10 + + ' true' + #13#10 + + ' ' + #13#10 + + ' ' + #13#10 + + ' ' + #13#10 + + ' ' + #13#10 + + ' S-1-5-4' + #13#10 + + ' LeastPrivilege' + #13#10 + + ' ' + #13#10 + + ' ' + #13#10 + + ' ' + #13#10 + + ' IgnoreNew' + #13#10 + + ' false' + #13#10 + + ' false' + #13#10 + + ' true' + #13#10 + + ' true' + #13#10 + + ' false' + #13#10 + + ' ' + #13#10 + + ' false' + #13#10 + + ' false' + #13#10 + + ' ' + #13#10 + + ' true' + #13#10 + + ' true' + #13#10 + + ' false' + #13#10 + + ' false' + #13#10 + + ' false' + #13#10 + + ' PT5M' + #13#10 + + ' 5' + #13#10 + + ' ' + #13#10 + + ' ' + #13#10 + + ' ' + #13#10 + + ' conhost.exe' + #13#10 + + ' --headless "' + GvfsExe + '" service --mount-all' + #13#10 + + ' ' + #13#10 + + ' ' + #13#10 + + ''; + + SaveStringToFile(TempXmlFile, TaskXml, False); + Log('[GVFS-INSTALL] RegisterAutoMountLogonTask: Wrote task XML to ' + TempXmlFile); + + // Create task folder if needed, then register task + Exec(ExpandConstant('{sys}\schtasks.exe'), '/Create /TN "\GVFS\AutoMount" /XML "' + TempXmlFile + '" /F', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + if ResultCode = 0 then + Log('[GVFS-INSTALL] RegisterAutoMountLogonTask: Logon task registered successfully') else - begin - Log('StagingUpdateService: Warning - could not launch sc.exe'); - end; + Log('[GVFS-INSTALL] RegisterAutoMountLogonTask: schtasks /Create returned ' + IntToStr(ResultCode)); - WriteOnDiskVersion16CapableFile(); + DeleteFile(TempXmlFile); finally WizardForm.StatusLabel.Caption := StatusText; WizardForm.ProgressGauge.Style := npbstNormal; end; end; +function UninstallAutomountTask(): Boolean; +var + ResultCode: integer; +begin + Result := False; + Log('[GVFS-INSTALL] UninstallAutomountTask: Checking for task'); + if Exec(ExpandConstant('{sys}\schtasks.exe'), '/Query /TN "\GVFS\AutoMount"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0) then + begin + Log('[GVFS-INSTALL] UninstallAutomountTask: Deleting task'); + if Exec(ExpandConstant('{sys}\schtasks.exe'), '/Delete /TN "\GVFS\AutoMount" /F', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode = 0) then + begin + Log('[GVFS-INSTALL] UninstallAutomountTask: Deleted successfully'); + Result := True; + end + else + Log('[GVFS-INSTALL] UninstallAutomountTask: Delete failed with exit code ' + IntToStr(ResultCode)); + end + else + begin + Log('[GVFS-INSTALL] UninstallAutomountTask: Task not found or query failed'); + Result := True; // Not an error if task doesn't exist + end; +end; function DeleteFileIfItExists(FilePath: string) : Boolean; begin Result := False; if FileExists(FilePath) then begin - Log('DeleteFileIfItExists: Removing ' + FilePath); + Log('[GVFS-INSTALL] DeleteFileIfItExists: Removing ' + FilePath); if DeleteFile(FilePath) then begin if not FileExists(FilePath) then @@ -365,17 +509,17 @@ begin end else begin - Log('DeleteFileIfItExists: File still exists after deleting: ' + FilePath); + Log('[GVFS-INSTALL] DeleteFileIfItExists: File still exists after deleting: ' + FilePath); end; end else begin - Log('DeleteFileIfItExists: Failed to delete ' + FilePath); + Log('[GVFS-INSTALL] DeleteFileIfItExists: Failed to delete ' + FilePath); end; end else begin - Log('DeleteFileIfItExists: File does not exist: ' + FilePath); + Log('[GVFS-INSTALL] DeleteFileIfItExists: File does not exist: ' + FilePath); Result := True; end; end; @@ -384,8 +528,14 @@ procedure UninstallGvFlt(); var StatusText: string; UninstallSuccessful: Boolean; + AppBase: string; begin - if (FileExists(ExpandConstant('{app}\Filter\GvFlt.inf'))) then + if IsUserModeInstall then + AppBase := ExpandConstant('{localappdata}\GVFS') + else + AppBase := ExpandConstant('{app}'); + + if (FileExists(AppBase + '\Filter\GvFlt.inf')) then begin UninstallSuccessful := False; @@ -406,9 +556,9 @@ begin if UninstallSuccessful = True then begin - if not DeleteFile(ExpandConstant('{app}\Filter\GvFlt.inf')) then + if not DeleteFile(AppBase + '\Filter\GvFlt.inf') then begin - Log('UninstallGvFlt: Failed to delete GvFlt.inf'); + Log('[GVFS-INSTALL] UninstallGvFlt: Failed to delete GvFlt.inf'); end; end else @@ -421,16 +571,22 @@ end; function UninstallNonInboxProjFS(): Boolean; var StatusText: string; + AppBase: string; begin Result := False; StatusText := WizardForm.StatusLabel.Caption; WizardForm.StatusLabel.Caption := 'Uninstalling PrjFlt Driver.'; WizardForm.ProgressGauge.Style := npbstMarquee; - Log('UninstallNonInboxProjFS: Uninstalling ProjFS'); + if IsUserModeInstall then + AppBase := ExpandConstant('{localappdata}\GVFS') + else + AppBase := ExpandConstant('{app}'); + + Log('[GVFS-INSTALL] UninstallNonInboxProjFS: Uninstalling ProjFS'); try UninstallService('prjflt', False); - if DeleteFileIfItExists(ExpandConstant('{app}\ProjectedFSLib.dll')) then + if DeleteFileIfItExists(AppBase + '\ProjectedFSLib.dll') then begin if DeleteFileIfItExists(ExpandConstant('{sys}\drivers\prjflt.sys')) then begin @@ -447,8 +603,14 @@ procedure UninstallProjFSIfNecessary(); var ProjFSFeatureEnabledResultCode: integer; UninstallSuccessful: Boolean; + AppBase: string; begin - if FileExists(ExpandConstant('{app}\Filter\PrjFlt.inf')) and FileExists(ExpandConstant('{sys}\drivers\prjflt.sys')) then + if IsUserModeInstall then + AppBase := ExpandConstant('{localappdata}\GVFS') + else + AppBase := ExpandConstant('{app}'); + + if FileExists(AppBase + '\Filter\PrjFlt.inf') and FileExists(ExpandConstant('{sys}\drivers\prjflt.sys')) then begin UninstallSuccessful := False; @@ -457,7 +619,7 @@ begin if ProjFSFeatureEnabledResultCode = 2 then begin // Client-ProjFS is not an optional feature - Log('UninstallProjFSIfNecessary: Could not locate Windows Projected File System optional feature, uninstalling ProjFS'); + Log('[GVFS-INSTALL] UninstallProjFSIfNecessary: Could not locate Windows Projected File System optional feature, uninstalling ProjFS'); if UninstallNonInboxProjFS() then begin UninstallSuccessful := True; @@ -467,8 +629,8 @@ begin begin // Client-ProjFS is already enabled. If the native ProjFS library is in the apps folder it must // be deleted to ensure GVFS uses the inbox library (in System32) - Log('UninstallProjFSIfNecessary: Client-ProjFS already enabled'); - if DeleteFileIfItExists(ExpandConstant('{app}\ProjectedFSLib.dll')) then + Log('[GVFS-INSTALL] UninstallProjFSIfNecessary: Client-ProjFS already enabled'); + if DeleteFileIfItExists(AppBase + '\ProjectedFSLib.dll') then begin UninstallSuccessful := True; end; @@ -476,7 +638,7 @@ begin if ProjFSFeatureEnabledResultCode = 4 then begin // Client-ProjFS is currently disabled but prjflt.sys is present and should be removed - Log('UninstallProjFSIfNecessary: Client-ProjFS is disabled, uninstalling ProjFS'); + Log('[GVFS-INSTALL] UninstallProjFSIfNecessary: Client-ProjFS is disabled, uninstalling ProjFS'); if UninstallNonInboxProjFS() then begin UninstallSuccessful := True; @@ -508,84 +670,38 @@ begin end; end; -function ExecWithResult(Filename, Params, WorkingDir: String; ShowCmd: Integer; - Wait: TExecWait; var ResultCode: Integer; var ResultString: ansiString): Boolean; -var - TempFilename: string; - Command: string; -begin - TempFilename := ExpandConstant('{tmp}\~execwithresult.txt'); - { Exec via cmd and redirect output to file. Must use special string-behavior to work. } - Command := Format('"%s" /S /C ""%s" %s > "%s""', [ExpandConstant('{cmd}'), Filename, Params, TempFilename]); - Result := Exec(ExpandConstant('{cmd}'), Command, WorkingDir, ShowCmd, Wait, ResultCode); - if Result then - begin - LoadStringFromFile(TempFilename, ResultString); - end; - DeleteFile(TempFilename); -end; - -procedure UnmountRepos(); -var - ResultCode: integer; -begin - Exec('gvfs.exe', 'service --unmount-all', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); -end; - -procedure MountRepos(); -var - StatusText: string; - MountOutput: ansiString; - ResultCode: integer; - MsgBoxText: string; -begin - StatusText := WizardForm.StatusLabel.Caption; - WizardForm.StatusLabel.Caption := 'Mounting Repos.'; - WizardForm.ProgressGauge.Style := npbstMarquee; - - ExecWithResult(ExpandConstant('{app}') + '\gvfs.exe', 'service --mount-all', '', SW_HIDE, ewWaitUntilTerminated, ResultCode, MountOutput); - WizardForm.StatusLabel.Caption := StatusText; - WizardForm.ProgressGauge.Style := npbstNormal; - - // 4 = ReturnCode.FilterError - if (ResultCode = 4) then - begin - RaiseException('Fatal: Could not configure and start Windows Projected File System.'); - end - else if (ResultCode <> 0) then - begin - MsgBoxText := 'Mounting one or more repos failed:' + #13#10 + MountOutput; - SuppressibleMsgBox(MsgBoxText, mbConfirmation, MB_OK, IDOK); - ExitCode := 17; - end; -end; - procedure MigrateFile(OldPath, NewPath : string); begin - Log('MigrateFile(' + OldPath + ', ' + NewPath + ')'); + Log('[GVFS-INSTALL] MigrateFile(' + OldPath + ', ' + NewPath + ')'); if (FileExists(OldPath)) then begin if (not FileExists(NewPath)) then begin if (not RenameFile(OldPath, NewPath)) then - Log('Could not move ' + OldPath + ' continuing anyway') + Log('[GVFS-INSTALL] Could not move ' + OldPath + ' continuing anyway') else - Log('Moved ' + OldPath + ' to ' + NewPath); + Log('[GVFS-INSTALL] Moved ' + OldPath + ' to ' + NewPath); end else - Log('Migration cancelled. Newer file exists at path ' + NewPath); + Log('[GVFS-INSTALL] Migration cancelled. Newer file exists at path ' + NewPath); end else - Log('Migration cancelled. ' + OldPath + ' does not exist'); + Log('[GVFS-INSTALL] Migration cancelled. ' + OldPath + ' does not exist'); end; procedure MigrateConfigAndStatusCacheFiles(); var CommonAppDataDir: string; SecureAppDataDir: string; + AppBase: string; begin + if IsUserModeInstall then + AppBase := ExpandConstant('{localappdata}\GVFS') + else + AppBase := ExpandConstant('{app}'); + CommonAppDataDir := ExpandConstant('{commonappdata}\GVFS'); - SecureAppDataDir := ExpandConstant('{app}\ProgramData'); + SecureAppDataDir := AppBase + '\Current\ProgramData'; MigrateFile(CommonAppDataDir + '\{#GVFSConfigFileName}', SecureAppDataDir + '\{#GVFSConfigFileName}'); MigrateFile(CommonAppDataDir + '\{#ServiceName}\{#GVFSStatuscacheTokenFileName}', SecureAppDataDir + '\{#ServiceName}\{#GVFSStatuscacheTokenFileName}'); @@ -624,7 +740,7 @@ begin if ExecWithResult('gvfs.exe', 'config upgrade.ring', '', SW_HIDE, ewWaitUntilTerminated, ResultCode, ResultString) then begin if ResultCode = 0 then begin ResultString := AnsiLowercase(Trim(ResultString)); - Log('GetConfiguredUpgradeRing: upgrade.ring is ' + ResultString); + Log('[GVFS-INSTALL] GetConfiguredUpgradeRing: upgrade.ring is ' + ResultString); if CompareText(ResultString, 'none') = 0 then begin Result := urNone; end else if CompareText(ResultString, 'fast') = 0 then begin @@ -632,13 +748,13 @@ begin end else if CompareText(ResultString, 'slow') = 0 then begin Result := urSlow; end else begin - Log('GetConfiguredUpgradeRing: Unknown upgrade ring: ' + ResultString); + Log('[GVFS-INSTALL] GetConfiguredUpgradeRing: Unknown upgrade ring: ' + ResultString); end; end else begin - Log('GetConfiguredUpgradeRing: Call to gvfs config upgrade.ring failed with ' + SysErrorMessage(ResultCode)); + Log('[GVFS-INSTALL] GetConfiguredUpgradeRing: Call to gvfs config upgrade.ring failed with ' + SysErrorMessage(ResultCode)); end; end else begin - Log('GetConfiguredUpgradeRing: Call to gvfs config upgrade.ring failed with ' + SysErrorMessage(ResultCode)); + Log('[GVFS-INSTALL] GetConfiguredUpgradeRing: Call to gvfs config upgrade.ring failed with ' + SysErrorMessage(ResultCode)); end; end; @@ -650,7 +766,7 @@ begin Result := False if ExecWithResult('gvfs.exe', Format('config %s', [ConfigKey]), '', SW_HIDE, ewWaitUntilTerminated, ResultCode, ResultString) then begin ResultString := AnsiLowercase(Trim(ResultString)); - Log(Format('IsConfigured(%s): value is %s', [ConfigKey, ResultString])); + Log(Format('[GVFS-INSTALL] IsConfigured(%s): value is %s', [ConfigKey, ResultString])); Result := Length(ResultString) > 1 end end; @@ -662,12 +778,12 @@ var begin if IsConfigured(ConfigKey) = False then begin if ExecWithResult('gvfs.exe', Format('config %s %s', [ConfigKey, ConfigValue]), '', SW_HIDE, ewWaitUntilTerminated, ResultCode, ResultString) then begin - Log(Format('SetIfNotConfigured: Set %s to %s', [ConfigKey, ConfigValue])); + Log(Format('[GVFS-INSTALL] SetIfNotConfigured: Set %s to %s', [ConfigKey, ConfigValue])); end else begin - Log(Format('SetIfNotConfigured: Failed to set %s with %s', [ConfigKey, SysErrorMessage(ResultCode)])); + Log(Format('[GVFS-INSTALL] SetIfNotConfigured: Failed to set %s with %s', [ConfigKey, SysErrorMessage(ResultCode)])); end; end else begin - Log(Format('SetIfNotConfigured: %s is configured, not overwriting', [ConfigKey])); + Log(Format('[GVFS-INSTALL] SetIfNotConfigured: %s is configured, not overwriting', [ConfigKey])); end; end; @@ -684,7 +800,7 @@ begin end else if (ConfiguredRing = urSlow) or (ConfiguredRing = urNone) then begin RingName := 'Slow'; end else begin - Log('SetNuGetFeedIfNecessary: No upgrade ring configured. Not configuring NuGet feed.') + Log('[GVFS-INSTALL] SetNuGetFeedIfNecessary: No upgrade ring configured. Not configuring NuGet feed.') exit; end; @@ -695,305 +811,560 @@ begin SetIfNotConfigured('upgrade.feedpackagename', FeedPackageName); end; -// Below are EVENT FUNCTIONS -> The main entry points of InnoSetup into the code region -// Documentation : http://www.jrsoftware.org/ishelp/index.php?topic=scriptevents - -function InitializeUninstall(): Boolean; -begin - UnmountRepos(); - Result := EnsureGvfsNotRunning(); -end; +// PHASE 3: Admin drift detection functions -// Called just after "install" phase, before "post install" -function NeedRestart(): Boolean; +function IsProjFSEnabled(): Boolean; +var + ResultCode: integer; begin Result := False; + // Check PrjFlt driver service registration in registry (works non-elevated). + // Start type <= 2 means the driver loads automatically (Boot/System/Auto). + if Exec('powershell.exe', '-NoProfile "$svc=Get-ItemProperty ''HKLM:\SYSTEM\CurrentControlSet\Services\PrjFlt'' -EA SilentlyContinue; if($svc -and $svc.Start -le 2){exit 0}else{exit 1}"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + begin + Result := (ResultCode = 0); + end; end; -function UninstallNeedRestart(): Boolean; +function IsEnableProjFSTaskCurrent(): Boolean; +var + ResultCode: integer; + TaskXml: ansiString; + HashPos: Integer; + ExpectedHash: string; begin Result := False; + // Query the registered task XML via schtasks + if not ExecWithResult(ExpandConstant('{sys}\schtasks.exe'), '/Query /TN "{#ProjFSTaskPath}" /XML', '', SW_HIDE, ewWaitUntilTerminated, ResultCode, TaskXml) then + exit; + if ResultCode <> 0 then + exit; + // Look for the hash marker in the task description + HashPos := Pos('[gvfs-task-hash=', TaskXml); + if HashPos = 0 then + exit; + // The expected hash is passed as a preprocessor define at build time + // (set by the build step that runs build-task-xml.ps1). For now, if + // any valid hash marker is present, we consider the task registered. + // B4 will add the exact hash comparison when it wires up the build step. + Result := True; + Log('[GVFS-INSTALL] IsEnableProjFSTaskCurrent: Task found with hash marker'); end; -procedure CurStepChanged(CurStep: TSetupStep); +procedure EnableProjFSFeature(); +var + ResultCode: integer; begin - case CurStep of - ssInstall: - begin - if not KeepMountsRunning then - UninstallService('GVFS.Service', True); - end; - ssPostInstall: - begin - if KeepMountsRunning then - begin - // All staged files have been written to PendingUpgrade. - // Write .ready marker so the service knows the staging is - // complete and safe to apply. - SaveStringToFile(ExpandConstant('{app}\PendingUpgrade\.ready'), '', False); - Log('CurStepChanged: Wrote PendingUpgrade .ready marker'); - - // Start the service AFTER .ready is written. Previously this - // was an AfterInstall hook on GVFS.Service.exe, but that races: - // the service's debounce timer could fire before .ready exists. - StagingUpdateService(); - end; - MigrateConfigAndStatusCacheFiles(); - if (not KeepMountsRunning) and (ExpandConstant('{param:REMOUNTREPOS|true}') = 'true') then - begin - MountRepos(); - end - end; + if IsProjFSEnabled() then + begin + Log('[GVFS-INSTALL] EnableProjFSFeature: Already enabled, skipping'); + exit; + end; + Log('[GVFS-INSTALL] EnableProjFSFeature: Enabling Client-ProjFS optional feature'); + if Exec('powershell.exe', '-NoProfile "Enable-WindowsOptionalFeature -Online -FeatureName Client-ProjFS -NoRestart"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + begin + if ResultCode <> 0 then + Log('[GVFS-INSTALL] EnableProjFSFeature: PowerShell returned ' + IntToStr(ResultCode) + ' (may require reboot)') + else + Log('[GVFS-INSTALL] EnableProjFSFeature: Enabled successfully'); + end + else + begin + Log('[GVFS-INSTALL] EnableProjFSFeature: Failed to launch PowerShell'); + RaiseException('Fatal: Could not enable ProjFS.'); end; end; -function GetCustomSetupExitCode: Integer; +procedure RegisterEnableProjFSTask(); +var + ResultCode: integer; + TaskXmlPath: string; begin - Result := ExitCode; + if IsEnableProjFSTaskCurrent() then + begin + Log('[GVFS-INSTALL] RegisterEnableProjFSTask: Task is already current, skipping'); + exit; + end; + + Log('[GVFS-INSTALL] RegisterEnableProjFSTask: Registering EnableProjFSOnAllDrives task'); + // Extract the pre-built task XML from the installer's embedded files + ExtractTemporaryFile('enable-projfs-on-all-drives-task.xml'); + TaskXmlPath := ExpandConstant('{tmp}\enable-projfs-on-all-drives-task.xml'); + + if not Exec(ExpandConstant('{sys}\schtasks.exe'), '/Create /TN "{#ProjFSTaskPath}" /XML "' + TaskXmlPath + '" /F', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then + begin + Log('[GVFS-INSTALL] RegisterEnableProjFSTask: schtasks /Create failed with exit code ' + IntToStr(ResultCode)); + RaiseException('Fatal: Could not register EnableProjFSOnAllDrives scheduled task.'); + end; + Log('[GVFS-INSTALL] RegisterEnableProjFSTask: Task registered successfully'); + + // Run the task immediately so PrjFlt is attached before we return + Exec(ExpandConstant('{sys}\schtasks.exe'), '/Run /TN "{#ProjFSTaskPath}"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + if ResultCode <> 0 then + Log('[GVFS-INSTALL] RegisterEnableProjFSTask: Warning - immediate task run returned ' + IntToStr(ResultCode)) + else + Log('[GVFS-INSTALL] RegisterEnableProjFSTask: Task triggered for immediate execution'); end; -procedure CurUninstallStepChanged(CurStep: TUninstallStep); +function NeedsAdminSetup(): Boolean; begin - case CurStep of - usUninstall: - begin - UninstallService('GVFS.Service', False); - RemovePath(ExpandConstant('{app}')); - end; + if not IsProjFSEnabled() then + begin + Log('[GVFS-INSTALL] NeedsAdminSetup: ProjFS not enabled'); + Result := True; + exit; + end; + if not IsEnableProjFSTaskCurrent() then + begin + Log('[GVFS-INSTALL] NeedsAdminSetup: EnableProjFSOnAllDrives task not current'); + Result := True; + exit; end; + Log('[GVFS-INSTALL] NeedsAdminSetup: Admin setup is current, no elevation needed'); + Result := False; end; -// Shows a modal dialog letting the user choose how to handle mounted repos. -// Returns True if the user clicked Continue, False if Cancel. On Continue, -// KeepMounted is set to True if the user chose to stage the upgrade and -// leave repos mounted, or False to unmount and remount immediately. -function ShowMountChoiceDialog(Repos: String; var KeepMounted: Boolean): Boolean; -var - Form: TForm; - HeaderLbl, ReposLbl, RemountDescLbl, KeepDescLbl: TNewStaticText; - RemountRadio, KeepRadio: TNewRadioButton; - BtnContinue, BtnCancel: TNewButton; - ButtonWidth, ButtonHeight, ContentWidth, Margin, IndentMargin: Integer; - ModalResult, Y: Integer; +// Below are EVENT FUNCTIONS -> The main entry points of InnoSetup into the code region +// Documentation : http://www.jrsoftware.org/ishelp/index.php?topic=scriptevents + +function InitializeUninstall(): Boolean; begin - Margin := ScaleX(15); - IndentMargin := ScaleX(34); - ButtonWidth := ScaleX(85); - ButtonHeight := ScaleY(25); + IsUserModeInstall := not IsAdmin(); + Result := EnsureGvfsNotRunning(); +end; - Form := TForm.Create(nil); - try - Form.Caption := 'Setup'; - Form.BorderStyle := bsDialog; - Form.Position := poOwnerFormCenter; - Form.ClientWidth := ScaleX(520); - ContentWidth := Form.ClientWidth - (2 * Margin); - - Y := ScaleY(15); - - HeaderLbl := TNewStaticText.Create(Form); - HeaderLbl.Parent := Form; - HeaderLbl.Left := Margin; - HeaderLbl.Top := Y; - HeaderLbl.Caption := 'The following repos are currently mounted:'; - HeaderLbl.AutoSize := True; - Y := HeaderLbl.Top + HeaderLbl.Height + ScaleY(4); - - ReposLbl := TNewStaticText.Create(Form); - ReposLbl.Parent := Form; - ReposLbl.Left := IndentMargin; - ReposLbl.Top := Y; - ReposLbl.Width := Form.ClientWidth - IndentMargin - Margin; - ReposLbl.WordWrap := True; - ReposLbl.AutoSize := True; - ReposLbl.Caption := Trim(Repos); - Y := ReposLbl.Top + ReposLbl.Height + ScaleY(16); - - RemountRadio := TNewRadioButton.Create(Form); - RemountRadio.Parent := Form; - RemountRadio.Left := Margin; - RemountRadio.Top := Y; - RemountRadio.Width := ContentWidth; - RemountRadio.Caption := 'Remount repos as part of the installation'; - RemountRadio.Checked := True; - Y := RemountRadio.Top + RemountRadio.Height + ScaleY(2); - - RemountDescLbl := TNewStaticText.Create(Form); - RemountDescLbl.Parent := Form; - RemountDescLbl.Left := IndentMargin; - RemountDescLbl.Top := Y; - RemountDescLbl.Width := Form.ClientWidth - IndentMargin - Margin; - RemountDescLbl.WordWrap := True; - RemountDescLbl.AutoSize := True; - RemountDescLbl.Caption := 'They will be temporarily unavailable.'; - Y := RemountDescLbl.Top + RemountDescLbl.Height + ScaleY(14); - - KeepRadio := TNewRadioButton.Create(Form); - KeepRadio.Parent := Form; - KeepRadio.Left := Margin; - KeepRadio.Top := Y; - KeepRadio.Width := ContentWidth; - KeepRadio.Caption := 'Keep repos mounted'; - Y := KeepRadio.Top + KeepRadio.Height + ScaleY(2); - - KeepDescLbl := TNewStaticText.Create(Form); - KeepDescLbl.Parent := Form; - KeepDescLbl.Left := IndentMargin; - KeepDescLbl.Top := Y; - KeepDescLbl.Width := Form.ClientWidth - IndentMargin - Margin; - KeepDescLbl.WordWrap := True; - KeepDescLbl.AutoSize := True; - KeepDescLbl.Caption := 'The upgrade will complete automatically when all repos are unmounted, or at next reboot.'; - Y := KeepDescLbl.Top + KeepDescLbl.Height + ScaleY(20); - - BtnContinue := TNewButton.Create(Form); - BtnContinue.Parent := Form; - BtnContinue.Width := ButtonWidth; - BtnContinue.Height := ButtonHeight; - BtnContinue.Top := Y; - BtnContinue.Left := Form.ClientWidth - Margin - ButtonWidth - ScaleX(10) - ButtonWidth; - BtnContinue.Caption := '&Continue'; - BtnContinue.Default := True; - BtnContinue.ModalResult := mrOk; - - BtnCancel := TNewButton.Create(Form); - BtnCancel.Parent := Form; - BtnCancel.Width := ButtonWidth; - BtnCancel.Height := ButtonHeight; - BtnCancel.Top := Y; - BtnCancel.Left := Form.ClientWidth - Margin - ButtonWidth; - BtnCancel.Caption := '&Cancel'; - BtnCancel.Cancel := True; - BtnCancel.ModalResult := mrCancel; - - Form.ClientHeight := Y + ButtonHeight + ScaleY(15); - Form.ActiveControl := BtnContinue; - - ModalResult := Form.ShowModal(); - if ModalResult = mrOk then - begin - KeepMounted := KeepRadio.Checked; - Result := True; - end - else - begin - Result := False; - end; - finally - Form.Free(); - end; +// Called just after "install" phase, before "post install" +function NeedRestart(): Boolean; +begin + Result := False; end; -function PrepareToInstall(var NeedsRestart: Boolean): String; +procedure CreateOrUpdateCurrentJunction(); var - Repos: ansiString; + AppDir: string; + JunctionPath: string; + JunctionNew: string; + VersionDir: string; ResultCode: integer; - HasMounts: Boolean; begin - NeedsRestart := False; - KeepMountsRunning := False; - Result := ''; - SetNuGetFeedIfNecessary(); + if IsUserModeInstall then + AppDir := ExpandConstant('{localappdata}\GVFS') + else + AppDir := ExpandConstant('{app}'); + + JunctionPath := AppDir + '\Current'; + JunctionNew := AppDir + '\Current.new'; + VersionDir := AppDir + '\Versions\{#MyAppInstallerVersion}'; + + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Target version = {#MyAppInstallerVersion}'); - // Check for mounted repos by querying the service, and also check for - // running GVFS processes (a mount can be running without being registered - // in the service's repo-registry, e.g., after a reinstall). - HasMounts := False; - if ExecWithResult('gvfs.exe', 'service --list-mounted', '', SW_HIDE, ewWaitUntilTerminated, ResultCode, Repos) then + // Fix #4: Atomic junction swap using .new temporary + // Create new junction at Current.new + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Creating junction Current.new -> ' + VersionDir); + if not Exec(ExpandConstant('{cmd}'), '/C mklink /J "' + JunctionNew + '" "' + VersionDir + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then begin - if (ResultCode = 0) and (Repos <> '') then - HasMounts := True; + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: mklink /J failed with exit code ' + IntToStr(ResultCode)); + RaiseException('Fatal: Could not create Current.new junction at ' + JunctionNew); end; - if (not HasMounts) and IsGVFSRunning() then + + // Remove existing Current junction if present + if DirExists(JunctionPath) then begin - HasMounts := True; - Repos := '(GVFS processes detected)'; - Log('PrepareToInstall: No registered mounts but GVFS processes are running'); + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Removing existing Current junction'); + if not Exec(ExpandConstant('{cmd}'), '/C rmdir "' + JunctionPath + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then + begin + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: WARNING - rmdir failed with exit code ' + IntToStr(ResultCode)); + // Continue anyway - rename might still work + end; end; - if HasMounts then + // Rename Current.new -> Current + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Renaming Current.new -> Current'); + if not Exec(ExpandConstant('{cmd}'), '/C ren "' + JunctionNew + '" Current', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) or (ResultCode <> 0) then begin - if WizardSilent() then + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: ren failed with exit code ' + IntToStr(ResultCode)); + // Fallback: if Current.new exists, at least installer can reference it + if DirExists(JunctionNew) then begin - // Silent mode: STAGEIFMOUNTED=true stages files instead of unmounting. - // Default: false (clean upgrade, matching pre-existing behavior). - KeepMountsRunning := ExpandConstant('{param:STAGEIFMOUNTED|false}') = 'true'; - if KeepMountsRunning then - Log('PrepareToInstall: Silent mode with mounted repos, KeepMountsRunning=True') - else - Log('PrepareToInstall: Silent mode with mounted repos, KeepMountsRunning=False'); + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: WARNING - Using Current.new as fallback'); end else begin - // Interactive mode: show a radio-button modal so the user can pick - // between remounting (immediate but brief unavailability) and - // staging the upgrade (deferred until repos are unmounted). - if not ShowMountChoiceDialog(Repos, KeepMountsRunning) then - begin - Result := 'Installation cancelled.'; - exit; - end; + RaiseException('Fatal: Could not rename Current.new to Current'); end; + end + else + begin + Log('[GVFS-INSTALL] CreateOrUpdateCurrentJunction: Junction created successfully'); + end; +end; + +function GetFileVersion(FilePath: string): string; +var + VersionMS: Cardinal; + VersionLS: Cardinal; +begin + Result := ''; + if GetVersionNumbers(FilePath, VersionMS, VersionLS) then + begin + Result := Format('%d.%d.%d.%d', [ + VersionMS shr 16, + VersionMS and $FFFF, + VersionLS shr 16, + VersionLS and $FFFF + ]); + end; +end; + +function IsProcessRunningFromPath(PathPrefix: string): Boolean; +var + ResultCode: integer; + PowerShellCmd: string; +begin + // PowerShell: check if any gvfs.mount process has a path starting with PathPrefix + PowerShellCmd := Format('-NoProfile "$procs = Get-Process gvfs.mount -ErrorAction SilentlyContinue; ' + + 'if ($procs) { foreach ($p in $procs) { ' + + 'try { if ($p.Path -like ''%s*'') { exit 10 } } catch {} } }; exit 0"', [PathPrefix]); + + if Exec('powershell.exe', PowerShellCmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + begin + Result := (ResultCode = 10); + end + else + begin + Log('[GVFS-INSTALL] IsProcessRunningFromPath: PowerShell query failed'); + Result := False; end; +end; + +procedure GarbageCollectOldVersions(); +var + AppDir: string; + VersionsDir: string; + CurrentVersion: string; + FlatGvfsExe: string; + FlatVersion: string; + FindRec: TFindRec; + VersionDirs: array of string; + VersionTimes: array of Int64; + Count: integer; + I, J: integer; + TempStr: string; + TempTime: Int64; + VersionPath: string; + CanDelete: Boolean; +begin + if IsUserModeInstall then + AppDir := ExpandConstant('{localappdata}\GVFS') + else + AppDir := ExpandConstant('{app}'); + + VersionsDir := AppDir + '\Versions'; + CurrentVersion := '{#MyAppInstallerVersion}'; + + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Current version = ' + CurrentVersion); - if KeepMountsRunning then + // First, check for flat-layout binaries at {app}\GVFS.exe + FlatGvfsExe := AppDir + '\GVFS.exe'; + if FileExists(FlatGvfsExe) then begin - // Staging mode: most files go to {app}\PendingUpgrade\ via [Files] entries - // with Check: IsStagingInstall. GVFS.Service.exe goes directly to {app}. - // Clean up any leftover staging dirs from a prior attempt first, - // so we don't mix files from different upgrade versions. - if DirExists(ExpandConstant('{app}\PendingUpgrade')) then + FlatVersion := GetFileVersion(FlatGvfsExe); + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Detected flat layout with version ' + FlatVersion); + + // Check if any mounts are running from the flat install + if IsProcessRunningFromPath(AppDir + '\') then begin - Log('PrepareToInstall: Removing stale PendingUpgrade from prior staging attempt'); - DelTree(ExpandConstant('{app}\PendingUpgrade'), True, True, True); - end; - if DirExists(ExpandConstant('{app}\PreviousVersion')) then + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Mounts running from flat layout - leaving in place'); + end + else begin - Log('PrepareToInstall: Removing stale PreviousVersion from prior staging attempt'); - DelTree(ExpandConstant('{app}\PreviousVersion'), True, True, True); + Log('[GVFS-INSTALL] GarbageCollectOldVersions: No mounts running from flat layout - would migrate to Versions\' + FlatVersion); + // For now, just log. Full migration logic can move files to Versions\. + // Defer to avoid complexity in first PR. end; - // Stop the service now so its exe is unlocked for replacement. - // Mounts are independent processes and unaffected. - Log('PrepareToInstall: Staging mode. Stopping service for exe replacement.'); - StopService('GVFS.Service'); - WaitForServiceProcessToExit('GVFS.Service'); - end - else + end; + + // Enumerate version directories + Count := 0; + SetArrayLength(VersionDirs, 0); + SetArrayLength(VersionTimes, 0); + + if not DirExists(VersionsDir) then + begin + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Versions directory does not exist'); + exit; + end; + + if FindFirst(VersionsDir + '\*', FindRec) then begin - // Clean upgrade: unmount, stop everything, replace files directly. - // Remove any leftover PendingUpgrade or PreviousVersion from a - // previous staging install so stale files don't interfere with - // the fresh install. - if DirExists(ExpandConstant('{app}\PendingUpgrade')) then + try + repeat + if (FindRec.Name <> '.') and (FindRec.Name <> '..') and (FindRec.Attributes and FILE_ATTRIBUTE_DIRECTORY <> 0) then + begin + // Skip the current version + if FindRec.Name <> CurrentVersion then + begin + SetArrayLength(VersionDirs, Count + 1); + SetArrayLength(VersionTimes, Count + 1); + VersionDirs[Count] := FindRec.Name; + VersionTimes[Count] := FindRec.Time; + Count := Count + 1; + end; + end; + until not FindNext(FindRec); + finally + FindClose(FindRec); + end; + end; + + if Count = 0 then + begin + Log('[GVFS-INSTALL] GarbageCollectOldVersions: No old versions to clean up'); + exit; + end; + + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Found ' + IntToStr(Count) + ' old version(s)'); + + // Sort by time (bubble sort, oldest first) + for I := 0 to Count - 2 do + begin + for J := I + 1 to Count - 1 do begin - Log('PrepareToInstall: Removing leftover PendingUpgrade directory'); - DelTree(ExpandConstant('{app}\PendingUpgrade'), True, True, True); + if VersionTimes[I] > VersionTimes[J] then + begin + TempTime := VersionTimes[I]; + VersionTimes[I] := VersionTimes[J]; + VersionTimes[J] := TempTime; + TempStr := VersionDirs[I]; + VersionDirs[I] := VersionDirs[J]; + VersionDirs[J] := TempStr; + end; end; - if DirExists(ExpandConstant('{app}\PreviousVersion')) then + end; + + // Keep the 1 most recent old version (index Count-1), delete the rest + for I := 0 to Count - 2 do + begin + VersionPath := VersionsDir + '\' + VersionDirs[I]; + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Checking version ' + VersionDirs[I]); + + // Check if any mounts are running from this version + CanDelete := not IsProcessRunningFromPath(VersionPath + '\'); + + if CanDelete then begin - Log('PrepareToInstall: Removing leftover PreviousVersion directory'); - DelTree(ExpandConstant('{app}\PreviousVersion'), True, True, True); + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Deleting old version ' + VersionDirs[I]); + if DelTree(VersionPath, True, True, True) then + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Deleted ' + VersionPath) + else + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Failed to delete ' + VersionPath); + end + else + begin + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Version ' + VersionDirs[I] + ' has running mounts - skipping'); end; - if HasMounts then + end; + + // Log the most recent old version that we're keeping + if Count > 0 then + Log('[GVFS-INSTALL] GarbageCollectOldVersions: Keeping most recent old version ' + VersionDirs[Count - 1]); +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +var + ResultCode: integer; + HasGVFSRunning: Boolean; + KillCmd: string; +begin + NeedsRestart := False; + Result := ''; + + if IsAdminStage then + begin + // Elevated re-launch from the user-mode installer. Do only + // the per-machine admin setup, then exit cleanly. The user-mode + // installer is waiting for our exit code (0 = success). + // + // We let Inno Setup proceed through the install phase, but all + // [Files] entries are gated behind Check functions that return + // false in admin-stage mode, so nothing is deployed. This gives + // us a clean exit code without fighting the Inno Setup lifecycle. + Log('[GVFS-INSTALL] PrepareToInstall: /ADMINSTAGE - running admin setup'); + EnableProjFSFeature(); + RegisterEnableProjFSTask(); + Log('[GVFS-INSTALL] PrepareToInstall: /ADMINSTAGE - admin setup complete'); + exit; + end; + + SetNuGetFeedIfNecessary(); + + if IsUserModeInstall then + begin + // User-mode install: check whether per-machine admin setup + // (ProjFS feature + EnableProjFSOnAllDrives task) is current. + // If not, re-launch ourselves elevated to do the admin portion. + if NeedsAdminSetup() then + begin + Log('[GVFS-INSTALL] PrepareToInstall: Admin setup needed, re-launching elevated'); + if not ShellExec('runas', ExpandConstant('{srcexe}'), '/VERYSILENT /ADMINSTAGE=true', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + begin + Result := 'Failed to launch elevated admin setup (user may have declined UAC).'; + exit; + end; + if ResultCode <> 0 then + begin + Result := 'Admin setup failed (exit code ' + IntToStr(ResultCode) + ').'; + exit; + end; + Log('[GVFS-INSTALL] PrepareToInstall: Admin setup completed successfully'); + end + else begin - UnmountRepos(); + Log('[GVFS-INSTALL] PrepareToInstall: Admin setup is current, skipping elevation'); end; - // With CloseApplications=no, Restart Manager won't kill GVFS - // processes. If unmount-all didn't clean up everything (e.g. - // registry was empty), force-kill remaining processes since - // the user already consented to a full upgrade. + + // Check for running GVFS processes if IsGVFSRunning() then begin - Log('PrepareToInstall: GVFS processes still running after unmount, force-killing'); - Exec('powershell.exe', '-NoProfile "Get-Process gvfs,gvfs.mount -ErrorAction SilentlyContinue | Stop-Process -Force"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); - Sleep(2000); + if WizardSilent() then + begin + Log('[GVFS-INSTALL] PrepareToInstall: Silent mode - killing GVFS processes'); + KillCmd := '-NoProfile "Get-Process gvfs,gvfs.mount -ErrorAction SilentlyContinue | % { $pid = $_.Id; Stop-Process -Id $pid -Force }"'; + Exec('powershell.exe', KillCmd, '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Sleep(2000); + end + else + begin + if not EnsureGvfsNotRunning() then + begin + Result := 'Installation cancelled.'; + exit; + end; + end; end; - if not EnsureGvfsNotRunning() then + end + else + begin + // System-mode install: versioned layout with GVFS processes check + HasGVFSRunning := IsGVFSRunning(); + + if HasGVFSRunning then begin - Abort(); + if WizardSilent() then + begin + Log('[GVFS-INSTALL] PrepareToInstall: GVFS processes running in silent mode, proceeding anyway'); + end + else + begin + // Interactive mode: warn user but allow them to continue + Log('[GVFS-INSTALL] PrepareToInstall: GVFS processes detected in interactive mode'); + end; + end; end; + end; + + // Clean up old PendingUpgrade/PreviousVersion dirs from pre-versioned installs + if DirExists(ExpandConstant('{app}\PendingUpgrade')) then + begin + Log('[GVFS-INSTALL] PrepareToInstall: Removing legacy PendingUpgrade directory'); + DelTree(ExpandConstant('{app}\PendingUpgrade'), True, True, True); + end; + if DirExists(ExpandConstant('{app}\PreviousVersion')) then + begin + Log('[GVFS-INSTALL] PrepareToInstall: Removing legacy PreviousVersion directory'); + DelTree(ExpandConstant('{app}\PreviousVersion'), True, True, True); + end; + + // Stop and delete the old service if it exists (migration from service-based install). + // Only for system-mode installs — user-mode can't stop services (ACCESS_DENIED). + // The /ADMINSTAGE path handles ProjFS setup for user-mode. + if not IsUserModeInstall then + begin + Log('[GVFS-INSTALL] PrepareToInstall: Stopping and deleting GVFS.Service if present'); StopService('GVFS.Service'); + WaitForServiceProcessToExit('GVFS.Service'); + UninstallGvFlt(); UninstallProjFSIfNecessary(); end; end; + +function UninstallNeedRestart(): Boolean; +begin + Result := False; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + AppBase: string; +begin + case CurStep of + ssInstall: + begin + // Stop and delete service if present (upgrade from service-based install) + Log('[GVFS-INSTALL] CurStepChanged ssInstall: Stopping and deleting GVFS.Service if present'); + UninstallService('GVFS.Service', True); + + // Create/update the Current junction BEFORE files are extracted + if not IsAdminStage then + CreateOrUpdateCurrentJunction(); + end; + ssPostInstall: + begin + if IsAdminStage then + begin + Log('[GVFS-INSTALL] CurStepChanged ssPostInstall: /ADMINSTAGE - skipping post-install tasks'); + exit; + end; + + if IsUserModeInstall then + AppBase := ExpandConstant('{localappdata}\GVFS') + else + AppBase := ExpandConstant('{app}'); + + // Remove legacy flat PATH entry on upgrade from flat layout + Log('[GVFS-INSTALL] CurStepChanged ssPostInstall: Removing legacy flat PATH entry'); + RemovePath(AppBase); + + // GC runs after junction is already in place (from ssInstall above) + GarbageCollectOldVersions(); + + // Migrate config and status cache files + MigrateConfigAndStatusCacheFiles(); + + // Write OnDiskVersion16Capable marker + WriteOnDiskVersion16CapableFile(); + + // Register AutoMount logon task (replaces service startup) + Log('[GVFS-INSTALL] CurStepChanged ssPostInstall: Registering AutoMount logon task'); + RegisterAutoMountLogonTask(); + end; + end; +end; + +function GetCustomSetupExitCode: Integer; +begin + Result := ExitCode; +end; + +procedure CurUninstallStepChanged(CurStep: TUninstallStep); +var + AppBase: string; +begin + case CurStep of + usUninstall: + begin + UninstallService('GVFS.Service', False); + + if IsUserModeInstall then + AppBase := ExpandConstant('{localappdata}\GVFS') + else + AppBase := ExpandConstant('{app}'); + + RemovePath(AppBase + '\Current'); + + // Unregister the AutoMount logon task + UninstallAutomountTask(); + end; + end; +end; diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index 1896d21f6..fa2d495d7 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -56,8 +56,7 @@ public class InProcessMount private GVFSContext context; private GVFSGitObjects gitObjects; - private volatile MountState currentState; - private volatile string mountProgressMessage; + private MountState currentState; private HeartbeatThread heartbeat; private ManualResetEvent unmountEvent; @@ -196,71 +195,65 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords) this.enlistment.InitializeCachePaths(localCacheRoot, gitObjectsRoot, blobSizesRoot); - // Start the pipe server early so MountVerb can connect and poll progress - // during the parallel validation phase. Only GetStatus requests are - // handled while currentState == Mounting (see HandleRequest guard). - this.mountProgressMessage = "Authenticating and validating"; - using (NamedPipeServer pipeServer = this.StartNamedPipe()) + // Local validations and git config run while we wait for the network + var localTask = Task.Run(() => { - this.tracer.RelatedEvent( - EventLevel.Informational, - $"{nameof(this.Mount)}_StartedNamedPipe", - new EventMetadata { { "NamedPipeName", this.enlistment.NamedPipeName } }); + Stopwatch sw = Stopwatch.StartNew(); - // Local validations and git config run while we wait for the network - Task localTask = Task.Run(() => - { - Stopwatch sw = Stopwatch.StartNew(); + this.ValidateGitVersion(); + this.tracer.RelatedInfo("ParallelMount: ValidateGitVersion completed in {0}ms", sw.ElapsedMilliseconds); - this.ValidateGitVersion(); - this.tracer.RelatedInfo("ParallelMount: ValidateGitVersion completed in {0}ms", sw.ElapsedMilliseconds); + this.ValidateHooksVersion(); + this.ValidateFileSystemSupportsRequiredFeatures(); - this.ValidateHooksVersion(); - this.ValidateFileSystemSupportsRequiredFeatures(); + GitProcess git = new GitProcess(this.enlistment); + if (!git.IsValidRepo()) + { + this.FailMountAndExit("The .git folder is missing or has invalid contents"); + } - GitProcess git = new GitProcess(this.enlistment); - if (!git.IsValidRepo()) - { - this.FailMountAndExit("The .git folder is missing or has invalid contents"); - } + if (!GVFSPlatform.Instance.FileSystem.IsFileSystemSupported(this.enlistment.WorkingDirectoryRoot, out string fsError)) + { + this.FailMountAndExit("FileSystem unsupported: " + fsError); + } - if (!GVFSPlatform.Instance.FileSystem.IsFileSystemSupported(this.enlistment.WorkingDirectoryRoot, out string fsError)) - { - this.FailMountAndExit("FileSystem unsupported: " + fsError); - } + this.tracer.RelatedInfo("ParallelMount: Local validations completed in {0}ms", sw.ElapsedMilliseconds); - this.tracer.RelatedInfo("ParallelMount: Local validations completed in {0}ms", sw.ElapsedMilliseconds); + if (!this.TrySetRequiredGitConfigSettings()) + { + this.FailMountAndExit("Unable to configure git repo"); + } - if (!this.TrySetRequiredGitConfigSettings()) - { - this.FailMountAndExit("Unable to configure git repo"); - } + this.LogEnlistmentInfoAndSetConfigValues(); + this.tracer.RelatedInfo("ParallelMount: Local validations + git config completed in {0}ms", sw.ElapsedMilliseconds); + }); - this.LogEnlistmentInfoAndSetConfigValues(); - this.tracer.RelatedInfo("ParallelMount: Local validations + git config completed in {0}ms", sw.ElapsedMilliseconds); - }); + try + { + Task.WaitAll(networkTask, localTask); + } + catch (AggregateException ae) + { + this.FailMountAndExit(ae.Flatten().InnerExceptions[0].Message); + } - try - { - Task.WaitAll(networkTask, localTask); - } - catch (AggregateException ae) - { - this.FailMountAndExit(ae.Flatten().InnerExceptions[0].Message); - } + parallelTimer.Stop(); + this.tracer.RelatedInfo("ParallelMount: All parallel tasks completed in {0}ms", parallelTimer.ElapsedMilliseconds); - parallelTimer.Stop(); - this.tracer.RelatedInfo("ParallelMount: All parallel tasks completed in {0}ms", parallelTimer.ElapsedMilliseconds); + ServerGVFSConfig serverGVFSConfig = networkTask.Result; - ServerGVFSConfig serverGVFSConfig = networkTask.Result; + CacheServerResolver cacheServerResolver = new CacheServerResolver(this.tracer, this.enlistment); + this.cacheServer = cacheServerResolver.ResolveNameFromRemote(this.cacheServer.Url, serverGVFSConfig); - this.mountProgressMessage = "Resolving cache server"; - CacheServerResolver cacheServerResolver = new CacheServerResolver(this.tracer, this.enlistment); - this.cacheServer = cacheServerResolver.ResolveNameFromRemote(this.cacheServer.Url, serverGVFSConfig); + this.EnsureLocalCacheIsHealthy(serverGVFSConfig); - this.EnsureLocalCacheIsHealthy(serverGVFSConfig); + using (NamedPipeServer pipeServer = this.StartNamedPipe()) + { + this.tracer.RelatedEvent( + EventLevel.Informational, + $"{nameof(this.Mount)}_StartedNamedPipe", + new EventMetadata { { "NamedPipeName", this.enlistment.NamedPipeName } }); - this.mountProgressMessage = "Preparing mount"; this.context = this.CreateContext(); if (this.context.Unattended) @@ -281,7 +274,6 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords) GVFSPlatform.Instance.ConfigureVisualStudio(this.enlistment.GitBinPath, this.tracer); - this.mountProgressMessage = "Starting virtualization"; this.MountAndStartWorkingDirectoryCallbacks(this.cacheServer); try @@ -304,7 +296,6 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords) }, Keywords.Telemetry); - this.mountProgressMessage = null; this.currentState = MountState.Ready; this.unmountEvent.WaitOne(); @@ -484,17 +475,6 @@ private void HandleRequest(ITracer tracer, string request, NamedPipeServer.Conne { NamedPipeMessages.Message message = NamedPipeMessages.Message.FromString(request); - // While mounting, only GetStatus requests are safe — other handlers depend - // on context, fileSystemCallbacks, etc. that aren't initialized yet. - // MountFailed is NOT guarded: HandleUnmountRequest needs to reach the - // "unmount even if mount failed" path so users aren't forced to kill the process. - if (message.Header != NamedPipeMessages.GetStatus.Request && - this.currentState == MountState.Mounting) - { - connection.TrySendResponse(NamedPipeMessages.MountNotReadyResult); - return; - } - try { switch (message.Header) @@ -568,6 +548,8 @@ private void HandleRequest(ITracer tracer, string request, NamedPipeServer.Conne metadata.Add("Header", message.Header); metadata.Add("Exception", e.ToString()); this.tracer.RelatedError(metadata, "HandleRequest: Unhandled exception in request handler"); + + connection.TrySendResponse(NamedPipeMessages.UnknownRequest); } } @@ -912,76 +894,56 @@ private void HandleDownloadObjectRequest(NamedPipeMessages.Message message, Name } else { - try + Stopwatch downloadTime = Stopwatch.StartNew(); + + /* If this is the root tree for a commit that was was just downloaded, assume that more + * trees will be needed soon and download them as well by using the download commit API. + * + * Otherwise, or as a fallback if the commit download fails, download the object directly. + */ + if (this.ShouldDownloadCommitPack(objectSha, out string commitSha) + && this.gitObjects.TryDownloadCommit(commitSha)) { - response = this.DownloadObject(objectSha); + this.DownloadedCommitPack(commitSha); + response = new NamedPipeMessages.DownloadObject.Response(NamedPipeMessages.DownloadObject.SuccessResult); + // FUTURE: Should the stats be updated to reflect all the trees in the pack? + // FUTURE: Should we try to clean up duplicate trees or increase depth of the commit download? } - catch (Exception e) when (e is not OutOfMemoryException) + else if (this.gitObjects.TryDownloadAndSaveObject(objectSha, GVFSGitObjects.RequestSource.NamedPipeMessage) == GitObjects.DownloadAndSaveObjectResult.Success) + { + this.UpdateTreesForDownloadedCommits(objectSha); + response = new NamedPipeMessages.DownloadObject.Response(NamedPipeMessages.DownloadObject.SuccessResult); + } + else { - EventMetadata metadata = new EventMetadata(); - metadata.Add("Area", "Mount"); - metadata.Add("objectSha", objectSha); - metadata.Add("Exception", e.ToString()); - this.tracer.RelatedWarning(metadata, nameof(this.HandleDownloadObjectRequest) + ": Exception downloading object"); - response = new NamedPipeMessages.DownloadObject.Response(NamedPipeMessages.DownloadObject.DownloadFailed); } - } - } - - connection.TrySendResponse(response.CreateMessage()); - } - private NamedPipeMessages.DownloadObject.Response DownloadObject(string objectSha) - { - NamedPipeMessages.DownloadObject.Response response; - Stopwatch downloadTime = Stopwatch.StartNew(); - - /* If this is the root tree for a commit that was was just downloaded, assume that more - * trees will be needed soon and download them as well by using the download commit API. - * - * Otherwise, or as a fallback if the commit download fails, download the object directly. - */ - if (this.ShouldDownloadCommitPack(objectSha, out string commitSha) - && this.gitObjects.TryDownloadCommit(commitSha)) - { - this.DownloadedCommitPack(commitSha); - response = new NamedPipeMessages.DownloadObject.Response(NamedPipeMessages.DownloadObject.SuccessResult); - // FUTURE: Should the stats be updated to reflect all the trees in the pack? - // FUTURE: Should we try to clean up duplicate trees or increase depth of the commit download? - } - else if (this.gitObjects.TryDownloadAndSaveObject(objectSha, GVFSGitObjects.RequestSource.NamedPipeMessage) == GitObjects.DownloadAndSaveObjectResult.Success) - { - this.UpdateTreesForDownloadedCommits(objectSha); - response = new NamedPipeMessages.DownloadObject.Response(NamedPipeMessages.DownloadObject.SuccessResult); - } - else - { - response = new NamedPipeMessages.DownloadObject.Response(NamedPipeMessages.DownloadObject.DownloadFailed); - } - Native.ObjectTypes? objectType; - this.context.Repository.TryGetObjectType(objectSha, out objectType); - this.context.Repository.GVFSLock.Stats.RecordObjectDownload(objectType == Native.ObjectTypes.Blob, downloadTime.ElapsedMilliseconds); + Native.ObjectTypes? objectType; + this.context.Repository.TryGetObjectType(objectSha, out objectType); + this.context.Repository.GVFSLock.Stats.RecordObjectDownload(objectType == Native.ObjectTypes.Blob, downloadTime.ElapsedMilliseconds); - if (objectType == Native.ObjectTypes.Commit - && !this.context.Repository.CommitAndRootTreeExists(objectSha, out var treeSha) - && !string.IsNullOrEmpty(treeSha)) - { - /* If a commit is downloaded, it wasn't prefetched. - * The trees for the commit may be needed soon depending on the context. - * e.g. git log (without a pathspec) doesn't need trees, but git checkout does. - * - * If any prefetch has been done there is probably a similar commit/tree in the graph, - * but in case there isn't (such as if the cache server repack maintenance job is failing) - * we should still try to avoid downloading an excessive number of loose trees for a commit. - * - * Save the tree/commit so if more trees are requested we can download all the trees for the commit in a batch. - */ - this.missingTreeTracker.AddMissingRootTree(treeSha: treeSha, commitSha: objectSha); + if (objectType == Native.ObjectTypes.Commit + && !this.context.Repository.CommitAndRootTreeExists(objectSha, out var treeSha) + && !string.IsNullOrEmpty(treeSha)) + { + /* If a commit is downloaded, it wasn't prefetched. + * The trees for the commit may be needed soon depending on the context. + * e.g. git log (without a pathspec) doesn't need trees, but git checkout does. + * + * If any prefetch has been done there is probably a similar commit/tree in the graph, + * but in case there isn't (such as if the cache server repack maintenance job is failing) + * we should still try to avoid downloading an excessive number of loose trees for a commit. + * + * Save the tree/commit so if more trees are requested we can download all the trees for the commit in a batch. + */ + this.missingTreeTracker.AddMissingRootTree(treeSha: treeSha, commitSha: objectSha); + } + } } - return response; + connection.TrySendResponse(response.CreateMessage()); } private bool ShouldDownloadCommitPack(string objectSha, out string commitSha) @@ -1230,15 +1192,14 @@ private void HandleGetStatusRequest(NamedPipeServer.Connection connection) response.EnlistmentRoot = this.enlistment.WorkingDirectoryRoot; response.LocalCacheRoot = !string.IsNullOrWhiteSpace(this.enlistment.LocalCacheRoot) ? this.enlistment.LocalCacheRoot : this.enlistment.GitObjectsRoot; response.RepoUrl = this.enlistment.RepoUrl; - response.CacheServer = this.cacheServer?.ToString() ?? string.Empty; - response.LockStatus = this.context?.Repository?.GVFSLock != null ? this.context.Repository.GVFSLock.GetStatus() : "Unavailable"; + response.CacheServer = this.cacheServer.ToString(); + response.LockStatus = this.context?.Repository.GVFSLock != null ? this.context.Repository.GVFSLock.GetStatus() : "Unavailable"; response.DiskLayoutVersion = $"{GVFSPlatform.Instance.DiskLayoutUpgrade.Version.CurrentMajorVersion}.{GVFSPlatform.Instance.DiskLayoutUpgrade.Version.CurrentMinorVersion}"; switch (this.currentState) { case MountState.Mounting: response.MountStatus = NamedPipeMessages.GetStatus.Mounting; - response.MountProgress = this.mountProgressMessage; break; case MountState.Ready: diff --git a/GVFS/GVFS.Payload/layout.bat b/GVFS/GVFS.Payload/layout.bat index fbaf9ea7c..14e9e1168 100644 --- a/GVFS/GVFS.Payload/layout.bat +++ b/GVFS/GVFS.Payload/layout.bat @@ -45,7 +45,7 @@ xcopy /Y %VCRUNTIME%\lib\x64\vcruntime140.dll %OUTPUT% xcopy /Y /S %BUILD_OUT%\GVFS\%MANAGED_OUT_FRAGMENT%\* %OUTPUT% xcopy /Y /S %BUILD_OUT%\GVFS.Hooks\%MANAGED_OUT_FRAGMENT%\* %OUTPUT% xcopy /Y /S %BUILD_OUT%\GVFS.Mount\%MANAGED_OUT_FRAGMENT%\* %OUTPUT% -xcopy /Y /S %BUILD_OUT%\GVFS.Service\%MANAGED_OUT_FRAGMENT%\* %OUTPUT% +REM GVFS.Service removed -- no longer part of the user-level install model xcopy /Y /S %BUILD_OUT%\GitHooksLoader\%NATIVE_OUT_FRAGMENT%\* %OUTPUT% xcopy /Y /S %BUILD_OUT%\GVFS.PostIndexChangedHook\%NATIVE_OUT_FRAGMENT%\* %OUTPUT% xcopy /Y /S %BUILD_OUT%\GVFS.ReadObjectHook\%NATIVE_OUT_FRAGMENT%\* %OUTPUT% diff --git a/GVFS/GVFS.UnitTests/Common/LocalRepoRegistryTests.cs b/GVFS/GVFS.UnitTests/Common/LocalRepoRegistryTests.cs new file mode 100644 index 000000000..9f789fddc --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/LocalRepoRegistryTests.cs @@ -0,0 +1,420 @@ +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); + } + + [TestCase] + public void SeedFromSystemRegistry_NoUserRegistry_SeedsAccessibleRepos() + { + (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry(); + + // Create a system registry with three entries + string systemRegistryDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "GVFS", + LocalRepoRegistry.ServiceDataDirName); + string systemRegistryPath = Path.Combine(systemRegistryDir, LocalRepoRegistry.RegistryFileName); + + fs.CreateDirectory(systemRegistryDir); + + string sid1 = Guid.NewGuid().ToString(); + string sid2 = Guid.NewGuid().ToString(); + string sid3 = Guid.NewGuid().ToString(); + + string systemContent = + "2\n" + + "{\"EnlistmentRoot\":\"" + Repo1.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"" + sid1 + "\",\"IsActive\":true}\n" + + "{\"EnlistmentRoot\":\"" + Repo2.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"" + sid2 + "\",\"IsActive\":false}\n" + + "{\"EnlistmentRoot\":\"" + Repo3.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"" + sid3 + "\",\"IsActive\":true}\n"; + + fs.WriteAllText(systemRegistryPath, systemContent); + + // Only Repo1 and Repo2 directories exist + fs.CreateDirectory(Repo1); + fs.CreateDirectory(Repo2); + + // Seed the user registry + registry.SeedFromSystemRegistryIfNeeded(); + + // Verify only Repo1 and Repo2 were seeded (Repo3 directory doesn't exist) + Dictionary all = registry.ReadRegistry(); + all.Count.ShouldEqual(2); + all.ContainsKey(Repo1).ShouldBeTrue(); + all.ContainsKey(Repo2).ShouldBeTrue(); + all.ContainsKey(Repo3).ShouldBeFalse(); + + // Verify the seeded entries preserve their original state + VerifyEntry(all[Repo1], expectedOwnerSID: sid1, expectedIsActive: true); + VerifyEntry(all[Repo2], expectedOwnerSID: sid2, expectedIsActive: false); + } + + [TestCase] + public void SeedFromSystemRegistry_UserRegistryExists_DoesNotOverwrite() + { + (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry(); + + // Create existing user registry + string ownerSID = Guid.NewGuid().ToString(); + registry.TryRegisterRepo(Repo1, ownerSID, out _).ShouldBeTrue(); + string userContentBefore = fs.ReadAllText(Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName)); + + // Create system registry + string systemRegistryDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "GVFS", + LocalRepoRegistry.ServiceDataDirName); + string systemRegistryPath = Path.Combine(systemRegistryDir, LocalRepoRegistry.RegistryFileName); + + fs.CreateDirectory(systemRegistryDir); + fs.WriteAllText(systemRegistryPath, "2\n{\"EnlistmentRoot\":\"" + Repo2.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"other\",\"IsActive\":true}\n"); + fs.CreateDirectory(Repo2); + + // Attempt to seed + registry.SeedFromSystemRegistryIfNeeded(); + + // User registry should be unchanged + string userContentAfter = fs.ReadAllText(Path.Combine(DataLocation, LocalRepoRegistry.RegistryFileName)); + userContentAfter.ShouldEqual(userContentBefore); + + Dictionary all = registry.ReadRegistry(); + all.Count.ShouldEqual(1); + all.ContainsKey(Repo1).ShouldBeTrue(); + all.ContainsKey(Repo2).ShouldBeFalse(); + } + + [TestCase] + public void SeedFromSystemRegistry_NoSystemRegistry_DoesNothing() + { + (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry(); + + // No system registry exists, user registry is empty + registry.SeedFromSystemRegistryIfNeeded(); + + // User registry should still be empty + Dictionary all = registry.ReadRegistry(); + all.Count.ShouldEqual(0); + } + + [TestCase] + public void SeedFromSystemRegistry_EmptySystemRegistry_DoesNothing() + { + (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry(); + + // Create empty system registry + string systemRegistryDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "GVFS", + LocalRepoRegistry.ServiceDataDirName); + string systemRegistryPath = Path.Combine(systemRegistryDir, LocalRepoRegistry.RegistryFileName); + + fs.CreateDirectory(systemRegistryDir); + fs.WriteAllText(systemRegistryPath, "2\n"); + + registry.SeedFromSystemRegistryIfNeeded(); + + Dictionary all = registry.ReadRegistry(); + all.Count.ShouldEqual(0); + } + + [TestCase] + public void SeedFromSystemRegistry_AllReposInaccessible_CreatesEmptyUserRegistry() + { + (LocalRepoRegistry registry, MockFileSystem fs) = this.CreateRegistry(); + + // Create system registry with entries whose directories don't exist + string systemRegistryDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "GVFS", + LocalRepoRegistry.ServiceDataDirName); + string systemRegistryPath = Path.Combine(systemRegistryDir, LocalRepoRegistry.RegistryFileName); + + fs.CreateDirectory(systemRegistryDir); + fs.WriteAllText( + systemRegistryPath, + "2\n{\"EnlistmentRoot\":\"" + Repo1.Replace("\\", "\\\\") + "\",\"OwnerSID\":\"sid\",\"IsActive\":true}\n"); + + // Don't create Repo1 directory + + registry.SeedFromSystemRegistryIfNeeded(); + + // User registry should be empty (no accessible repos to seed) + Dictionary all = registry.ReadRegistry(); + all.Count.ShouldEqual(0); + } + + 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..8bc76fcf1 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/LogonTaskRegistrationTests.cs @@ -0,0 +1,265 @@ +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"; + + [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); + + xml.Contains(LogonTaskRegistration.GvfsPathPlaceholder).ShouldBeFalse("no GVFS_PATH 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(LogonTaskRegistration.TemplateHash).ShouldBeTrue("template hash should appear in the XML"); + } + + [TestCase] + public void BuildTaskXml_ProducesMountAllArguments() + { + string xml = LogonTaskRegistration.BuildTaskXml(TestGvfsPath); + // 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)); + Assert.Catch(() => LogonTaskRegistration.BuildTaskXml("")); + } + + [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); + 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); + 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, 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); + LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker); + + reg.TryRegisterOrUpdate(TestGvfsPath, out _).ShouldBeTrue(); + invoker.RegisterCallCount.ShouldEqual(0); + } + + [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); + LogonTaskRegistration reg = new LogonTaskRegistration(new MockTracer(), invoker); + + reg.TryRegisterOrUpdate(TestGvfsPath, 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, out string error).ShouldBeFalse(); + error.ShouldEqual("Permission denied"); + } + + [TestCase] + public void TryUnregister_DelegatesToInvoker() + { + MockScheduledTaskInvoker invoker = new MockScheduledTaskInvoker(); + invoker.RegisteredTasks[LogonTaskRegistration.FullTaskPath] = + LogonTaskRegistration.BuildTaskXml(TestGvfsPath); + 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 51b693578..036345f09 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) @@ -198,22 +196,6 @@ protected bool ShowStatusWhileRunning( initialDelayMs: 0); } - protected bool ShowStatusWhileRunning( - Func action, - Func getMessage, - string message, - string gvfsLogEnlistmentRoot) - { - return ConsoleHelper.ShowStatusWhileRunning( - action, - getMessage, - message, - this.Output, - showSpinner: !this.Unattended && this.Output == Console.Out && !Console.IsOutputRedirected, - gvfsLogEnlistmentRoot: gvfsLogEnlistmentRoot, - initialDelayMs: 0); - } - protected bool ShowStatusWhileRunning( Func action, string message, @@ -586,8 +568,19 @@ protected bool TryEnableAndAttachPrjFltThroughService(string enlistmentRoot, out { if (!client.Connect()) { - errorMessage = "GVFS.Service is not responding. " + GVFSVerb.StartServiceInstructions; - return false; + // Service not available (user-level install model). Trigger + // the EnableProjFSOnAllDrives scheduled task to ensure + // PrjFlt is attached to this volume. Task failures are not + // fatal — if PrjFlt is truly missing at mount time, the + // mount-process filter check will catch it. + ProcessResult result = ProcessHelper.Run( + "schtasks.exe", + "/Run /TN \"\\GVFS\\EnableProjFSOnAllDrives\""); + + // Task not registered or failed — may be fine if PrjFlt + // is already attached. Continue and let mount validate. + errorMessage = string.Empty; + return true; } try diff --git a/GVFS/GVFS/CommandLine/MountVerb.cs b/GVFS/GVFS/CommandLine/MountVerb.cs index 53077bba4..03f083149 100644 --- a/GVFS/GVFS/CommandLine/MountVerb.cs +++ b/GVFS/GVFS/CommandLine/MountVerb.cs @@ -14,7 +14,6 @@ public class MountVerb : GVFSVerb.ForExistingEnlistment { private const string MountVerbName = "mount"; private Process mountProcess; - private volatile string currentMountProgress; public string Verbosity { get; set; } @@ -198,9 +197,7 @@ protected override void Execute(GVFSEnlistment enlistment) if (!this.ShowStatusWhileRunning( () => { return this.TryMount(tracer, enlistment, mountExecutableLocation, out errorMessage); }, - getMessage: () => this.currentMountProgress, - "Mounting", - enlistment.WorkingDirectoryRoot)) + "Mounting")) { ReturnCode mountExitCode = ReturnCode.GenericError; if (this.mountProcess != null) @@ -238,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,35 +277,42 @@ private bool TryMount(ITracer tracer, GVFSEnlistment enlistment, string mountExe tracer.RelatedInfo($"{nameof(this.TryMount)}: Waiting for repo to be mounted"); - return GVFSEnlistment.WaitUntilMounted( - tracer, - enlistment.NamedPipeName, - enlistment.WorkingDirectoryRoot, - this.Unattended, - out errorMessage, - onProgress: progress => this.currentMountProgress = progress); + 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 +}