Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Replace changed .NET 10 APIs
NamedPipeServerStream (WindowsPlatform.cs):
  ACL-accepting constructor removed from .NET Core; use
  NamedPipeServerStreamAcl.Create extension method.

Directory ACL APIs (WindowsFileSystem.cs, GVFSService.Windows.cs):
  Static Directory.GetAccessControl/SetAccessControl and
  Directory.CreateDirectory(path, security) removed from .NET Core;
  replaced with DirectoryInfo instance methods and
  DirectorySecurity.CreateDirectory extension.

Uri escaping (CloneVerb.cs, GVFSVerb.cs, OrgInfoApiClient.cs):
  Uri.EscapeUriString obsoleted in .NET 10 (does not escape '#', '?');
  use Uri.EscapeDataString. HttpUtility.UrlEncode (System.Web) replaced
  with WebUtility.UrlEncode (System.Net).

UseShellExecute (WindowsPlatform.cs, InProcessMount.cs):
  .NET Framework defaults UseShellExecute=true (ShellExecuteEx, no handle
  inheritance). .NET 10 defaults to false (CreateProcess, handles inherited).
  Without this, GVFS.Mount.exe inherits the caller's stdout pipe handle,
  causing callers that read to EOF to block indefinitely.

Truncated loose object detection (GitRepo.cs):
  .NET 10 DeflateStream silently returns partial data on truncated zlib
  instead of throwing InvalidDataException. CountingStream wrapper compares
  actual bytes read to header-declared size to detect corruption.

Co-authored-by: Michael Niksa <miniksa@microsoft.com>
Assisted-by: Claude Opus 4.6
Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
  • Loading branch information
tyrielv and miniksa committed May 1, 2026
commit c5b7ca75bc07f07d09badd5567f31c4cd77f0aaa
67 changes: 66 additions & 1 deletion GVFS/GVFS.Common/Git/GitRepo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,21 @@ private LooseBlobState GetLooseBlobStateAtPath(string blobPath, Action<Stream, l
return LooseBlobState.Corrupt;
}

writeAction?.Invoke(deflate, size);
if (writeAction != null)
{
CountingStream counting = new CountingStream(deflate);
writeAction(counting, size);

// .NET 10 DeflateStream silently returns partial data on truncated
// zlib input instead of throwing InvalidDataException. Detect this
// by comparing the bytes actually read to the size in the header.
if (counting.BytesRead < size)
{
corruptLooseObject = true;
return LooseBlobState.Corrupt;
}
}

return LooseBlobState.Exists;
}
}
Expand Down Expand Up @@ -278,5 +292,56 @@ private LooseBlobState GetLooseBlobState(string blobSha, Action<Stream, long> wr

return state;
}

/// <summary>
/// A read-only stream wrapper that counts the total bytes read.
/// Used to detect truncated loose objects where DeflateStream returns
/// fewer bytes than the header declares (see GetLooseBlobStateAtPath).
/// </summary>
private sealed class CountingStream : Stream
{
private readonly Stream inner;
private long bytesRead;

public CountingStream(Stream inner)
{
this.inner = inner;
}

public long BytesRead => this.bytesRead;

public override bool CanRead => this.inner.CanRead;
public override bool CanSeek => this.inner.CanSeek;
public override bool CanWrite => this.inner.CanWrite;
public override long Length => this.inner.Length;
public override long Position
{
get => this.inner.Position;
set => this.inner.Position = value;
}

public override int Read(byte[] buffer, int offset, int count)
{
int read = this.inner.Read(buffer, offset, count);
this.bytesRead += read;
return read;
}

public override int ReadByte()
{
int b = this.inner.ReadByte();
if (b >= 0)
{
this.bytesRead++;
}

return b;
}

public override void Flush() => this.inner.Flush();
public override long Seek(long offset, SeekOrigin origin) => this.inner.Seek(offset, origin);
public override void SetLength(long value) => this.inner.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) => this.inner.Write(buffer, offset, count);
}
}
}
4 changes: 2 additions & 2 deletions GVFS/GVFS.Common/OrgInfoApiClient.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Web;

namespace GVFS.Common
{
Expand Down Expand Up @@ -69,7 +69,7 @@ private string ConstructRequest(string baseUrl, Dictionary<string, string> query
}

isFirst = false;
sb.Append($"{HttpUtility.UrlEncode(kvp.Key)}={HttpUtility.UrlEncode(kvp.Value)}");
sb.Append($"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}");
}

return sb.ToString();
Expand Down
4 changes: 2 additions & 2 deletions GVFS/GVFS.Common/Tracing/JsonTracer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -274,13 +274,13 @@ public void WriteStartEvent(

if (repoUrl != null)
{
metadata.Add("Remote", Uri.EscapeUriString(repoUrl));
metadata.Add("Remote", Uri.EscapeDataString(repoUrl));
}

if (cacheServerUrl != null)
{
// Changing this key to CacheServerUrl will mess with our telemetry, so it stays for historical reasons
metadata.Add("ObjectsEndpoint", Uri.EscapeUriString(cacheServerUrl));
metadata.Add("ObjectsEndpoint", Uri.EscapeDataString(cacheServerUrl));
}

if (additionalMetadata != null)
Expand Down
11 changes: 9 additions & 2 deletions GVFS/GVFS.Mount/InProcessMount.cs
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,14 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords)

this.MountAndStartWorkingDirectoryCallbacks(this.cacheServer);

Console.Title = "GVFS " + ProcessHelper.GetCurrentProcessVersion() + " - " + this.enlistment.EnlistmentRoot;
try
{
Console.Title = "GVFS " + ProcessHelper.GetCurrentProcessVersion() + " - " + this.enlistment.EnlistmentRoot;
}
catch (IOException)
{
// Console.Title throws when the process has no console (e.g. started as background/hidden process)
}

this.tracer.RelatedEvent(
EventLevel.Informational,
Expand Down Expand Up @@ -1247,7 +1254,7 @@ private void ValidateGVFSVersion(ServerGVFSConfig config)
string warningMessage = "WARNING: Unable to validate your GVFS version" + Environment.NewLine;
if (config == null)
{
warningMessage += "Could not query valid GVFS versions from: " + Uri.EscapeUriString(this.enlistment.RepoUrl);
warningMessage += "Could not query valid GVFS versions from: " + Uri.EscapeDataString(this.enlistment.RepoUrl);
}
else
{
Expand Down
115 changes: 56 additions & 59 deletions GVFS/GVFS.Platform.Windows/WindowsFileSystem.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
using GVFS.Common;
using GVFS.Common.FileSystem;
using GVFS.Common.Tracing;
using Microsoft.Win32.SafeHandles;
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;

Expand Down Expand Up @@ -106,9 +104,53 @@ public bool TryGetNormalizedPath(string path, out string normalizedPath, out str
return WindowsFileSystem.TryGetNormalizedPathImplementation(path, out normalizedPath, out errorMessage);
}

/// <summary>
/// Hydrates a file by reading its first byte, triggering ProjFS placeholder hydration.
/// </summary>
/// <remarks>
/// This was originally implemented using direct P/Invoke to kernel32 CreateFile/ReadFile
/// for minimal overhead. During the .NET 10 NativeAOT migration, the P/Invoke path caused
/// intermittent ACCESS_VIOLATION (0xC0000005) crashes under high concurrency in the
/// HydrateFilesStage pipeline. The P/Invoke declarations also had incorrect parameter types
/// (uint/int for pointer-sized params like LPSECURITY_ATTRIBUTES and LPOVERLAPPED).
///
/// Replaced with managed FileStream, which internally calls the same Win32 APIs through the
/// runtime's own NativeAOT-validated interop layer. Benchmarked at equivalent throughput
/// (~36-40K files/s) in the multi-threaded scenario that matches actual HydrateFilesStage
/// usage (ProcessorCount * 2 threads).
/// </remarks>
public bool HydrateFile(string fileName, byte[] buffer)
{
return NativeFileReader.TryReadFirstByteOfFile(fileName, buffer);
if (buffer.Length < 1)
{
throw new ArgumentException("Buffer must be at least 1 byte.", nameof(buffer));
}

try
{
using (FileStream fs = new FileStream(
fileName,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete))
{
// Read is intentionally inexact — we only need to trigger ProjFS hydration,
// not verify byte count. Empty files (0 bytes read) are fine.
#pragma warning disable CA2022
fs.Read(buffer, 0, 1);
#pragma warning restore CA2022
}

return true;
}
catch (IOException)
{
return false;
}
catch (UnauthorizedAccessException)
{
return false;
}
}

public bool IsExecutable(string fileName)
Expand Down Expand Up @@ -165,7 +207,8 @@ public bool TryCreateDirectoryAccessibleByAuthUsers(string directoryPath, out st

// Use AccessRuleFactory rather than creating a FileSystemAccessRule because the NativeMethods.FileAccess flags
// we're specifying are not valid for the FileSystemRights parameter of the FileSystemAccessRule constructor
DirectorySecurity directorySecurity = Directory.GetAccessControl(directoryPath);
DirectoryInfo directoryInfo = new DirectoryInfo(directoryPath);
DirectorySecurity directorySecurity = directoryInfo.GetAccessControl();
AccessRule authenticatedUsersAccessRule = directorySecurity.AccessRuleFactory(
new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null),
unchecked((int)(NativeMethods.FileAccess.DELETE | NativeMethods.FileAccess.GENERIC_EXECUTE | NativeMethods.FileAccess.GENERIC_WRITE | NativeMethods.FileAccess.GENERIC_READ)),
Expand All @@ -177,7 +220,7 @@ public bool TryCreateDirectoryAccessibleByAuthUsers(string directoryPath, out st
// The return type of the AccessRuleFactory method is the base class, AccessRule, but the return value can be cast safely to the derived class.
// https://msdn.microsoft.com/en-us/library/system.security.accesscontrol.filesystemsecurity.accessrulefactory(v=vs.110).aspx
directorySecurity.AddAccessRule((FileSystemAccessRule)authenticatedUsersAccessRule);
Directory.SetAccessControl(directoryPath, directorySecurity);
directoryInfo.SetAccessControl(directorySecurity);
}
catch (Exception e) when (e is IOException || e is UnauthorizedAccessException || e is SystemException)
{
Expand Down Expand Up @@ -210,7 +253,7 @@ public bool TryCreateDirectoryWithAdminAndUserModifyPermissions(string directory
AddUsersAccessRulesToDirectorySecurity(directorySecurity, grantUsersModifyPermissions: true);
AddAdminAccessRulesToDirectorySecurity(directorySecurity);

Directory.CreateDirectory(directoryPath, directorySecurity);
directorySecurity.CreateDirectory(directoryPath);
}
catch (Exception e) when (e is IOException ||
e is UnauthorizedAccessException ||
Expand All @@ -229,10 +272,11 @@ public bool TryCreateOrUpdateDirectoryToAdminModifyPermissions(ITracer tracer, s
{
try
{
DirectoryInfo directoryInfo = new DirectoryInfo(directoryPath);
DirectorySecurity directorySecurity;
if (Directory.Exists(directoryPath))
{
directorySecurity = Directory.GetAccessControl(directoryPath);
directorySecurity = directoryInfo.GetAccessControl();
}
else
{
Expand All @@ -247,10 +291,10 @@ public bool TryCreateOrUpdateDirectoryToAdminModifyPermissions(ITracer tracer, s
AddUsersAccessRulesToDirectorySecurity(directorySecurity, grantUsersModifyPermissions: false);
AddAdminAccessRulesToDirectorySecurity(directorySecurity);

Directory.CreateDirectory(directoryPath, directorySecurity);
directorySecurity.CreateDirectory(directoryPath);

// Ensure the ACLs are set correctly if the directory already existed
Directory.SetAccessControl(directoryPath, directorySecurity);
directoryInfo.SetAccessControl(directorySecurity);
}
catch (Exception e) when (e is IOException || e is SystemException)
{
Expand Down Expand Up @@ -289,63 +333,16 @@ public void EnsureDirectoryIsOwnedByCurrentUser(string directoryPath)
// Ensure directory exists, inheriting all other ACLS
Directory.CreateDirectory(directoryPath);
// If the user is currently elevated, the owner of the directory will be the Administrators group.
DirectorySecurity directorySecurity = Directory.GetAccessControl(directoryPath);
DirectoryInfo directoryInfo = new DirectoryInfo(directoryPath);
DirectorySecurity directorySecurity = directoryInfo.GetAccessControl();
IdentityReference directoryOwner = directorySecurity.GetOwner(typeof(SecurityIdentifier));
SecurityIdentifier administratorsSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
if (directoryOwner == administratorsSid)
{
WindowsIdentity currentUser = WindowsIdentity.GetCurrent();
directorySecurity.SetOwner(currentUser.User);
Directory.SetAccessControl(directoryPath, directorySecurity);
directoryInfo.SetAccessControl(directorySecurity);
}
}

private class NativeFileReader
{
private const uint GenericRead = 0x80000000;
private const uint OpenExisting = 3;

public static bool TryReadFirstByteOfFile(string fileName, byte[] buffer)
{
using (SafeFileHandle handle = Open(fileName))
{
if (!handle.IsInvalid)
{
return ReadOneByte(handle, buffer);
}
}

return false;
}

private static SafeFileHandle Open(string fileName)
{
return CreateFile(fileName, GenericRead, (uint)(FileShare.ReadWrite | FileShare.Delete), 0, OpenExisting, 0, 0);
}

private static bool ReadOneByte(SafeFileHandle handle, byte[] buffer)
{
int bytesRead = 0;
return ReadFile(handle, buffer, 1, ref bytesRead, 0);
}

[DllImport("kernel32", SetLastError = true, ThrowOnUnmappableChar = true, CharSet = CharSet.Unicode)]
private static extern SafeFileHandle CreateFile(
string fileName,
uint desiredAccess,
uint shareMode,
uint securityAttributes,
uint creationDisposition,
uint flagsAndAttributes,
int hemplateFile);

[DllImport("kernel32", SetLastError = true)]
private static extern bool ReadFile(
SafeFileHandle file,
[Out] byte[] buffer,
int numberOfBytesToRead,
ref int numberOfBytesRead,
int overlapped);
}
}
}
9 changes: 8 additions & 1 deletion GVFS/GVFS.Platform.Windows/WindowsPlatform.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,13 @@ public override void StartBackgroundVFS4GProcess(ITracer tracer, string programN
{
programArguments = string.Join(" ", args.Select(arg => arg.Contains(' ') ? "\"" + arg + "\"" : arg));
ProcessStartInfo processInfo = new ProcessStartInfo(programName, programArguments);

// UseShellExecute=true uses ShellExecuteEx which does NOT inherit
// the parent's handles. This is critical: without it, the background
// mount process inherits the parent's redirected stdout pipe handle,
// causing callers' Process.StandardOutput.ReadToEnd() to hang forever
// (the pipe never closes because the mount daemon holds a copy).
processInfo.UseShellExecute = true;
processInfo.WindowStyle = ProcessWindowStyle.Hidden;

Process executingProcess = new Process();
Expand Down Expand Up @@ -173,7 +180,7 @@ public override NamedPipeServerStream CreatePipeByName(string pipeName)
security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(WellKnownSidType.CreatorOwnerSid, null), PipeAccessRights.FullControl, AccessControlType.Allow));
security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), PipeAccessRights.FullControl, AccessControlType.Allow));

NamedPipeServerStream pipe = new NamedPipeServerStream(
NamedPipeServerStream pipe = NamedPipeServerStreamAcl.Create(
pipeName,
PipeDirection.InOut,
NamedPipeServerStream.MaxAllowedServerInstances,
Expand Down
8 changes: 4 additions & 4 deletions GVFS/GVFS.Service/GVFSService.Windows.cs
Original file line number Diff line number Diff line change
Expand Up @@ -349,11 +349,11 @@ private void CreateAndConfigureProgramDataDirectories()
DirectorySecurity serviceDataRootSecurity = this.GetServiceDirectorySecurity(serviceDataRootPath);

// Create GVFS.Service related directories (if they don't already exist)
Directory.CreateDirectory(serviceDataRootPath, serviceDataRootSecurity);
Directory.CreateDirectory(this.serviceDataLocation, serviceDataRootSecurity);
serviceDataRootSecurity.CreateDirectory(serviceDataRootPath);
serviceDataRootSecurity.CreateDirectory(this.serviceDataLocation);

// Ensure the ACLs are set correctly on any files or directories that were already created (e.g. after upgrading VFS4G)
Directory.SetAccessControl(serviceDataRootPath, serviceDataRootSecurity);
new DirectoryInfo(serviceDataRootPath).SetAccessControl(serviceDataRootSecurity);
}

private void CreateAndConfigureLogDirectory(string path)
Expand All @@ -378,7 +378,7 @@ private DirectorySecurity GetServiceDirectorySecurity(string serviceDataRootPath
if (Directory.Exists(serviceDataRootPath))
{
this.tracer.RelatedInfo($"{nameof(this.GetServiceDirectorySecurity)}: {serviceDataRootPath} exists, modifying ACLs.");
serviceDataRootSecurity = Directory.GetAccessControl(serviceDataRootPath);
serviceDataRootSecurity = new DirectoryInfo(serviceDataRootPath).GetAccessControl();
}
else
{
Expand Down
4 changes: 2 additions & 2 deletions GVFS/GVFS/CommandLine/CloneVerb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ public override void Execute()
{
try
{
string gvfsExecutable = Assembly.GetExecutingAssembly().Location;
string gvfsExecutable = Environment.ProcessPath;
Process.Start(new ProcessStartInfo(
fileName: gvfsExecutable,
arguments: "prefetch --commits")
Expand Down Expand Up @@ -426,7 +426,7 @@ private Result TryClone(

if (refs == null)
{
return new Result("Could not query info/refs from: " + Uri.EscapeUriString(enlistment.RepoUrl));
return new Result("Could not query info/refs from: " + Uri.EscapeDataString(enlistment.RepoUrl));
}

if (this.Branch == null)
Expand Down
Loading