Skip to content

Service: add versioned install layout support#1994

Merged
tyrielv merged 1 commit into
microsoft:masterfrom
tyrielv:tyrielv/versioned-install-layout
Jun 1, 2026
Merged

Service: add versioned install layout support#1994
tyrielv merged 1 commit into
microsoft:masterfrom
tyrielv:tyrielv/versioned-install-layout

Conversation

@tyrielv
Copy link
Copy Markdown
Contributor

@tyrielv tyrielv commented Jun 1, 2026

Summary

Adds versioned install layout awareness to GVFS.Service — groundwork for non-disruptive upgrades.

Changes

Configuration.cs:

  • New CurrentVersionPath property resolves {app}\Current junction when present, falls back to flat layout
  • GVFSLocation is now a computed property (not cached in constructor) so junction re-targeting takes effect without service restart
  • AssemblyPath uses Lazy<string> for thread-safe initialization

PendingUpgradeHandler.cs:

  • GetInstalledMountProcesses now recognizes mount processes from any Versions\ subdirectory (not just flat {app}\GVFS.Mount.exe)
  • New IsInstalledMountPath helper validates both filename and install-root prefix

Backward Compatibility

Fully backward-compatible. On existing flat installs:

  • CurrentVersionPath → no Current dir → falls back to AssemblyPath → identical behavior
  • GVFSLocation → same path as before
  • Mount process detection → same match as before

818 unit tests pass.

Context

This is the first step toward a Chrome-like versioned install layout:

C:\Program Files\VFS for Git\
  GVFS.Service.exe            ← stays at root (SCM needs stable path)
  gvfs.exe                    ← shim (future PR)
  Versions\
    1.0.X\  ...               ← old version (running)
    1.0.Y\  ...               ← new version (installed)
  Current -> Versions\1.0.Y   ← junction

…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>
@tyrielv tyrielv marked this pull request as ready for review June 1, 2026 22:22
@tyrielv tyrielv merged commit cfbecff into microsoft:master Jun 1, 2026
51 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants