Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
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
32 changes: 25 additions & 7 deletions GVFS/FastFetch/FastFetchVerb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,15 @@ private int ExecuteWithExitCode()
}

// Check this before any code that reads or writes refs assuming the "files"
// ref storage format runs, since FastFetch can be pointed at an arbitrary
// pre-existing git repository rather than only ones 'gvfs clone' created.
// A genuine config-read failure (as opposed to the key simply being absent,
// which reports the repository as files-format) is treated as fatal here: a
// missing key does not reach this branch, so a read error means the repo's
// config is anomalous and continuing risks operating on an unsupported repo.
if (!RefStorage.TryIsReftableRepo(enlistment.CreateGitProcess(), out bool isReftableRepo, out string refStorageReadError))
// ref storage format, or any code that assumes SHA1-shaped object ids, runs -
// since FastFetch can be pointed at an arbitrary pre-existing git repository
// rather than only ones 'gvfs clone' created. A genuine ref-storage config-read
// failure (as opposed to the key simply being absent, which reports the
// repository as files-format) is treated as fatal here: a missing key does not
// reach this branch, so a read error means the repo's config is anomalous and
// continuing risks operating on an unsupported repo.
GitProcess git = enlistment.CreateGitProcess();
if (!RefStorage.TryIsReftableRepo(git, out bool isReftableRepo, out string refStorageReadError))
{
Console.WriteLine("Could not determine the repository's ref storage format: " + refStorageReadError);
return ExitFailure;
Expand All @@ -222,6 +224,22 @@ private int ExecuteWithExitCode()
return ExitFailure;
}

// Same rationale as above, for SHA256: a genuine config-read failure (as
// opposed to the key simply being absent, which reports the repository as
// SHA1) is treated as fatal here: a missing key does not reach this branch,
// so a read error means the repo's config is anomalous and continuing risks
// operating on an unsupported repo.
if (!ObjectFormat.TryIsSha256Repo(git, out bool isSha256Repo, out string objectFormatReadError))
{
Console.WriteLine("Could not determine the repository's object format: " + objectFormatReadError);
return ExitFailure;
}
else if (isSha256Repo)
{
Console.WriteLine(ObjectFormat.UnsupportedSha256ErrorMessage);
return ExitFailure;
}

string commitish = this.Commit ?? this.Branch;
if (string.IsNullOrWhiteSpace(commitish))
{
Expand Down
17 changes: 14 additions & 3 deletions GVFS/GVFS.Common/GVFSEnlistment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,10 @@ public override string GitHeadLogPath
public string GitStatusCacheFolder { get; private set; }
public string GitStatusCachePath { get; private set; }

// These version properties are only used in logging during clone and mount to track version numbers
// GitVersion is load-bearing, not just logging: CloneVerb.CreateClone re-parses it
Comment thread
tyrielv marked this conversation as resolved.
// to decide whether to pin 'git init' to --ref-format=files (GitProcess.Init). The
// other two (GVFSVersion, GVFSHooksVersion) remain logging-only, to track version
// numbers during clone and mount.
public string GitVersion
{
get { return this.gitVersion; }
Expand Down Expand Up @@ -311,8 +314,16 @@ public static bool WaitUntilMounted(
}
catch (BrokenPipeException e)
{
errorMessage = string.Format("Could not connect to GVFS.Mount: {0}", e);
tracer.RelatedError($"{nameof(WaitUntilMounted)}: {errorMessage}");
// Keep the console-facing message short - the mount process may have
// exited legitimately (e.g. it already logged its own clear, specific
// failure reason and called Environment.Exit, which is enough to break
// an in-flight pipe write/read here). The full exception detail still
// goes to this trace, and 'gvfs log' points users at the mount
// process's own log, where the real root cause is recorded.
errorMessage = string.Format("Could not connect to GVFS.Mount: {0}", e.Message);
tracer.RelatedError(
new EventMetadata { { "Exception", e.ToString() } },
$"{nameof(WaitUntilMounted)}: {errorMessage}");
Comment thread
tyrielv marked this conversation as resolved.
return false;
}
catch (JsonException e)
Expand Down
92 changes: 92 additions & 0 deletions GVFS/GVFS.Common/Git/ObjectFormat.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System;

namespace GVFS.Common.Git
{
/// <summary>
/// Detects whether a git repository was initialized with the SHA256 object format
/// (git's newer, opt-in hash algorithm) rather than the SHA1 format VFS for Git
/// assumes throughout its object id handling.
///
/// There is no supported way to convert a SHA256 repository to SHA1 in place: an
/// object's id is a hash of its content plus the algorithm used to compute it, so
/// "downgrading" would mean rewriting every object with a new identity - effectively
/// a fresh clone. Callers should use this detection to fail fast with a clear error
/// rather than let SHA1-only code (e.g. Sha1Id, SHA1Util, index parsing) hit
/// corruption or an unpredictable crash on 20-byte/40-hex-char assumptions.
/// </summary>
public static class ObjectFormat
{
public const string ObjectFormatConfigName = "extensions.objectformat";
public const string Sha256Value = "sha256";

/// <summary>
/// Length, in hex characters, of a SHA256 object id (32 bytes) - twice
/// GVFSConstants.ShaStringLength, the equivalent SHA1 length. Object ids are
/// written at full hex width wherever git records them as text - notably
/// .git/packed-refs ("&lt;objectid&gt; &lt;refname&gt;") and loose ref files - so this
/// is a physical, on-disk signal of a repository's object format that survives
/// even when git config is unreadable. Unlike loose objects, packed-refs is not
/// touched by git's own packing maintenance, and VFS for Git writes it directly
Comment thread
tyrielv marked this conversation as resolved.
Outdated
/// on every clone (see GitRefs.ToPackedRefs, CloneVerb.TryInitRepo), so it is
/// reliably present to probe.
/// </summary>
public const int Sha256HexIdLength = 64;

public const string UnsupportedSha256ErrorMessage =
"VFS for Git only supports SHA1 repositories, but this repository was initialized " +
"with the SHA256 object format. There is no supported way to convert a SHA256 " +
"repository to SHA1 in place. Re-clone or re-initialize the repository as a SHA1 " +
"repository (for example, 'git init --object-format=sha1').";

/// <summary>
/// Returns true if the given value of extensions.objectformat identifies a SHA256
/// repository. A missing/empty value means the repository defaults to SHA1.
/// </summary>
public static bool IsSha256(string objectFormatConfigValue)
Comment thread
tyrielv marked this conversation as resolved.
{
return string.Equals(objectFormatConfigValue?.Trim(), Sha256Value, StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Reads extensions.objectformat from the local config of the repo backing the given
/// GitProcess, and returns true if it identifies a SHA256 repository.
///
/// A config read failure (e.g. a broken git invocation, as opposed to the key simply
/// being absent) is treated the same as "not SHA256" here, matching the established
/// behavior of GitProcess.TryGetFromConfig elsewhere in this codebase for optional
/// config reads. Use <see cref="TryIsSha256Repo"/> if the caller wants to distinguish
/// and surface that failure instead of silently treating it as SHA1.
/// </summary>
public static bool IsSha256Repo(GitProcess git)
{
TryIsSha256Repo(git, out bool isSha256, out string _);
return isSha256;
}

/// <summary>
/// Reads extensions.objectformat from the local config of the repo backing the given
/// GitProcess. Returns false and populates <paramref name="error"/> if the config
/// could not be read at all (a missing key is not an error - it simply means the
/// repository defaults to SHA1, and isSha256 is set to false).
///
/// Note the limits of this distinction: 'git config' reports a missing key and a
/// missing/unreadable .git/config file identically - exit code 1 with no stderr
/// content - so a repository whose entire config is gone is treated as "not SHA256"
/// here, not as a read failure. This matches the codebase-wide convention for
/// optional config reads (GitProcess.TryGetFromConfig has the same ambiguity) rather
/// than a gap specific to this check.
/// </summary>
public static bool TryIsSha256Repo(GitProcess git, out bool isSha256, out string error)
{
GitProcess.ConfigResult result = git.GetFromLocalConfig(ObjectFormatConfigName);
if (!result.TryParseAsString(out string value, out error))
{
isSha256 = false;
return false;
}

isSha256 = IsSha256(value);
return true;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@
(current, temp) =>
{
byte[] currentStartOfIndex = new byte[20];
current.Read(currentStartOfIndex, 0, currentStartOfIndex.Length);

Check warning on line 134 in GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerTestCase/RepairTests.cs

View workflow job for this annotation

GitHub Actions / Build and Unit Test (Release, x64)

Avoid inexact read with 'System.IO.FileStream.Read(byte[], int, int)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2022)

Check warning on line 134 in GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerTestCase/RepairTests.cs

View workflow job for this annotation

GitHub Actions / Build and Unit Test (Release, arm64)

Avoid inexact read with 'System.IO.FileStream.Read(byte[], int, int)' (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2022)
temp.Write(currentStartOfIndex, 0, currentStartOfIndex.Length);
});

Expand Down Expand Up @@ -159,6 +159,38 @@
this.Enlistment.MountGVFS();
}

[TestCase]
public void DoesNotRebuildSha256RepoConfig()
{
this.Enlistment.UnmountGVFS();

// Make this otherwise-ordinary enlistment's config look like a SHA256 repo -
// core.repositoryformatversion=1 plus extensions.objectformat=sha256, the shape
// 'git init --object-format=sha256' produces - while leaving the rest of the
// config (including the origin remote) intact and parseable. The enlistment's
// actual objects/refs are still genuinely SHA1-hashed; this exercises the
// config-based detection path specifically, not the packed-refs physical
// fallback (which only fires when the config itself is unreadable).
string configPath = Path.Combine(this.Enlistment.RepoBackingRoot, ".git", "config");
string originalConfig = File.ReadAllText(configPath);
string sha256Config = originalConfig.Replace("repositoryformatversion = 0", "repositoryformatversion = 1")
+ "[extensions]\n\tobjectformat = sha256\n";
File.WriteAllText(configPath, sha256Config);

this.Enlistment.TryMountGVFS().ShouldEqual(false, "GVFS shouldn't mount a SHA256 repo");

this.RepairWithoutConfirmShouldNotFix();

// Unlike FixesCorruptGitConfig above, repair must refuse here rather than fix:
// rebuilding the config from scratch would silently erase extensions.objectformat
// and leave a SHA1-shaped config over objects/refs that are declared SHA256.
this.Enlistment.Repair(confirm: true);
this.Enlistment.TryMountGVFS().ShouldEqual(false, "Repair must not make a SHA256 repo mountable");

string configAfterRepair = File.ReadAllText(configPath);
configAfterRepair.ShouldContain("sha256");
Comment thread
tyrielv marked this conversation as resolved.
Outdated
}

private void CreateCorruptIndexAndRename(string indexPath, Action<FileStream, FileStream> corruptionAction)
{
string tempIndexPath = indexPath + ".lock";
Expand Down
21 changes: 21 additions & 0 deletions GVFS/GVFS.Mount/InProcessMount.cs
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,27 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords)
this.FailMountAndExit(RefStorage.UnsupportedReftableErrorMessage);
}

// Same rationale as above, for SHA256: applying the required-config
// force-write to a SHA256 repo (extensions.objectformat) would brick it
// identically, so this check must also run before that write. A genuine
// config-read failure is fatal here rather than a warning: a missing key
// reports the repo as SHA1 and does not reach this branch, so a read error
// means the config is anomalous and proceeding into the force-write could
// brick an unsupported repo.
if (!ObjectFormat.TryIsSha256Repo(git, out bool isSha256Repo, out string objectFormatReadError))
{
// Pass the git error as a format argument, not concatenated into the
Comment thread
tyrielv marked this conversation as resolved.
Outdated
// format string: FailMountAndExit routes through ITracer.RelatedError(
// string, params object[]), which runs string.Format, so a '{' in the
// git stderr (possible with a crafted/corrupt .git/config) would throw
// a FormatException instead of failing the mount cleanly.
this.FailMountAndExit("Could not determine the repository's object format: {0}", objectFormatReadError);
Comment thread
tyrielv marked this conversation as resolved.
}
else if (isSha256Repo)
{
this.FailMountAndExit(ObjectFormat.UnsupportedSha256ErrorMessage);
}

if (!GVFSPlatform.Instance.FileSystem.IsFileSystemSupported(this.enlistment.WorkingDirectoryRoot, out string fsError))
{
this.FailMountAndExit("FileSystem unsupported: " + fsError);
Expand Down
88 changes: 88 additions & 0 deletions GVFS/GVFS.UnitTests/CommandLine/GitConfigRepairJobTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using GVFS.Common;
using GVFS.RepairJobs;
using GVFS.Tests.Should;
using GVFS.UnitTests.Mock.Common;
using NUnit.Framework;
using System;
using System.IO;

namespace GVFS.UnitTests.CommandLine
Comment thread
tyrielv marked this conversation as resolved.
Outdated
{
[TestFixture]
public class GitConfigRepairJobTests
{
private string testEnlistmentRoot;
private string dotGitRoot;

[SetUp]
public void CreateTestEnlistmentRoot()
{
this.testEnlistmentRoot = Path.Combine(Path.GetTempPath(), "GVFSUnitTests_GitConfigRepairJob_" + Guid.NewGuid().ToString("N"));

// Construct a real (non-mock) GVFSEnlistment pointed at a scratch directory purely
// to get DotGitRoot's platform-specific layout right - PackedRefsLookSha256 only
// does real file I/O under that path, so there is no need to mock GitProcess or
// register GVFSPlatform for these tests specifically (though the [SetUpFixture] in
// GVFS.UnitTests.Setup must still have run, as it does for the whole assembly).
GVFSEnlistment enlistment = new GVFSEnlistment(this.testEnlistmentRoot, "mock://repoUrl", "mock:\\git.exe", authentication: null);
this.dotGitRoot = enlistment.DotGitRoot;
Directory.CreateDirectory(this.dotGitRoot);
}

[TearDown]
public void DeleteTestEnlistmentRoot()
{
if (Directory.Exists(this.testEnlistmentRoot))
{
Directory.Delete(this.testEnlistmentRoot, recursive: true);
}
}

[TestCase]
public void PackedRefsLookSha256IsTrueForSha256WidthObjectId()
{
this.WritePackedRefs("bb3fd16685b64a36d8d433fd0684d192277bb3b64f2b8441c5832d8d3ba8da91 refs/heads/master");

this.CreateJob().PackedRefsLookSha256().ShouldEqual(true);
}

[TestCase]
public void PackedRefsLookSha256IsFalseForSha1WidthObjectId()
{
this.WritePackedRefs("a3541ccec5694b0334d91298e348dc7401c07ff7 refs/heads/master");

this.CreateJob().PackedRefsLookSha256().ShouldEqual(false);
}

[TestCase]
public void PackedRefsLookSha256SkipsHeaderCommentAndPeeledLines()
{
// Matches the real shape 'git pack-refs'/GitRefs.ToPackedRefs produce: a leading
// '#' comment, and '^'-prefixed peeled object ids for annotated tags that must not
// be mistaken for the preceding ref's own object id.
this.WritePackedRefs(
"# pack-refs with: peeled fully-peeled",
"bb3fd16685b64a36d8d433fd0684d192277bb3b64f2b8441c5832d8d3ba8da91 refs/tags/v1",
"^0000000000000000000000000000000000000000000000000000000000000000");

this.CreateJob().PackedRefsLookSha256().ShouldEqual(true);
}

[TestCase]
public void PackedRefsLookSha256IsFalseWhenFileIsMissing()
{
this.CreateJob().PackedRefsLookSha256().ShouldEqual(false);
}

private GitConfigRepairJob CreateJob()
{
GVFSEnlistment enlistment = new GVFSEnlistment(this.testEnlistmentRoot, "mock://repoUrl", "mock:\\git.exe", authentication: null);
return new GitConfigRepairJob(new MockTracer(), new StringWriter(), enlistment);
}

private void WritePackedRefs(params string[] lines)
{
File.WriteAllLines(Path.Combine(this.dotGitRoot, GVFSConstants.DotGit.PackedRefsName), lines);
}
}
}
Loading
Loading