Service: add versioned install layout support#1994
Merged
tyrielv merged 1 commit intoJun 1, 2026
Conversation
…ndingUpgradeHandler Add CurrentVersionPath to Configuration that resolves a Current\ junction under the install root, falling back to the flat layout when no junction exists. GVFSLocation is now computed dynamically so junction re-targeting takes effect without restarting the service. Update PendingUpgradeHandler.GetInstalledMountProcesses to recognize mount processes from any Versions\ subdirectory, not just the flat install root. These changes are backward-compatible: on existing flat installs, CurrentVersionPath falls back to AssemblyPath and all behavior is identical. This is groundwork for a versioned install layout (Plan 002) where each version lives in its own directory and a Current junction points to the active version, enabling non-disruptive upgrades. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
derrickstolee
approved these changes
Jun 1, 2026
This was referenced Jun 3, 2026
Merged
tyrielv
added a commit
to tyrielv/VFSForGit
that referenced
this pull request
Jun 4, 2026
Add an upgrade flow that lets the user run `gvfs upgrade <installer-path> [--allow-unsigned]` from a non-elevated shell and have the already-elevated GVFS.Service launch the installer on the caller's behalf. Removes the UAC prompt that the old upgrade path required on every install. Builds on PR microsoft#1994 (merged) which added the versioned install layout and mount-process detection for Versions\ subdirs. How it works: - CLI: New UpgradeVerb sends a RunInstallerRequest over the existing GVFS.Service named pipe. Returns immediately after the service confirms the installer launched ("Upgrade started"). - Service: New RunInstallerHandler verifies the installer, then launches it detached with /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /STAGEIFMOUNTED=true /LOG=<path>. Does NOT WaitForExit() — the installer stops GVFS.Service as part of its upgrade flow, so waiting would deadlock. - Staging: /STAGEIFMOUNTED=true is non-disruptive. With mounts active, the installer stages binaries to PendingUpgrade\ instead of unmounting. The existing PendingUpgradeHandler applies the staged upgrade on the next unmount. - Capability detection: Pre-2.1 services don't know the new RunInstallerRequest header and respond with "UnknownRequest". The verb detects this and emits a clear "your GVFS service is too old; install a newer GVFS first" error instead of a JSON deserialization stack trace. Clients can also probe the capability via `gvfs version` (>=2.1.x supports this flow). Security model — the service runs as LocalSystem and its named pipe is ACL'd for BUILTIN\Users, so the handler must assume the caller is untrusted: - Authenticode: Verified via WinVerifyTrust (WINTRUST_ACTION_GENERIC_VERIFY_V2) — the only Win32 API that actually checks the file's signed digest against its contents. Extracting just the signer cert (X509Certificate.CreateFromSignedFile + X509Chain.Build) is NOT sufficient: it leaves a tampered binary with an intact signature blob fully accepted. Maps the common HRESULTs (TRUST_E_NOSIGNATURE, TRUST_E_BAD_DIGEST, ...) to specific user-facing errors. - Publisher: Exact-string match against certificate.GetNameInfo(X509NameType.SimpleName) == "Microsoft Corporation". Avoids substring-collision attacks such as CN="Microsoft Corporation Ltd" or DNs that place the attacker's CN alongside "Microsoft Corporation" in other fields (a Subject.Contains check would accept either). - Product identity: PE ProductName must equal "VFS for Git", checked even when --allow-unsigned is in effect. Rejects other Microsoft-signed binaries (notepad.exe etc.) at the Authenticode-trusted but wrong-product stage. - --allow-unsigned: Available in DEBUG builds only. In release builds the CLI does not register the option and the service rejects AllowUnsigned=true requests outright (defense-in-depth against a hand-crafted pipe request bypassing the CLI). In debug builds the service additionally impersonates the pipe client (NamedPipeServerStream.RunAsClient, exposed via Connection.TryRunAsClient) and rejects unless the caller is in BUILTIN\Administrators. Without this gate, any local non-admin user could stamp ProductName="VFS for Git" onto an arbitrary binary and get LocalSystem code execution. - TOCTOU: The handler opens the installer with FileShare.Read (no SHARE_WRITE, no SHARE_DELETE) at the start of Run() and holds the handle across verify and Process.Start. On Windows this blocks any rename, delete, or write of the path, closing the window where an attacker could swap the file between verification and launch. Other notes: - GVFSJsonContext registers RunInstallerRequest and its Response for source-generated System.Text.Json serialization (NativeAOT-compatible). - RunInstallerHandler creates a fresh EventMetadata for each tracer call via CreateBaseMetadata(). JsonTracer mutates the passed-in metadata by adding the "Message" key, so a single shared instance would throw a duplicate-key exception on the second tracer call — and the throw was being caught and reported back as a false-positive "Upgrade failed" even though the installer had launched successfully. This matches the fresh-per-call pattern used by GetActiveRepoListHandler and RequestHandler. - Pipeline version bumped 2.0 -> 2.1 so the "service supports UAC-free upgrade" capability is detectable from the version number alone. Testing (manual end-to-end on Windows): - Unsigned dev installer without --allow-unsigned -> rejected ("Installer is not signed") - notepad.exe -> rejected by ProductName check - --allow-unsigned from non-admin shell (DEBUG build) -> rejected ("requires Administrator privileges") - Tampered Microsoft-signed installer (one byte flipped) -> rejected ("Authenticode hash does not match — file has been tampered with") - Real signed SetupGVFS.2.0.26147.6.exe with mounts active -> accepted, stages 23 files to PendingUpgrade\, mount status stays Ready - Unmount after staging -> PendingUpgradeHandler applies the staged upgrade within ~5s, version updates accordingly - 818 unit tests pass Assisted-by: Claude Opus 4.7 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
tyrielv
added a commit
to tyrielv/VFSForGit
that referenced
this pull request
Jun 4, 2026
Add an upgrade flow that lets the user run `gvfs upgrade <installer-path> [--allow-unsigned]` from a non-elevated shell and have the already-elevated GVFS.Service launch the installer on the caller's behalf. Removes the UAC prompt that the old upgrade path required on every install. Builds on PR microsoft#1994 (merged) which added the versioned install layout and mount-process detection for Versions\ subdirs. How it works: - CLI: New UpgradeVerb sends a RunInstallerRequest over the existing GVFS.Service named pipe. Returns immediately after the service confirms the installer launched ("Upgrade started"). - Service: New RunInstallerHandler verifies the installer, then launches it detached with /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /STAGEIFMOUNTED=true /LOG=<path>. Does NOT WaitForExit() — the installer stops GVFS.Service as part of its upgrade flow, so waiting would deadlock. - Staging: /STAGEIFMOUNTED=true is non-disruptive. With mounts active, the installer stages binaries to PendingUpgrade\ instead of unmounting. The existing PendingUpgradeHandler applies the staged upgrade on the next unmount. - Capability detection: Pre-2.1 services don't know the new RunInstallerRequest header and respond with "UnknownRequest". The verb detects this and emits a clear "your GVFS service is too old; install a newer GVFS first" error instead of a JSON deserialization stack trace. Clients can also probe the capability via `gvfs version` (>=2.1.x supports this flow). Security model — the service runs as LocalSystem and its named pipe is ACL'd for BUILTIN\Users, so the handler must assume the caller is untrusted: - Authenticode: Verified via WinVerifyTrust (WINTRUST_ACTION_GENERIC_VERIFY_V2) — the only Win32 API that actually checks the file's signed digest against its contents. Extracting just the signer cert (X509Certificate.CreateFromSignedFile + X509Chain.Build) is NOT sufficient: it leaves a tampered binary with an intact signature blob fully accepted. Maps the common HRESULTs (TRUST_E_NOSIGNATURE, TRUST_E_BAD_DIGEST, ...) to specific user-facing errors. - Publisher: Exact-string match against certificate.GetNameInfo(X509NameType.SimpleName) == "Microsoft Corporation". Avoids substring-collision attacks such as CN="Microsoft Corporation Ltd" or DNs that place the attacker's CN alongside "Microsoft Corporation" in other fields (a Subject.Contains check would accept either). - Product identity: PE ProductName must equal "VFS for Git", checked even when --allow-unsigned is in effect. Rejects other Microsoft-signed binaries (notepad.exe etc.) at the Authenticode-trusted but wrong-product stage. - --allow-unsigned: Available in DEBUG builds only. In release builds the CLI does not register the option and the service rejects AllowUnsigned=true requests outright (defense-in-depth against a hand-crafted pipe request bypassing the CLI). In debug builds the service additionally impersonates the pipe client (NamedPipeServerStream.RunAsClient, exposed via Connection.TryRunAsClient) and rejects unless the caller is in BUILTIN\Administrators. Without this gate, any local non-admin user could stamp ProductName="VFS for Git" onto an arbitrary binary and get LocalSystem code execution. - TOCTOU: The handler opens the installer with FileShare.Read (no SHARE_WRITE, no SHARE_DELETE) at the start of Run() and holds the handle across verify and Process.Start. On Windows this blocks any rename, delete, or write of the path, closing the window where an attacker could swap the file between verification and launch. Other notes: - GVFSJsonContext registers RunInstallerRequest and its Response for source-generated System.Text.Json serialization (NativeAOT-compatible). - RunInstallerHandler creates a fresh EventMetadata for each tracer call via CreateBaseMetadata(). JsonTracer mutates the passed-in metadata by adding the "Message" key, so a single shared instance would throw a duplicate-key exception on the second tracer call — and the throw was being caught and reported back as a false-positive "Upgrade failed" even though the installer had launched successfully. This matches the fresh-per-call pattern used by GetActiveRepoListHandler and RequestHandler. - Pipeline version bumped 2.0 -> 2.1 so the "service supports UAC-free upgrade" capability is detectable from the version number alone. Testing (manual end-to-end on Windows): - Unsigned dev installer without --allow-unsigned -> rejected ("Installer is not signed") - notepad.exe -> rejected by ProductName check - --allow-unsigned from non-admin shell (DEBUG build) -> rejected ("requires Administrator privileges") - Tampered Microsoft-signed installer (one byte flipped) -> rejected ("Authenticode hash does not match — file has been tampered with") - Real signed SetupGVFS.2.0.26147.6.exe with mounts active -> accepted, stages 23 files to PendingUpgrade\, mount status stays Ready - Unmount after staging -> PendingUpgradeHandler applies the staged upgrade within ~5s, version updates accordingly - 818 unit tests pass Assisted-by: Claude Opus 4.7 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds versioned install layout awareness to
GVFS.Service— groundwork for non-disruptive upgrades.Changes
Configuration.cs:
CurrentVersionPathproperty resolves{app}\Currentjunction when present, falls back to flat layoutGVFSLocationis now a computed property (not cached in constructor) so junction re-targeting takes effect without service restartAssemblyPathusesLazy<string>for thread-safe initializationPendingUpgradeHandler.cs:
GetInstalledMountProcessesnow recognizes mount processes from anyVersions\subdirectory (not just flat{app}\GVFS.Mount.exe)IsInstalledMountPathhelper validates both filename and install-root prefixBackward Compatibility
Fully backward-compatible. On existing flat installs:
CurrentVersionPath→ noCurrentdir → falls back toAssemblyPath→ identical behaviorGVFSLocation→ same path as before818 unit tests pass.
Context
This is the first step toward a Chrome-like versioned install layout: