diff --git a/eng/Version.Details.props b/eng/Version.Details.props index e2c7d5f8..285a257c 100644 --- a/eng/Version.Details.props +++ b/eng/Version.Details.props @@ -8,6 +8,7 @@ This file should be imported by eng/Versions.props 10.0.0-beta.25506.103 2.0.0 + 10.0.0-rc.2.25502.107 17.11.31 17.11.31 diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 25ffcf7c..9649d2e7 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -21,5 +21,9 @@ https://github.com/dotnet/msbuild 933b72e36e86c22ba73e8b8148488f8298bb73c7 + + https://github.com/dotnet/dotnet + be28ec777bf12db631725399c442448d52093087 + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index a1ab4835..07e0d6d2 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -22,6 +22,7 @@ + diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/GitConfigTests.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/GitConfigTests.cs index 0b506b1a..19bee0d7 100644 --- a/src/Microsoft.Build.Tasks.Git.UnitTests/GitConfigTests.cs +++ b/src/Microsoft.Build.Tasks.Git.UnitTests/GitConfigTests.cs @@ -1,8 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the License.txt file in the project root for more information. + using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.Linq; using System.Text; @@ -448,5 +450,59 @@ public void TryParseBooleanValue_Error(string? str) { Assert.False(GitConfig.TryParseBooleanValue(str, out _)); } + + [Theory] + [InlineData(null, ReferenceStorageFormat.LooseFiles)] + [InlineData("reftable", ReferenceStorageFormat.RefTable)] + internal void RefStorage(string? value, ReferenceStorageFormat expected) + { + var variables = ImmutableDictionary>.Empty; + if (value != null) + { + variables = variables.Add(new GitVariableName("extensions", "", "refStorage"), [value]); + } + + Assert.Equal(expected, new GitConfig(variables).ReferenceStorageFormat); + } + + [Theory] + [InlineData("")] + [InlineData("x")] + [InlineData("refTable")] + public void RefStorage_Invalid(string value) + { + Assert.Throws(() => + new GitConfig(ImmutableDictionary>.Empty + .Add(new GitVariableName("extensions", "", "refStorage"), [value]))); + } + + [Theory] + [InlineData(null, ObjectNameFormat.Sha1)] + [InlineData("sha1", ObjectNameFormat.Sha1)] + [InlineData("sha256", ObjectNameFormat.Sha256)] + internal void ParseObjectFormat(string? value, ObjectNameFormat expected) + { + var variables = ImmutableDictionary>.Empty; + if (value != null) + { + variables = variables.Add(new GitVariableName("extensions", "", "objectFormat"), [value]); + } + + Assert.Equal(expected, new GitConfig(variables).ObjectNameFormat); + } + + [Theory] + [InlineData("")] + [InlineData("Sha1")] + [InlineData("sha-1")] + [InlineData("sha-256")] + [InlineData("sha384")] + [InlineData("sha512")] + internal void ParseObjectFormat_Invalid(string value) + { + Assert.Throws(() => + new GitConfig(ImmutableDictionary>.Empty + .Add(new GitVariableName("extensions", "", "objectFormat"), [value]))); + } } } diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/GitRefTableReaderTests.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/GitRefTableReaderTests.cs new file mode 100644 index 00000000..1335d097 --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.UnitTests/GitRefTableReaderTests.cs @@ -0,0 +1,1188 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using System.Linq; +using System.Text; +using Xunit; + +namespace Microsoft.Build.Tasks.Git.UnitTests; + +public class GitRefTableReaderTests +{ + private static GitRefTableReader Create(params byte[] bytes) + => new(new MemoryStream(bytes)); + + private static GitRefTableReader Create(GitRefTableTestWriter writer) + { + writer.Position = 0; + return new(writer.Stream); + } + + private static byte[] GetObjectName(byte b0, ObjectNameFormat format = ObjectNameFormat.Sha1) + => GitRefTableTestWriter.GetObjectName(b0, format); + + [Fact] + public void ReadByte_Success() + { + using var reader = Create(0x12, 0x34); + Assert.Equal((byte)0x12, reader.ReadByte()); + Assert.Equal((byte)0x34, reader.ReadByte()); + } + + [Fact] + public void ReadByte_EndOfStream() + { + using var reader = Create(); + Assert.Throws(() => reader.ReadByte()); + } + + [Theory] + [InlineData(new byte[] { 0x00 }, 0)] + [InlineData(new byte[] { 0x01 }, 1)] + [InlineData(new byte[] { 0x7F }, 127)] + [InlineData(new byte[] { 0x80, 0x00 }, 128)] + [InlineData(new byte[] { 0x80, 0x01 }, 129)] + [InlineData(new byte[] { 0x80, 0x02 }, 130)] + [InlineData(new byte[] { 0xFF, 0x7F }, 16511)] + [InlineData(new byte[] { 0x80, 0x80, 0x00 }, 16512)] + [InlineData(new byte[] { 0x80, 0x80, 0x01 }, 16513)] + [InlineData(new byte[] { 0xFF, 0xFF, 0x7F }, 2113663)] + [InlineData(new byte[] { 0x80, 0x80, 0x80, 0x00 }, 2113664)] + [InlineData(new byte[] { 0xFF, 0xFF, 0xFF, 0x7F }, 270549119)] + [InlineData(new byte[] { 0x80, 0x80, 0x80, 0x80, 0x00 }, 270549120)] + [InlineData(new byte[] { 0x86, 0xFE, 0xFE, 0xFE, 0x7F }, int.MaxValue)] + public void ReadVarInt_Valid(byte[] encoding, int expected) + { + using var reader = Create(encoding); + Assert.Equal(expected, reader.ReadVarInt()); + + var writer = new GitRefTableTestWriter(); + writer.WriteVarInt(expected); + Assert.Equal(encoding, writer.ToArray()); + } + + [Fact] + public void ReadVarInt_Invalid_TooManyContinuationBytes() + { + using var reader = Create(0x86, 0xFE, 0xFE, 0xFF, 0x7F); + Assert.Throws(() => reader.ReadVarInt()); + } + + [Fact] + public void ReadUInt16BE() + { + using var reader = Create(0x12, 0x34); + Assert.Equal((ushort)0x1234, reader.ReadUInt16BE()); + } + + [Fact] + public void ReadUInt24BE() + { + using var reader = Create(0x01, 0x02, 0x03); + Assert.Equal(0x10203, reader.ReadUInt24BE()); + } + + [Fact] + public void ReadUInt32BE() + { + using var reader = Create(0x01, 0x02, 0x03, 0x04); + Assert.Equal(0x01020304u, reader.ReadUInt32BE()); + } + + [Fact] + public void ReadUInt64BE() + { + using var reader = Create(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08); + Assert.Equal(0x0102030405060708ul, reader.ReadUInt64BE()); + } + + [Fact] + public void ReadBytes_Success() + { + using var reader = Create(0x10, 0x20, 0x30, 0x40); + var data = reader.ReadBytes(4); + Assert.Equal([0x10, 0x20, 0x30, 0x40], data); + } + + [Fact] + public void ReadBytes_EndOfStream() + { + using var reader = Create(0x10, 0x20); + Assert.Throws(() => reader.ReadBytes(3)); + } + + [Fact] + public void ReadBytes_OutOfMemory() + { + using var reader = Create(); + Assert.Throws(() => reader.ReadBytes(int.MaxValue)); + } + + [Fact] + public void ReadExactly_FillsBuffer() + { + using var reader = Create(0xAA, 0xBB, 0xCC); + var buffer = new byte[3]; + reader.ReadExactly(buffer); + Assert.Equal([0xAA, 0xBB, 0xCC], buffer); + } + + [Fact] + public void ReadExactly_WithCount_FillsPartialBuffer() + { + using var reader = Create(0xAA, 0xBB, 0xCC); + var buffer = new byte[5]; + reader.ReadExactly(buffer, 3); + Assert.Equal([0xAA, 0xBB, 0xCC, 0x00, 0x00], buffer); + } + + [Fact] + public void ReadExactly_EndOfStream() + { + using var reader = Create(0xAA); + var buffer = new byte[2]; + Assert.Throws(() => reader.ReadExactly(buffer)); + } + + [Theory] + [InlineData(new byte[] { 0x00 }, "00")] + [InlineData(new byte[] { 0x01, 0x02, 0x0A }, "01020a")] + public void ReadObjectName(byte[] bytes, string expected) + { + using var reader = Create(bytes); + var actual = reader.ReadObjectName(bytes.Length); + Assert.Equal(expected, actual); + } + + [Fact] + public void ReadExactly_MultipleUnderlyingReads() + { + var data = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray(); + using var stream = new ChunkedStream(new MemoryStream(data), maxChunk: 3); + using var reader = new GitRefTableReader(stream); + var buffer = new byte[data.Length]; + reader.ReadExactly(buffer); + Assert.Equal(data, buffer); + Assert.True(stream.ReadCallCount > 1, "Expected multiple Read calls"); + } + + private sealed class ChunkedStream(Stream inner, int maxChunk) : Stream + { + public int ReadCallCount { get; private set; } + + public override bool CanRead => true; + public override bool CanSeek => inner.CanSeek; + public override bool CanWrite => false; + public override long Length => inner.Length; + public override long Position { get => inner.Position; set => inner.Position = value; } + public override void Flush() => inner.Flush(); + + public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); + public override void SetLength(long value) => inner.SetLength(value); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) + { + var toRead = Math.Min(maxChunk, count); + var n = inner.Read(buffer, offset, toRead); + if (n > 0) ReadCallCount++; + return n; + } + } + + [Fact] + public void ReadHeader_InvalidMagic() + { + var writer = new GitRefTableTestWriter(); + + writer.WriteHeader( + magic: 0xDEADBEEF, + version: 1, + blockSize: 0x100, + minUpdate: 0, + maxUpdate: 0); + + using var reader = Create(writer.ToArray()); + Assert.Throws(() => reader.ReadHeader()); + } + + [Theory] + [InlineData(0)] + [InlineData(3)] + public void ReadHeader_Version_Unsupported(byte version) + { + var writer = new GitRefTableTestWriter(); + + writer.WriteHeader( + magic: 0x52454654, // 'RFTB' + version: version, + blockSize: 0x010203, + minUpdate: 0, + maxUpdate: 0); + + using var reader = Create(writer.ToArray()); + Assert.Throws(() => reader.ReadHeader()); + } + + [Fact] + public void ReadHeader_Version1() + { + var writer = new GitRefTableTestWriter(); + + writer.WriteHeader( + magic: 0x52454654, // 'RFTB' + version: 1, + blockSize: 0x010203, + minUpdate: 0, + maxUpdate: 0); + + using var reader = Create(writer.ToArray()); + var header = reader.ReadHeader(); + + Assert.Equal(24, header.Size); + Assert.Equal(0x010203, header.BlockSize); + Assert.Equal(ObjectNameFormat.Sha1, header.ObjectNameFormat); + } + + [Theory] + [InlineData("sha1", ObjectNameFormat.Sha1)] + [InlineData("s256", ObjectNameFormat.Sha256)] + internal void ReadHeader_Version2(string hashId, ObjectNameFormat format) + { + var writer = new GitRefTableTestWriter(); + + writer.WriteHeader( + magic: 0x52454654, // 'RFTB' + version: 2, + blockSize: 0x000102, + minUpdate: 1, + maxUpdate: 2, + hashId: hashId); + + using var reader = Create(writer.ToArray()); + var header = reader.ReadHeader(); + Assert.Equal(28, header.Size); + Assert.Equal(0x102, header.BlockSize); + Assert.Equal(format, header.ObjectNameFormat); + } + + [Theory] + [InlineData("SHA1")] + [InlineData("sha2")] + public void ReadHeader_Version2_InvalidHashId(string hashId) + { + var writer = new GitRefTableTestWriter(); + + writer.WriteHeader( + magic: 0x52454654, // 'RFTB' + version: 2, + blockSize: 0x000102, + minUpdate: 1, + maxUpdate: 2, + hashId: hashId); + + using var reader = Create(writer.ToArray()); + Assert.Throws(() => reader.ReadHeader()); + } + + [Fact] + public void ReadFooter_Success() + { + var writer = new GitRefTableTestWriter(); + + writer.WriteFooter( + writer => writer.WriteHeader( + magic: 0x52454654, // 'RFTB' + version: 2, + blockSize: 0x500, + minUpdate: 10, + maxUpdate: 20, + hashId: "sha1"), + refIndexPosition: 0, + objPosition: 1, + objIndexPosition: 2, + logPosition: 3, + logIndexPosition: 4); + + using var reader = Create(writer.ToArray()); + var footer = reader.ReadFooter(); + Assert.Equal(28, footer.Header.Size); + Assert.Equal(0x500, footer.Header.BlockSize); + Assert.Equal(ObjectNameFormat.Sha1, footer.Header.ObjectNameFormat); + Assert.Equal(0, footer.RefIndexPosition); + } + + [Fact] + public void ReadFooter_InvalidChecksum() + { + var writer = new GitRefTableTestWriter(); + + writer.WriteFooter( + writer => writer.WriteHeader( + magic: 0x52454654, + version: 1, + blockSize: 0x000100, + minUpdate: 0, + maxUpdate: 0, + hashId: "s256"), + refIndexPosition: 0, + objPosition: 0, + objIndexPosition: 0, + logPosition: 0, + logIndexPosition: 0); + + var footerBytes = writer.ToArray(); + + // Corrupt checksum (last4 bytes) + footerBytes[^1] ^= 0xFF; + + using var reader = Create(footerBytes); + Assert.Throws(() => reader.ReadFooter()); + } + + [Fact] + public void ReadFooter_InvalidRefIndexPosition() + { + var writer = new GitRefTableTestWriter(); + + writer.WriteFooter( + writer => writer.WriteHeader( + magic: 0x52454654, + version: 2, + blockSize: 0x000200, + minUpdate: 0, + maxUpdate: 0, + hashId: "sha1"), + refIndexPosition: 10_000_000, + objPosition: 0, + objIndexPosition: 0, + logPosition: 0, + logIndexPosition: 0); + + using var reader = Create(writer.ToArray()); + Assert.Throws(() => reader.ReadFooter()); + } + + [Fact] + public void ReadRefRecord_Deletion() + { + var writer = new GitRefTableTestWriter(); + + writer.WriteRefRecord( + prefixLength: 0, + valueType: 0, // deletion + suffix: Encoding.ASCII.GetBytes("refs/heads/main"), + updateIndexDelta: 0); + + using var reader = Create(writer); + + var header = new GitRefTableReader.Header() + { + BlockSize = 0x100, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + var record = reader.ReadRefRecord(header, priorName: ""); + Assert.Equal("refs/heads/main", record.RefName); + Assert.Null(record.ObjectName); + Assert.Null(record.SymbolicRef); + } + + [Fact] + public void ReadRefRecord_ObjectName() + { + var writer = new GitRefTableTestWriter(); + + writer.WriteRefRecord( + prefixLength: 0, + valueType: 1, // object name + suffix: Encoding.ASCII.GetBytes("refs/heads/main"), + updateIndexDelta: 0, + objectName: GetObjectName(0x01)); + + using var reader = Create(writer); + + var header = new GitRefTableReader.Header() + { + BlockSize = 0x100, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + var record = reader.ReadRefRecord(header, priorName: ""); + Assert.Equal("refs/heads/main", record.RefName); + Assert.Equal("0100000000000000000000000000000000000000", record.ObjectName); + Assert.Null(record.SymbolicRef); + } + + [Fact] + public void ReadRefRecord_ObjectNamePeeled() + { + var writer = new GitRefTableTestWriter(); + + writer.WriteRefRecord( + prefixLength: 0, + valueType: 2, // object name and peeled target + suffix: Encoding.ASCII.GetBytes("refs/heads/main"), + updateIndexDelta: 0, + objectName: GetObjectName(0x12), + peeledObjectName: GetObjectName(0x34)); + + using var reader = Create(writer); + + var header = new GitRefTableReader.Header() + { + BlockSize = 0x100, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + var record = reader.ReadRefRecord(header, priorName: ""); + Assert.Equal("refs/heads/main", record.RefName); + Assert.Equal("1200000000000000000000000000000000000000", record.ObjectName); + Assert.Null(record.SymbolicRef); + } + + [Fact] + public void ReadRefRecord_SymbolicRef() + { + var writer = new GitRefTableTestWriter(); + + writer.WriteRefRecord( + prefixLength: 0, + valueType: 3, // symbolic ref + suffix: Encoding.ASCII.GetBytes("refs/heads/main"), + updateIndexDelta: 0, + symbolicRef: Encoding.ASCII.GetBytes("refs/heads/foo")); + + using var reader = Create(writer); + + var header = new GitRefTableReader.Header() + { + BlockSize = 0x100, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + var record = reader.ReadRefRecord(header, priorName: ""); + Assert.Equal("refs/heads/main", record.RefName); + Assert.Null(record.ObjectName); + Assert.Equal("refs/heads/foo", record.SymbolicRef); + } + + [Fact] + public void ReadRefRecord_SymbolicRef_InvalidEncoding() + { + var writer = new GitRefTableTestWriter(); + + writer.WriteRefRecord( + prefixLength: 0, + valueType: 3, // symbolic ref + suffix: Encoding.ASCII.GetBytes("refs/heads/main"), + updateIndexDelta: 0, + symbolicRef: [0x00, 0xD8]); // U+D800: unpaired surrogate + + using var reader = Create(writer); + + var header = new GitRefTableReader.Header() + { + BlockSize = 0x100, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + Assert.Throws(() => reader.ReadRefRecord(header, priorName: "")); + } + + [Fact] + public void ReadRefRecord_Prefix() + { + var writer = new GitRefTableTestWriter(); + + var prefix = "refs/heads/"; + + writer.WriteRefRecord( + prefixLength: prefix.Length, + valueType: 0, // deletion + suffix: Encoding.ASCII.GetBytes("main"), + updateIndexDelta: 0); + + using var reader = Create(writer); + + var header = new GitRefTableReader.Header() + { + BlockSize = 0x100, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + var record = reader.ReadRefRecord(header, priorName: prefix + "foo"); + Assert.Equal("refs/heads/main", record.RefName); + } + + [Fact] + public void ReadRefRecord_InvalidPrefixLength() + { + var writer = new GitRefTableTestWriter(); + + writer.WriteRefRecord( + prefixLength: 100, + valueType: 0, // deletion + suffix: Encoding.ASCII.GetBytes("main"), + updateIndexDelta: 0); + + using var reader = Create(writer); + + var header = new GitRefTableReader.Header() + { + BlockSize = 0x100, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + Assert.Throws(() => reader.ReadRefRecord(header, priorName: "foo")); + } + + [Fact] + public void ReadRefRecord_InvalidEncoding() + { + var writer = new GitRefTableTestWriter(); + + writer.WriteRefRecord( + prefixLength: 0, + valueType: 0, // deletion + suffix: [0x00, 0xD8], // U+D800: unpaired surrogate + updateIndexDelta: 0); + + using var reader = Create(writer); + + var header = new GitRefTableReader.Header() + { + BlockSize = 0x100, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + Assert.Throws(() => reader.ReadRefRecord(header, priorName: "")); + } + + [Fact] + public void ReadRefIndexRecord_Prefix() + { + var writer = new GitRefTableTestWriter(); + + var prefix = "refs/heads/"; + + writer.WriteRefIndexRecord( + prefixLength: prefix.Length, + valueType: 0, // deletion + suffix: Encoding.ASCII.GetBytes("main"), + blockPosition: 12345); + + using var reader = Create(writer); + + var record = reader.ReadRefIndexRecord(priorName: prefix + "foo"); + Assert.Equal("refs/heads/main", record.LastRefName); + Assert.Equal(12345, record.BlockPosition); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void ReadRefIndexRecord_InvalidType(byte type) + { + var writer = new GitRefTableTestWriter(); + + writer.WriteRefIndexRecord( + prefixLength: 0, + valueType: type, + suffix: Encoding.ASCII.GetBytes("main"), + blockPosition: 1234); + + using var reader = Create(writer); + Assert.Throws(() => reader.ReadRefIndexRecord(priorName: "")); + } + + [Theory] + [CombinatorialData] + public void ReadRestartOffsets(bool isFirst) + { + var writer = new GitRefTableTestWriter(); + + var header = new GitRefTableReader.Header() + { + Size = 24, + BlockSize = 10, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + var offset1 = 0; + var offset2 = 0; + + writer.WriteBlock(isFirst ? header : null, 'r', (writer, blockStart) => + { + // ref_record+ + offset1 = (int)(writer.Position - blockStart); + + writer.WriteRefRecord( + prefixLength: 0, + valueType: 1, // object name + suffix: Encoding.ASCII.GetBytes("refs/heads/main"), + updateIndexDelta: 0, + objectName: GetObjectName(0x01)); + + writer.WriteRefRecord( + prefixLength: "refs/heads/".Length, + valueType: 1, // object name + suffix: Encoding.ASCII.GetBytes("foo"), + updateIndexDelta: 0, + objectName: GetObjectName(0x02)); + + writer.WriteRefRecord( + prefixLength: "refs/heads/".Length, + valueType: 1, // object name + suffix: Encoding.ASCII.GetBytes("bar"), + updateIndexDelta: 0, + objectName: GetObjectName(0x03)); + + offset2 = (int)writer.Position; + + writer.WriteRefRecord( + prefixLength: 0, + valueType: 1, // object name + suffix: Encoding.ASCII.GetBytes("baz"), + updateIndexDelta: 0, + objectName: GetObjectName(0x04)); + + // uint24(restart_offset)+ + writer.WriteRestartOffsets([offset1, offset2]); + }); + + using var reader = Create(writer); + + if (isFirst) + { + _ = reader.ReadHeader(); + } + + Assert.Equal((byte)'r', reader.ReadByte()); + + var offsets = reader.ReadRestartOffsets(header, blockLengthLimited: false, out var blockStart, out var blockEnd); + + Assert.Equal([offset1, offset2, (byte)(blockEnd - sizeof(ushort) - 2 * 3)], offsets); + Assert.Equal(0, blockStart); + Assert.Equal(writer.Stream.Length, blockEnd); + } + + [Fact] + public void ReadRestartOffsets_InvalidBlockLength_LargerThanHeaderBlockSize() + { + var writer = new GitRefTableTestWriter(); + + var header = new GitRefTableReader.Header() + { + Size = 24, + BlockSize = 0x100, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + writer.WriteBlock(null, 'r', (writer, _) => + { + // dummy content: + writer.Stream.WriteByte(0); + }); + + using var reader = Create(writer); + + Assert.Equal((byte)'r', reader.ReadByte()); + + Assert.Throws(() => reader.ReadRestartOffsets(header, blockLengthLimited: true, out _, out _)); + } + + [Fact] + public void ReadRestartOffsets_InvalidBlockLength_BlockSizeTooSmall() + { + var writer = new GitRefTableTestWriter(); + + var header = new GitRefTableReader.Header() + { + Size = 24, + BlockSize = 1, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + writer.WriteBlock(null, 'r', (writer, _) => + { + // dummy content: + writer.Stream.WriteByte(0); + }); + + using var reader = Create(writer); + + Assert.Equal((byte)'r', reader.ReadByte()); + + Assert.Throws(() => reader.ReadRestartOffsets(header, blockLengthLimited: false, out _, out _)); + } + + public enum OffsetError + { + Empty, + Unordered, + OutOfBlock, + } + + [Theory] + [CombinatorialData] + public void ReadRestartOffsets_Unordered(OffsetError error) + { + var writer = new GitRefTableTestWriter(); + + var header = new GitRefTableReader.Header() + { + Size = 24, + BlockSize = 10, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + var offset1 = 0; + var offset2 = 0; + + writer.WriteBlock(header: null, 'r', (writer, blockStart) => + { + // ref_record+ + offset1 = (int)(writer.Position - blockStart); + + writer.WriteRefRecord( + prefixLength: 0, + valueType: 1, // object name + suffix: Encoding.ASCII.GetBytes("refs/heads/main1"), + updateIndexDelta: 0, + objectName: GetObjectName(0x01)); + + writer.WriteRefRecord( + prefixLength: "refs/heads/".Length, + valueType: 1, // object name + suffix: Encoding.ASCII.GetBytes("foo"), + updateIndexDelta: 0, + objectName: GetObjectName(0x02)); + + offset2 = (int)(writer.Position - blockStart); + + writer.WriteRefRecord( + prefixLength: 0, + valueType: 1, // object name + suffix: Encoding.ASCII.GetBytes("refs/heads/main2"), + updateIndexDelta: 0, + objectName: GetObjectName(0x03)); + + // uint24(restart_offset)+ + writer.WriteRestartOffsets( + error switch + { + OffsetError.Empty => [], + OffsetError.Unordered => [offset2, offset1], + OffsetError.OutOfBlock => [offset1, 10000], + _ => throw new InvalidOperationException() + }); + }); + + using var reader = Create(writer); + + Assert.Equal((byte)'r', reader.ReadByte()); + + Assert.Throws(() => reader.ReadRestartOffsets(header, blockLengthLimited: false, out _, out _)); + } + + [Fact] + public void SearchBlock_RefRecord() + { + var writer = new GitRefTableTestWriter(); + + var header = new GitRefTableReader.Header() + { + Size = 24, + BlockSize = 1000, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + var refBlock1 = writer.WriteRefBlock(header: null, + [ + ("", "refs/heads/a", 0x01), + ("refs/heads/", "b", 0x02), + ("refs/heads/", "c", 0x03), + ("", "refs/heads/d", 0x04), + ]); + + using var reader = Create(writer); + + writer.Position = refBlock1; + var record = reader.SearchBlock(header, "refs/heads/c"); + Assert.NotNull(record); + Assert.Equal("refs/heads/c", record.Value.RefName); + Assert.Equal("0300000000000000000000000000000000000000", record.Value.ObjectName); + + writer.Position = 0; + record = reader.SearchBlock(header, "refs/heads/a"); + Assert.NotNull(record); + Assert.Equal("refs/heads/a", record.Value.RefName); + Assert.Equal("0100000000000000000000000000000000000000", record.Value.ObjectName); + + writer.Position = 0; + record = reader.SearchBlock(header, "refs/heads/b"); + Assert.NotNull(record); + Assert.Equal("refs/heads/b", record.Value.RefName); + Assert.Equal("0200000000000000000000000000000000000000", record.Value.ObjectName); + + writer.Position = 0; + record = reader.SearchBlock(header, "refs/heads/d"); + Assert.NotNull(record); + Assert.Equal("refs/heads/d", record.Value.RefName); + Assert.Equal("0400000000000000000000000000000000000000", record.Value.ObjectName); + + writer.Position = 0; + record = reader.SearchBlock(header, "refs/heads"); + Assert.Null(record); + + writer.Position = 0; + record = reader.SearchBlock(header, "refs/heads/aa"); + Assert.Null(record); + + writer.Position = 0; + record = reader.SearchBlock(header, "refs/heads/bb"); + Assert.Null(record); + + writer.Position = 0; + record = reader.SearchBlock(header, "refs/heads/cc"); + Assert.Null(record); + + writer.Position = 0; + record = reader.SearchBlock(header, "refs/heads/dd"); + Assert.Null(record); + } + + [Fact] + public void SearchBlock_RefIndexRecord() + { + var writer = new GitRefTableTestWriter(); + + var header = new GitRefTableReader.Header() + { + Size = 24, + BlockSize = 1000, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + // I3 -> I1 -> R1 + // R2 + // I2 -> R3 + // R4 + // R5 + + var refBlock1 = writer.WriteRefBlock(header, + [ + ("", "refs/heads/a", 0x01), + ("refs/heads/", "b", 0x02), + ("refs/heads/", "c", 0x03), + ]); + + var refBlock2 = writer.WriteRefBlock(header: null, + [ + ("", "refs/heads/d", 0x04), + ("refs/heads/", "e", 0x05), + ]); + + var refBlock3 = writer.WriteRefBlock(header: null, + [ + ("", "refs/heads/f", 0x06), + ]); + + var refBlock4 = writer.WriteRefBlock(header: null, + [ + ("", "refs/heads/g", 0x07), + ]); + + var refBlock5 = writer.WriteRefBlock(header: null, + [ + ("", "refs/heads/h", 0x08), + ]); + + var refIndexBlock1 = writer.WriteRefIndexBlock( + [ + ("", "refs/heads/c", refBlock1), + ("refs/heads/", "e", refBlock2), + ]); + + var refIndexBlock2 = writer.WriteRefIndexBlock( + [ + ("", "refs/heads/f", refBlock3), + ("refs/heads/", "g", refBlock4), + ]); + + var refIndexBlock3 = writer.WriteRefIndexBlock( + [ + ("", "refs/heads/e", refIndexBlock1), + ("refs/heads/", "g", refIndexBlock2), + ("", "refs/heads/h", refBlock5), + ]); + + using var reader = Create(writer); + + writer.Position = refIndexBlock3; + var record = reader.SearchBlock(header, "refs/heads/a"); + Assert.NotNull(record); + Assert.Equal("0100000000000000000000000000000000000000", record.Value.ObjectName); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads/b"); + Assert.NotNull(record); + Assert.Equal("0200000000000000000000000000000000000000", record.Value.ObjectName); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads/c"); + Assert.NotNull(record); + Assert.Equal("0300000000000000000000000000000000000000", record.Value.ObjectName); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads/d"); + Assert.NotNull(record); + Assert.Equal("0400000000000000000000000000000000000000", record.Value.ObjectName); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads/e"); + Assert.NotNull(record); + Assert.Equal("0500000000000000000000000000000000000000", record.Value.ObjectName); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads/f"); + Assert.NotNull(record); + Assert.Equal("0600000000000000000000000000000000000000", record.Value.ObjectName); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads/g"); + Assert.NotNull(record); + Assert.Equal("0700000000000000000000000000000000000000", record.Value.ObjectName); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads/h"); + Assert.NotNull(record); + Assert.Equal("0800000000000000000000000000000000000000", record.Value.ObjectName); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads"); + Assert.Null(record); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads/aa"); + Assert.Null(record); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads/bb"); + Assert.Null(record); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads/cc"); + Assert.Null(record); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads/dd"); + Assert.Null(record); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads/ee"); + Assert.Null(record); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads/ff"); + Assert.Null(record); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads/gg"); + Assert.Null(record); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads/hh"); + Assert.Null(record); + + writer.Position = refIndexBlock3; + record = reader.SearchBlock(header, "refs/heads/z"); + Assert.Null(record); + } + + [Fact] + public void TryFindReference_RefIndex() + { + var writer = new GitRefTableTestWriter(); + + var header = new GitRefTableReader.Header() + { + Size = 24, + BlockSize = 1000, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + var refBlock1 = writer.WriteRefBlock(header, + [ + ("", "refs/heads/a", 0x01), + ("refs/heads/", "b", "sym-ref"), + ]); + + var refIndexBlock1 = writer.WriteRefIndexBlock( + [ + ("", "refs/heads/b", refBlock1), + ]); + + writer.WriteFooter(header, refIndexBlock1); + + using var reader = Create(writer); + + Assert.True(reader.TryFindReference("refs/heads/a", out var objectName, out var symRef)); + Assert.Equal("0100000000000000000000000000000000000000", objectName); + Assert.Null(symRef); + + Assert.True(reader.TryFindReference("refs/heads/b", out objectName, out symRef)); + Assert.Equal("sym-ref", symRef); + Assert.Null(objectName); + + Assert.False(reader.TryFindReference("refs/heads/c", out objectName, out symRef)); + Assert.Null(symRef); + Assert.Null(objectName); + } + + [Theory] + [InlineData(0)] + [InlineData(100)] + public void TryFindReference_NoRefIndex(int blockSize) + { + var writer = new GitRefTableTestWriter(); + + var header = new GitRefTableReader.Header() + { + Size = 24, + BlockSize = blockSize, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + writer.WriteRefBlock(header, + [ + ("", "refs/heads/a", 0x01), + ("refs/heads/", "b", "sym-ref"), + ]); + + writer.WritePadding(blockSize); + + writer.WriteRefBlock(header: null, + [ + ("", "refs/heads/c", 0x02), + ]); + + writer.WritePadding(blockSize); + + writer.WriteFooter(header, refIndexPosition: 0); + + using var reader = Create(writer); + + Assert.True(reader.TryFindReference("refs/heads/a", out var objectName, out var symRef)); + Assert.Equal("0100000000000000000000000000000000000000", objectName); + Assert.Null(symRef); + + Assert.True(reader.TryFindReference("refs/heads/b", out objectName, out symRef)); + Assert.Equal("sym-ref", symRef); + Assert.Null(objectName); + + Assert.True(reader.TryFindReference("refs/heads/c", out objectName, out symRef)); + Assert.Equal("0200000000000000000000000000000000000000", objectName); + Assert.Null(symRef); + + Assert.False(reader.TryFindReference("refs/heads/d", out objectName, out symRef)); + Assert.Null(symRef); + Assert.Null(objectName); + } + + [Fact] + public void TryFindReference_RefBlockFollowedByObjBlock() + { + var writer = new GitRefTableTestWriter(); + + var header = new GitRefTableReader.Header() + { + Size = 24, + BlockSize = 0, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + writer.WriteRefBlock(header, + [ + ("", "refs/heads/a", 0x01) + ]); + + writer.WriteBlock(header, 'o', (writer, _) => + { + writer.Stream.WriteByte(0); + }); + + writer.WriteFooter(header, refIndexPosition: 0); + + using var reader = Create(writer); + + Assert.True(reader.TryFindReference("refs/heads/a", out var objectName, out var symRef)); + Assert.Equal("0100000000000000000000000000000000000000", objectName); + Assert.Null(symRef); + + Assert.False(reader.TryFindReference("refs/heads/b", out objectName, out symRef)); + Assert.Null(objectName); + Assert.Null(symRef); + } + + [Fact] + public void TryFindReference_InvalidBlockType() + { + var writer = new GitRefTableTestWriter(); + + var header = new GitRefTableReader.Header() + { + Size = 24, + BlockSize = 0, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + var refBlock1 = writer.WriteRefBlock(header, + [ + ("", "refs/heads/a", 0x01) + ]); + + writer.WriteRefIndexBlock( + [ + ("", "refs/heads/b", refBlock1), + ]); + + writer.WriteFooter(header, refIndexPosition: 0); + + using var reader = Create(writer); + + Assert.True(reader.TryFindReference("refs/heads/a", out var objectName, out var symRef)); + Assert.Equal("0100000000000000000000000000000000000000", objectName); + Assert.Null(symRef); + + Assert.Throws(() => reader.TryFindReference("refs/heads/b", out _, out _)); + } + + [Fact] + public void TryFindReference_NoRefBlock() + { + var writer = new GitRefTableTestWriter(); + + var header = new GitRefTableReader.Header() + { + Size = 24, + BlockSize = 0, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + writer.WriteBlock(header, 'o', (writer, _) => + { + writer.Stream.WriteByte(0); + }); + + writer.WriteFooter(header, refIndexPosition: 0); + + using var reader = Create(writer); + Assert.Throws(() => reader.TryFindReference("refs/heads/a", out _, out _)); + } +} diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/GitRefTableTestWriter.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/GitRefTableTestWriter.cs new file mode 100644 index 00000000..d29e1d0a --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git.UnitTests/GitRefTableTestWriter.cs @@ -0,0 +1,359 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Hashing; +using System.Linq; +using System.Text; + +namespace Microsoft.Build.Tasks.Git.UnitTests; + +internal class GitRefTableTestWriter +{ + public MemoryStream Stream { get; } = new(); + + public long Position + { + get => Stream.Position; + set => Stream.Position = value; + } + + public byte[] ToArray() + => Stream.ToArray(); + + public void WriteBytes(byte[] data) + => Stream.Write(data, 0, data.Length); + + public void WriteUInt16BE(int value) + { + Stream.WriteByte((byte)((value >> 8) & 0xFF)); + Stream.WriteByte((byte)(value & 0xFF)); + } + + public void WriteUInt24BE(int value) + { + Stream.WriteByte((byte)((value >> 16) & 0xFF)); + Stream.WriteByte((byte)((value >> 8) & 0xFF)); + Stream.WriteByte((byte)(value & 0xFF)); + } + + public void WriteUInt32BE(uint value) + { + Stream.WriteByte((byte)((value >> 24) & 0xFF)); + Stream.WriteByte((byte)((value >> 16) & 0xFF)); + Stream.WriteByte((byte)((value >> 8) & 0xFF)); + Stream.WriteByte((byte)(value & 0xFF)); + } + + public void WriteUInt64BE(ulong value) + { + Stream.WriteByte((byte)((value >> 56) & 0xFF)); + Stream.WriteByte((byte)((value >> 48) & 0xFF)); + Stream.WriteByte((byte)((value >> 40) & 0xFF)); + Stream.WriteByte((byte)((value >> 32) & 0xFF)); + Stream.WriteByte((byte)((value >> 24) & 0xFF)); + Stream.WriteByte((byte)((value >> 16) & 0xFF)); + Stream.WriteByte((byte)((value >> 8) & 0xFF)); + Stream.WriteByte((byte)(value & 0xFF)); + } + + public void WriteVarInt(int value) + { + const int V2 = 1 << 7; + const int V3 = V2 + (1 << 14); + const int V4 = V3 + (1 << 21); + const int V5 = V4 + (1 << 28); + + int shift; + if (value >= V5) + { + value -= V5; + shift = 28; + } + else if (value >= V4) + { + value -= V4; + shift = 21; + } + else if (value >= V3) + { + value -= V3; + shift = 14; + } + else if (value >= V2) + { + value -= V2; + shift = 7; + } + else + { + shift = 0; + } + + for (var s = shift; s >= 0; s -= 7) + { + Stream.WriteByte((byte)((value >> s) & 0x7f | (s > 0 ? 0x80 : 0))); + } + } + + public void WriteFooter( + Action writeHeader, + ulong refIndexPosition, + ulong objPosition, + ulong objIndexPosition, + ulong logPosition, + ulong logIndexPosition) + { + var startPosition = (int)Stream.Position; + writeHeader(this); + WriteUInt64BE(refIndexPosition); + WriteUInt64BE(objPosition); + WriteUInt64BE(objIndexPosition); + WriteUInt64BE(logPosition); + WriteUInt64BE(logIndexPosition); + var endPosition = (int)Stream.Position; + + WriteUInt32BE(Crc32.HashToUInt32(Stream.ToArray().AsSpan()[startPosition..endPosition])); + } + + public void WriteFooter(GitRefTableReader.Header header, long refIndexPosition) + { + WriteFooter( + writer => writer.WriteHeader(header), + (ulong)refIndexPosition, + objPosition: 0, + objIndexPosition: 0, + logPosition: 0, + logIndexPosition: 0); + } + + public void WriteHeader( + uint magic, + byte version, + int blockSize, + ulong minUpdate, + ulong maxUpdate, + string? hashId = null) + { + WriteUInt32BE(magic); + Stream.WriteByte(version); + WriteUInt24BE(blockSize); + WriteUInt64BE(minUpdate); + WriteUInt64BE(maxUpdate); + if (hashId != null) + { + WriteBytes(Encoding.ASCII.GetBytes(hashId)); + } + } + + public void WriteHeader(GitRefTableReader.Header header) + { + WriteHeader( + magic: 0x52454654, // 'RFTB' + version: 1, + blockSize: header.BlockSize, + minUpdate: 0, + maxUpdate: 0); + } + + public void WriteNameAndValueType( + int prefixLength, + int suffixLength, + byte valueType, + byte[] suffix) + { + WriteVarInt(prefixLength); + WriteVarInt((suffixLength << 3) | valueType); + WriteBytes(suffix); + } + + public void WriteRefRecord( + int prefixLength, + byte valueType, + byte[] suffix, + int updateIndexDelta, + byte[]? objectName = null, + byte[]? peeledObjectName = null, + byte[]? symbolicRef = null) + { + WriteNameAndValueType( + prefixLength, + suffix.Length, + valueType, + suffix); + + WriteVarInt(updateIndexDelta); + + if (objectName != null) + { + WriteBytes(objectName); + } + + if (peeledObjectName != null) + { + WriteBytes(peeledObjectName); + } + + if (symbolicRef != null) + { + WriteVarInt(symbolicRef.Length); + WriteBytes(symbolicRef); + } + } + + public void WriteRefIndexRecord( + int prefixLength, + byte valueType, + byte[] suffix, + long blockPosition) + { + WriteNameAndValueType( + prefixLength, + suffix.Length, + valueType, + suffix); + + WriteVarInt((int)blockPosition); + } + + public void WriteRestartOffsets(params int[] offsets) + { + foreach (var offset in offsets) + { + WriteUInt24BE(offset); + } + + WriteUInt16BE(offsets.Length); + } + + public long WriteBlock(GitRefTableReader.Header? header, char kind, Action writeContent) + { + var blockStart = Stream.Position; + + if (header != null) + { + WriteHeader( + magic: 0x52454654, // 'RFTB' + version: 1, + blockSize: header.Value.BlockSize, + minUpdate: 0, + maxUpdate: 0); + } + + var blockTypePosition = Stream.Position; + + Stream.WriteByte((byte)kind); + + // uint24(block_len) + var lengthPosition = Stream.Position; + WriteUInt24BE(0); + + writeContent(this, blockStart); + + var blockEnd = Stream.Position; + + // patch length: + Stream.Position = lengthPosition; + WriteUInt24BE((int)(blockEnd - blockStart)); + + Stream.Position = blockEnd; + + return blockTypePosition; + } + + public static byte[] GetObjectName(byte b0, ObjectNameFormat format = ObjectNameFormat.Sha1) + { + var name = new byte[format.HashSize]; + name[0] = b0; + return name; + } + + public readonly record struct NameOrSymRef(byte[]? ObjectName, byte[]? SymbolicReference) + { + public static implicit operator NameOrSymRef(string symbolicReference) => new(null, Encoding.UTF8.GetBytes(symbolicReference)); + public static implicit operator NameOrSymRef(byte objectName) => new(GetObjectName(objectName), null); + } + + public long WriteRefBlock( + GitRefTableReader.Header? header, + params (string prefix, string suffix, NameOrSymRef name)[] records) + { + return WriteBlock(header, 'r', (writer, blockStart) => + { + var offsets = new List(); + + foreach (var (prefix, suffix, name) in records) + { + if (prefix == "") + { + offsets.Add((int)(writer.Stream.Position - blockStart)); + } + + writer.WriteRefRecord( + prefixLength: prefix.Length, + valueType: (byte)(name.ObjectName != null ? 1 : 3), + suffix: Encoding.ASCII.GetBytes(suffix), + updateIndexDelta: 0, + objectName: name.ObjectName, + symbolicRef: name.SymbolicReference); + } + + WriteRestartOffsets([.. offsets]); + }); + } + + public long WriteRefIndexBlock(params (string prefix, string suffix, long blockPosition)[] records) + { + return WriteBlock(header: null, 'i', (writer, blockStart) => + { + var offsets = new List(); + + foreach (var (prefix, suffix, blockPosition) in records) + { + if (prefix == "") + { + offsets.Add((int)(Stream.Position - blockStart)); + } + + writer.WriteRefIndexRecord( + prefixLength: prefix.Length, + valueType: 0, + suffix: Encoding.ASCII.GetBytes(suffix), + blockPosition: blockPosition); + } + + WriteRestartOffsets([.. offsets]); + }); + } + + public void WritePadding(int alignment) + { + if (alignment == 0) + return; + + while (Stream.Position % alignment != 0) + { + Stream.WriteByte(0); + } + } + + public static byte[] GetRefTableBlob((string reference, NameOrSymRef name)[] references) + { + var writer = new GitRefTableTestWriter(); + + var header = new GitRefTableReader.Header() + { + Size = 24, + BlockSize = 0, + ObjectNameFormat = ObjectNameFormat.Sha1 + }; + + writer.WriteRefBlock(header, [.. references.Select(r => ("", r.reference, r.name))]); + writer.WriteFooter(header, refIndexPosition: 0); + + return writer.ToArray(); + } +} diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/GitReferenceResolverTests.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/GitReferenceResolverTests.cs index c61fb555..1015de5a 100644 --- a/src/Microsoft.Build.Tasks.Git.UnitTests/GitReferenceResolverTests.cs +++ b/src/Microsoft.Build.Tasks.Git.UnitTests/GitReferenceResolverTests.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. // See the License.txt file in the project root for more information. -using System; + using System.IO; using System.Linq; using TestUtilities; @@ -25,7 +25,7 @@ public void ResolveReference() refsHeadsDir.CreateFile("br1").WriteAllText("ref: refs/heads/br2"); refsHeadsDir.CreateFile("br2").WriteAllText("ref: refs/heads/master"); - var resolver = new GitReferenceResolver(gitDir.Path, commonDir.Path); + using var resolver = new GitReferenceResolver(gitDir.Path, commonDir.Path, ReferenceStorageFormat.LooseFiles, ObjectNameFormat.Sha1); Assert.Equal("0123456789ABCDEFabcdef000000000000000000", resolver.ResolveReference("0123456789ABCDEFabcdef000000000000000000")); @@ -33,13 +33,40 @@ public void ResolveReference() Assert.Equal("0000000000000000000000000000000000000000", resolver.ResolveReference("ref: refs/heads/br1")); Assert.Equal("0000000000000000000000000000000000000000", resolver.ResolveReference("ref: refs/heads/br2")); - // branch without commits (emtpy repository) will have not file in refs/heads: + // branch without commits (empty repository) will have not file in refs/heads: Assert.Null(resolver.ResolveReference("ref: refs/heads/none")); Assert.Null(resolver.ResolveReference("ref: refs/heads/rec1 ")); Assert.Null(resolver.ResolveReference("ref: refs/heads/none" + string.Join("/", Path.GetInvalidPathChars()))); } + [Fact] + public void ResolveReference_SHA256() + { + using var temp = new TempRoot(); + + var gitDir = temp.CreateDirectory(); + + var commonDir = temp.CreateDirectory(); + var refsHeadsDir = commonDir.CreateDirectory("refs").CreateDirectory("heads"); + + // SHA256 hash (64 characters) + refsHeadsDir.CreateFile("master").WriteAllText("0000000000000000000000000000000000000000000000000000000000000000"); + refsHeadsDir.CreateFile("br1").WriteAllText("ref: refs/heads/br2"); + refsHeadsDir.CreateFile("br2").WriteAllText("ref: refs/heads/master"); + + using var resolver = new GitReferenceResolver(gitDir.Path, commonDir.Path, ReferenceStorageFormat.LooseFiles, ObjectNameFormat.Sha256); + + // Verify SHA256 hash is accepted directly + Assert.Equal( + "0123456789ABCDEFabcdef00000000000000000000000000000000000000000000", + resolver.ResolveReference("0123456789ABCDEFabcdef00000000000000000000000000000000000000000000")); + + Assert.Equal("0000000000000000000000000000000000000000000000000000000000000000", resolver.ResolveReference("ref: refs/heads/master")); + Assert.Equal("0000000000000000000000000000000000000000000000000000000000000000", resolver.ResolveReference("ref: refs/heads/br1")); + Assert.Equal("0000000000000000000000000000000000000000000000000000000000000000", resolver.ResolveReference("ref: refs/heads/br2")); + } + [Fact] public void ResolveReference_Errors() { @@ -53,14 +80,24 @@ public void ResolveReference_Errors() refsHeadsDir.CreateFile("rec1").WriteAllText("ref: refs/heads/rec2"); refsHeadsDir.CreateFile("rec2").WriteAllText("ref: refs/heads/rec1"); - var resolver = new GitReferenceResolver(gitDir.Path, commonDir.Path); + using var resolver1 = new GitReferenceResolver(gitDir.Path, commonDir.Path, ReferenceStorageFormat.LooseFiles, ObjectNameFormat.Sha1); - Assert.Throws(() => resolver.ResolveReference("ref: refs/heads/rec1")); - Assert.Throws(() => resolver.ResolveReference("ref: xyz/heads/rec1")); - Assert.Throws(() => resolver.ResolveReference("ref:refs/heads/rec1")); - Assert.Throws(() => resolver.ResolveReference("refs/heads/rec1")); - Assert.Throws(() => resolver.ResolveReference(new string('0', 39))); - Assert.Throws(() => resolver.ResolveReference(new string('0', 41))); + Assert.Throws(() => resolver1.ResolveReference("ref: refs/heads/rec1")); + Assert.Throws(() => resolver1.ResolveReference("ref: xyz/heads/rec1")); + Assert.Throws(() => resolver1.ResolveReference("ref:refs/heads/rec1")); + Assert.Throws(() => resolver1.ResolveReference("refs/heads/rec1")); + + // Invalid SHA1 hash lengths + Assert.Throws(() => resolver1.ResolveReference(new string('0', ObjectNameFormat.Sha1.HashSize * 2 - 1))); + Assert.Throws(() => resolver1.ResolveReference(new string('0', ObjectNameFormat.Sha1.HashSize * 2 + 1))); + Assert.Throws(() => resolver1.ResolveReference(new string('0', ObjectNameFormat.Sha256.HashSize * 2))); + + using var resolver2 = new GitReferenceResolver(gitDir.Path, commonDir.Path, ReferenceStorageFormat.LooseFiles, ObjectNameFormat.Sha256); + + // Invalid SHA256 hash lengths + Assert.Throws(() => resolver2.ResolveReference(new string('0', ObjectNameFormat.Sha256.HashSize * 2 - 1))); + Assert.Throws(() => resolver2.ResolveReference(new string('0', ObjectNameFormat.Sha256.HashSize * 2 + 1))); + Assert.Throws(() => resolver2.ResolveReference(new string('0', ObjectNameFormat.Sha1.HashSize * 2))); } [Fact] @@ -80,13 +117,38 @@ 2222222222222222222222222222222222222222 refs/heads/br2 refsHeadsDir.CreateFile("br1").WriteAllText("ref: refs/heads/br2"); - var resolver = new GitReferenceResolver(gitDir.Path, commonDir.Path); + using var resolver = new GitReferenceResolver(gitDir.Path, commonDir.Path, ReferenceStorageFormat.LooseFiles, ObjectNameFormat.Sha1); Assert.Equal("1111111111111111111111111111111111111111", resolver.ResolveReference("ref: refs/heads/master")); Assert.Equal("2222222222222222222222222222222222222222", resolver.ResolveReference("ref: refs/heads/br1")); Assert.Equal("2222222222222222222222222222222222222222", resolver.ResolveReference("ref: refs/heads/br2")); } + [Fact] + public void ResolveReference_Packed_SHA256() + { + using var temp = new TempRoot(); + + var gitDir = temp.CreateDirectory(); + + // Packed refs with SHA256 hashes (64 characters) + gitDir.CreateFile("packed-refs").WriteAllText( +@"# pack-refs with: peeled fully-peeled sorted +1111111111111111111111111111111111111111111111111111111111111111 refs/heads/master +2222222222222222222222222222222222222222222222222222222222222222 refs/heads/br2 +"); + var commonDir = temp.CreateDirectory(); + var refsHeadsDir = commonDir.CreateDirectory("refs").CreateDirectory("heads"); + + refsHeadsDir.CreateFile("br1").WriteAllText("ref: refs/heads/br2"); + + using var resolver = new GitReferenceResolver(gitDir.Path, commonDir.Path, ReferenceStorageFormat.LooseFiles, ObjectNameFormat.Sha256); + + Assert.Equal("1111111111111111111111111111111111111111111111111111111111111111", resolver.ResolveReference("ref: refs/heads/master")); + Assert.Equal("2222222222222222222222222222222222222222222222222222222222222222", resolver.ResolveReference("ref: refs/heads/br1")); + Assert.Equal("2222222222222222222222222222222222222222222222222222222222222222", resolver.ResolveReference("ref: refs/heads/br2")); + } + [Fact] public void ReadPackedReferences() { @@ -101,7 +163,8 @@ 6666666666666666666666666666666666666666 y z 7777777777777777777777777777777777777777 refs/heads/br "; - var actual = GitReferenceResolver.ReadPackedReferences(new StringReader(packedRefs), ""); + using var resolver = new GitReferenceResolver(TempRoot.Root, TempRoot.Root, ReferenceStorageFormat.LooseFiles, ObjectNameFormat.Sha1); + var actual = resolver.ReadPackedReferences(new StringReader(packedRefs), ""); AssertEx.SetEqual(new[] { @@ -110,26 +173,88 @@ 7777777777777777777777777777777777777777 refs/heads/br }, actual.Select(e => $"{e.Key}:{e.Value}")); } + [Fact] + public void ReadPackedReferences_SHA256() + { + var packedRefs = +@"# pack-refs with: +1111111111111111111111111111111111111111111111111111111111111111 refs/heads/master +2222222222222222222222222222222222222222222222222222222222222222 refs/heads/br +^3333333333333333333333333333333333333333333333333333333333333333 +4444444444444444444444444444444444444444444444444444444444444444 x +5555555555555555555555555555555555555555555555555555555555555555 y +6666666666666666666666666666666666666666666666666666666666666666 y z +7777777777777777777777777777777777777777777777777777777777777777 refs/heads/br +"; + + using var resolver = new GitReferenceResolver(TempRoot.Root, TempRoot.Root, ReferenceStorageFormat.LooseFiles, ObjectNameFormat.Sha256); + var actual = resolver.ReadPackedReferences(new StringReader(packedRefs), ""); + + AssertEx.SetEqual(new[] + { + "refs/heads/br:2222222222222222222222222222222222222222222222222222222222222222", + "refs/heads/master:1111111111111111111111111111111111111111111111111111111111111111" + }, actual.Select(e => $"{e.Key}:{e.Value}")); + } + [Theory] [InlineData("# pack-refs with:")] [InlineData("# pack-refs with:xyz")] [InlineData("# pack-refs with:xyz\n")] public void ReadPackedReferences_Empty(string content) { - Assert.Empty(GitReferenceResolver.ReadPackedReferences(new StringReader(content), "")); + using var resolver = new GitReferenceResolver(TempRoot.Root, TempRoot.Root, ReferenceStorageFormat.LooseFiles, ObjectNameFormat.Sha256); + Assert.Empty(resolver.ReadPackedReferences(new StringReader(content), "")); } [Theory] [InlineData("")] // missing header [InlineData("# pack-refs with")] // invalid header prefix [InlineData("# pack-refs with:xyz\n1")] // bad object id + [InlineData("# pack-refs with:xyz\n^2222222222222222222222222222222222222222222222222222222222222222")] // bad object id: sha256 [InlineData("# pack-refs with:xyz\n1111111111111111111111111111111111111111")] // no reference name [InlineData("# pack-refs with:xyz\n^1111111111111111111111111111111111111111")] // tag dereference without previous ref [InlineData("# pack-refs with:xyz\n1111111111111111111111111111111111111111 x\n^1")] // bad object id [InlineData("# pack-refs with:xyz\n^1111111111111111111111111111111111111111\n^2222222222222222222222222222222222222222")] // tag dereference without previous ref public void ReadPackedReferences_Errors(string content) { - Assert.Throws(() => GitReferenceResolver.ReadPackedReferences(new StringReader(content), "")); + using var resolver = new GitReferenceResolver(TempRoot.Root, TempRoot.Root, ReferenceStorageFormat.LooseFiles, ObjectNameFormat.Sha1); + Assert.Throws(() => resolver.ReadPackedReferences(new StringReader(content), "")); + } + + [Fact] + public void ResolveReference_RefTable() + { + using var temp = new TempRoot(); + + var gitDir = temp.CreateDirectory(); + var commonDir = temp.CreateDirectory(); + + var refTableDir = gitDir.CreateDirectory("reftable"); + + refTableDir.CreateFile("tables.list").WriteAllText(""" + 2.ref + 1.ref + """); + + var ref1 = refTableDir.CreateFile("1.ref").WriteAllBytes(GitRefTableTestWriter.GetRefTableBlob([("refs/heads/a", 0x01), ("refs/heads/c", 0x02)])); + TempFile ref2; + + using (var resolver = new GitReferenceResolver(gitDir.Path, commonDir.Path, ReferenceStorageFormat.RefTable, ObjectNameFormat.Sha1)) + { + Assert.Equal("0100000000000000000000000000000000000000", resolver.ResolveReference("ref: refs/heads/a")); + Assert.Equal("0200000000000000000000000000000000000000", resolver.ResolveReference("ref: refs/heads/c")); + + // 2.ref shouldn't be opened until needed: + ref2 = refTableDir.CreateFile("2.ref").WriteAllBytes(GitRefTableTestWriter.GetRefTableBlob([("refs/heads/b", 0x03), ("refs/heads/c", 0x04)])); + + Assert.Equal("0300000000000000000000000000000000000000", resolver.ResolveReference("ref: refs/heads/b")); + Assert.Null(resolver.ResolveReference("ref: refs/heads/d")); + } + + // files should have been closed: + File.WriteAllBytes(ref1.Path, [0]); + File.WriteAllBytes(ref2.Path, [0]); } } } diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/GitRepositoryTests.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/GitRepositoryTests.cs index 7ba9993d..e32f3af2 100644 --- a/src/Microsoft.Build.Tasks.Git.UnitTests/GitRepositoryTests.cs +++ b/src/Microsoft.Build.Tasks.Git.UnitTests/GitRepositoryTests.cs @@ -65,7 +65,9 @@ public void TryFindRepository_Worktree_Realistic() { using var temp = new TempRoot(); - var mainWorkingDir = temp.CreateDirectory(); + var repoDir = temp.CreateDirectory(); + + var mainWorkingDir = repoDir.CreateDirectory("main"); var mainWorkingSubDir = mainWorkingDir.CreateDirectory("A"); var mainGitDir = mainWorkingDir.CreateDirectory(".git"); mainGitDir.CreateFile("HEAD"); @@ -73,13 +75,17 @@ public void TryFindRepository_Worktree_Realistic() var worktreesDir = mainGitDir.CreateDirectory("worktrees"); var worktreeGitDir = worktreesDir.CreateDirectory("myworktree"); var worktreeGitSubDir = worktreeGitDir.CreateDirectory("B"); - var worktreeDir = temp.CreateDirectory(); + var worktreeDir = repoDir.CreateDirectory("worktree"); var worktreeSubDir = worktreeDir.CreateDirectory("C"); - var worktreeGitFile = worktreeDir.CreateFile(".git").WriteAllText("gitdir: " + worktreeGitDir + " \r\n\t\v"); + + // test relative path to work tree dir: + var worktreeGitFile = worktreeDir.CreateFile(".git").WriteAllText("gitdir: ../main/.git/worktrees/myworktree \r\n\t\v"); worktreeGitDir.CreateFile("HEAD"); worktreeGitDir.CreateFile("commondir").WriteAllText("../..\n"); - worktreeGitDir.CreateFile("gitdir").WriteAllText(worktreeGitFile.Path + " \r\n\t\v"); + + // test relative path to work tree .git file: + worktreeGitDir.CreateFile("gitdir").WriteAllText("../../../../worktree/.git \r\n\t\v"); // start under main repository directory: Assert.True(GitRepository.TryFindRepository(mainWorkingSubDir.Path, out var location)); @@ -226,6 +232,8 @@ public void OpenRepository_Version1_Extensions() preciousObjects = true partialClone = promisor_remote worktreeConfig = true + relativeWorktrees = true + objectformat = sha256 "); Assert.True(GitRepository.TryFindRepository(gitDir.Path, out var location)); @@ -264,6 +272,36 @@ public void OpenRepository_Version1_UnknownExtension() Assert.Throws(() => GitRepository.OpenRepository(src.Path, new GitEnvironment(homeDir.Path))); } + [Fact] + public void OpenRepository_Version1_ObjectFormatExtension() + { + using var temp = new TempRoot(); + + var homeDir = temp.CreateDirectory(); + + var workingDir = temp.CreateDirectory(); + var gitDir = workingDir.CreateDirectory(".git"); + + gitDir.CreateFile("HEAD").WriteAllText("ref: refs/heads/master"); + gitDir.CreateDirectory("refs").CreateDirectory("heads").CreateFile("master").WriteAllText("0000000000000000000000000000000000000000"); + gitDir.CreateDirectory("objects"); + + gitDir.CreateFile("config").WriteAllText(@" +[core] + repositoryformatversion = 1 +[extensions] + objectformat = sha256"); + + var src = workingDir.CreateDirectory("src"); + + // Should not throw - objectformat extension should be supported + var repository = GitRepository.OpenRepository(src.Path, new GitEnvironment(homeDir.Path)); + Assert.NotNull(repository); + Assert.Equal(gitDir.Path, repository.GitDirectory); + Assert.Equal(gitDir.Path, repository.CommonDirectory); + Assert.Equal(workingDir.Path, repository.WorkingDirectory); + } + [Fact] public void OpenRepository_VersionNotSupported() { @@ -307,7 +345,7 @@ public void OpenRepository_Worktree_GitdirFileMissing() Assert.Equal(worktreeGitDir.Path, location.GitDirectory); Assert.Equal(mainGitDir.Path, location.CommonDirectory); Assert.Equal(worktreeDir.Path, location.WorkingDirectory); - + var repository = GitRepository.OpenRepository(location, GitEnvironment.Empty); Assert.Equal(repository.GitDirectory, location.GitDirectory); Assert.Equal(repository.CommonDirectory, location.CommonDirectory); @@ -346,7 +384,7 @@ public void OpenRepository_Worktree_GitdirFileDifferentPath() var repository = GitRepository.OpenRepository(location, GitEnvironment.Empty); Assert.Equal(repository.GitDirectory, location.GitDirectory); Assert.Equal(repository.CommonDirectory, location.CommonDirectory); - + // actual working dir is not affected: Assert.Equal(worktreeDir.Path, location.WorkingDirectory); } @@ -503,7 +541,7 @@ public void GetSubmoduleHeadCommitSha() submoduleGitDir.CreateFile("HEAD").WriteAllText("ref: refs/heads/master"); Assert.Equal("0000000000000000000000000000000000000000", - GitRepository.GetSubmoduleReferenceResolver(submoduleWorkingDir.Path)?.ResolveHeadReference()); + GitRepository.GetSubmoduleReferenceResolver(submoduleWorkingDir.Path, GitEnvironment.Empty)?.ResolveHeadReference()); } [Fact] @@ -523,7 +561,7 @@ public void GetOldStyleSubmoduleHeadCommitSha() oldStyleSubmoduleGitDir.CreateFile("HEAD").WriteAllText("ref: refs/heads/branch1"); Assert.Equal("1111111111111111111111111111111111111111", - GitRepository.GetSubmoduleReferenceResolver(oldStyleSubmoduleWorkingDir.Path)?.ResolveHeadReference()); + GitRepository.GetSubmoduleReferenceResolver(oldStyleSubmoduleWorkingDir.Path, GitEnvironment.Empty)?.ResolveHeadReference()); } [Fact] @@ -537,7 +575,38 @@ public void GetSubmoduleHeadCommitSha_NoGitFile() var submoduleGitDir = temp.CreateDirectory(); var submoduleWorkingDir = workingDir.CreateDirectory("sub").CreateDirectory("abc"); - Assert.Null(GitRepository.GetSubmoduleReferenceResolver(submoduleWorkingDir.Path)?.ResolveHeadReference()); + Assert.Null(GitRepository.GetSubmoduleReferenceResolver(submoduleWorkingDir.Path, GitEnvironment.Empty)?.ResolveHeadReference()); + } + + [Fact] + public void GetSubmoduleHeadCommitSha_RefTable() + { + using var temp = new TempRoot(); + + var gitDir = temp.CreateDirectory(); + var workingDir = temp.CreateDirectory(); + + var submoduleGitDir = temp.CreateDirectory(); + + var submoduleWorkingDir = workingDir.CreateDirectory("sub").CreateDirectory("abc"); + submoduleWorkingDir.CreateFile(".git").WriteAllText("gitdir: " + submoduleGitDir.Path + "\t \v\f\r\n\n\r"); + submoduleGitDir.CreateFile("HEAD").WriteAllText("ref: refs/heads/.invalid"); + + submoduleGitDir.CreateFile("config").WriteAllText(""" + [core] + repositoryformatversion = 1 + [extensions] + refStorage = reftable + """); + + var refTableDir = submoduleGitDir.CreateDirectory("reftable"); + refTableDir.CreateFile("tables.list").WriteAllText("1.ref"); + var ref1 = refTableDir.CreateFile("1.ref").WriteAllBytes(GitRefTableTestWriter.GetRefTableBlob([("HEAD", "refs/heads/main"), ("refs/heads/main", 0x01)])); + + var resolver = GitRepository.GetSubmoduleReferenceResolver(submoduleWorkingDir.Path, GitEnvironment.Empty); + Assert.NotNull(resolver); + Assert.Equal("0100000000000000000000000000000000000000", resolver.ResolveHeadReference()); + Assert.Equal("refs/heads/main", resolver.GetBranchForHead()); } [Fact] diff --git a/src/Microsoft.Build.Tasks.Git.UnitTests/TestUtilities.cs b/src/Microsoft.Build.Tasks.Git.UnitTests/TestUtilities.cs index abfd0721..a49590f4 100644 --- a/src/Microsoft.Build.Tasks.Git.UnitTests/TestUtilities.cs +++ b/src/Microsoft.Build.Tasks.Git.UnitTests/TestUtilities.cs @@ -3,6 +3,8 @@ // See the License.txt file in the project root for more information. using System; +using System.Collections; +using System.Collections.Generic; using Microsoft.Build.Framework; namespace Microsoft.Build.Tasks.Git.UnitTests diff --git a/src/Microsoft.Build.Tasks.Git/GitDataReader/CharUtils.cs b/src/Microsoft.Build.Tasks.Git/GitDataReader/CharUtils.cs index 730910dd..71d265fb 100644 --- a/src/Microsoft.Build.Tasks.Git/GitDataReader/CharUtils.cs +++ b/src/Microsoft.Build.Tasks.Git/GitDataReader/CharUtils.cs @@ -13,5 +13,8 @@ internal static class CharUtils public static bool IsHexadecimalDigit(char c) => c is >= '0' and <= '9' or >= 'A' and <= 'F' or >= 'a' and <= 'f'; + + public static char ToHexDigit(byte nibble) + => nibble < 10 ? (char)('0' + nibble) : (char)('a' + (nibble - 10)); } } diff --git a/src/Microsoft.Build.Tasks.Git/GitDataReader/CollectionExtensions.cs b/src/Microsoft.Build.Tasks.Git/GitDataReader/CollectionExtensions.cs new file mode 100644 index 00000000..76f4bb14 --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git/GitDataReader/CollectionExtensions.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Build.Tasks.Git; + +internal static class CollectionExtensions +{ + internal static (TValue? exactMatch, int firstGreater) BinarySearch( + this IReadOnlyList array, + int index, + int length, + Func selector, + Func compareItemToSearchValue) + { + var lo = index; + var hi = index + length - 1; + while (lo <= hi) + { + var i = lo + ((hi - lo) >> 1); + var item = selector(array[i]); + + var comparison = compareItemToSearchValue(item); + if (comparison == 0) + { + return (exactMatch: item, firstGreater: -1); + } + + if (comparison < 0) + { + lo = i + 1; + } + else + { + hi = i - 1; + } + } + + return (exactMatch: default, firstGreater: lo); + } +} diff --git a/src/Microsoft.Build.Tasks.Git/GitDataReader/GitConfig.Reader.cs b/src/Microsoft.Build.Tasks.Git/GitDataReader/GitConfig.Reader.cs index 854e6785..f0bdc503 100644 --- a/src/Microsoft.Build.Tasks.Git/GitDataReader/GitConfig.Reader.cs +++ b/src/Microsoft.Build.Tasks.Git/GitDataReader/GitConfig.Reader.cs @@ -60,7 +60,7 @@ internal sealed class Reader private readonly Func _fileOpener; private readonly GitEnvironment _environment; - public Reader(string gitDirectory, string commonDirectory, GitEnvironment environment, Func? fileOpener = null) + internal Reader(string gitDirectory, string commonDirectory, GitEnvironment environment, Func? fileOpener = null) { NullableDebug.Assert(environment != null); @@ -102,7 +102,7 @@ internal GitConfig LoadFrom(string path) { return Path.Combine(xdgConfigHome, "git"); } - + if (_environment.HomeDirectory != null) { return Path.Combine(_environment.HomeDirectory, ".config", "git"); diff --git a/src/Microsoft.Build.Tasks.Git/GitDataReader/GitConfig.cs b/src/Microsoft.Build.Tasks.Git/GitDataReader/GitConfig.cs index 4462d0fc..40e13fc6 100644 --- a/src/Microsoft.Build.Tasks.Git/GitDataReader/GitConfig.cs +++ b/src/Microsoft.Build.Tasks.Git/GitDataReader/GitConfig.cs @@ -5,24 +5,98 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; -using System.Text; namespace Microsoft.Build.Tasks.Git { internal sealed partial class GitConfig { - public static readonly GitConfig Empty = new GitConfig(ImmutableDictionary>.Empty); + public static readonly GitConfig Empty = new(ImmutableDictionary>.Empty); + + private const int SupportedGitRepoFormatVersion = 1; + + private const string CoreSectionName = "core"; + private const string ExtensionsSectionName = "extensions"; + + private const string RefStorageExtensionName = "refstorage"; + private const string ObjectFormatExtensionName = "objectFormat"; + private const string RelativeWorktreesExtensionName = "relativeWorktrees"; + private const string RepositoryFormatVersionVariableName = "repositoryformatversion"; + + private static readonly ImmutableArray s_knownExtensions = + ["noop", "preciousObjects", "partialclone", "worktreeConfig", RefStorageExtensionName, ObjectFormatExtensionName, RelativeWorktreesExtensionName]; public readonly ImmutableDictionary> Variables; + public readonly ReferenceStorageFormat ReferenceStorageFormat; + + /// + /// The parsed value of "extensions.objectFormat" variable. + /// + public ObjectNameFormat ObjectNameFormat { get; } + /// public GitConfig(ImmutableDictionary> variables) { - NullableDebug.Assert(variables != null); Variables = variables; + + ReferenceStorageFormat = GetVariableValue(ExtensionsSectionName, RefStorageExtensionName) switch + { + null => ReferenceStorageFormat.LooseFiles, + "reftable" => ReferenceStorageFormat.RefTable, + _ => throw new InvalidDataException(), + }; + + ObjectNameFormat = GetVariableValue(ExtensionsSectionName, ObjectFormatExtensionName) switch + { + null or "sha1" => ObjectNameFormat.Sha1, + "sha256" => ObjectNameFormat.Sha256, + _ => throw new InvalidDataException(), + }; + } + + /// + /// + /// + public static GitConfig ReadRepositoryConfig(string gitDirectory, string commonDirectory, GitEnvironment environment) + { + var reader = new Reader(gitDirectory, commonDirectory, environment); + var config = reader.Load(); + config.ValidateRepositoryConfig(); + return config; + } + + /// + /// + /// + public static GitConfig ReadSubmoduleConfig(string gitDirectory, string commonDirectory, GitEnvironment environment, string submodulesFile) + { + var reader = new Reader(gitDirectory, commonDirectory, environment); + return reader.LoadFrom(submodulesFile); + } + + private void ValidateRepositoryConfig() + { + // See https://github.com/git/git/blob/master/Documentation/technical/repository-version.txt + var versionStr = GetVariableValue(CoreSectionName, RepositoryFormatVersionVariableName); + if (TryParseInt64Value(versionStr, out var version) && version > SupportedGitRepoFormatVersion) + { + throw new NotSupportedException(string.Format(Resources.UnsupportedRepositoryVersion, versionStr, SupportedGitRepoFormatVersion)); + } + + if (version == 1) + { + // All variables defined under extensions section must be known, otherwise a git implementation is not allowed to proceed. + foreach (var variable in Variables) + { + if (variable.Key.SectionNameEquals(ExtensionsSectionName) && + !s_knownExtensions.Contains(variable.Key.VariableName, StringComparer.OrdinalIgnoreCase)) + { + throw new NotSupportedException(string.Format( + Resources.UnsupportedRepositoryExtension, variable.Key.VariableName, string.Join(", ", s_knownExtensions))); + } + } + } } // for testing: diff --git a/src/Microsoft.Build.Tasks.Git/GitDataReader/GitRefTableReader.Primitives.cs b/src/Microsoft.Build.Tasks.Git/GitDataReader/GitRefTableReader.Primitives.cs new file mode 100644 index 00000000..dbd6b532 --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git/GitDataReader/GitRefTableReader.Primitives.cs @@ -0,0 +1,147 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using System.Text; + +namespace Microsoft.Build.Tasks.Git; + +internal sealed partial class GitRefTableReader +{ + private const int SizeOfUInt24 = 3; + + private readonly byte[] _buffer = new byte[64]; + + private long Position + { + get => stream.Position; + set => stream.Position = value; + } + + private long Length + => stream.Length; + + internal byte ReadByte() + { + var result = stream.ReadByte(); + if (result == -1) + { + throw new EndOfStreamException(); + } + + return (byte)result; + } + + /// + /// See https://git-scm.com/docs/reftable#_varint_encoding + /// and "offset encoding" in https://git-scm.com/docs/pack-format#_original_version_1_pack_idx_files_have_the_following_format + /// + internal int ReadVarInt() + { + long result = -1; + + while (true) + { + var b = ReadByte(); + + result = (result + 1) << 7 | (long)(b & 0x7f); + if (result > int.MaxValue) + { + throw new InvalidDataException(); + } + + if ((b & 0x80) == 0) + { + return (int)result; + } + } + } + + internal ushort ReadUInt16BE() + { + ReadExactly(_buffer, sizeof(ushort)); + return (ushort)(_buffer[0] << 8 | _buffer[1]); + } + + internal int ReadUInt24BE() + { + ReadExactly(_buffer, 3); + return _buffer[0] << 16 | _buffer[1] << 8 | _buffer[2]; + } + + internal uint ReadUInt32BE() + { + ReadExactly(_buffer, sizeof(uint)); + return + (uint)_buffer[0] << 24 | + (uint)_buffer[1] << 16 | + (uint)_buffer[2] << 8 | + _buffer[3]; + } + + internal ulong ReadUInt64BE() + { + ReadExactly(_buffer, sizeof(ulong)); + return + (ulong)_buffer[0] << 56 | + (ulong)_buffer[1] << 48 | + (ulong)_buffer[2] << 40 | + (ulong)_buffer[3] << 32 | + (ulong)_buffer[4] << 24 | + (ulong)_buffer[5] << 16 | + (ulong)_buffer[6] << 8 | + _buffer[7]; + } + + internal string ReadObjectName(int objectIdSize) + { + ReadExactly(_buffer, objectIdSize); + + var builder = new StringBuilder(objectIdSize * 2); + + for (var i = 0; i < objectIdSize; i++) + { + var b = _buffer[i]; + builder.Append(CharUtils.ToHexDigit((byte)(b >> 4))); + builder.Append(CharUtils.ToHexDigit((byte)(b & 0xf))); + } + + return builder.ToString(); + } + + internal byte[] ReadBytes(int count) + { + byte[] bytes; + try + { + bytes = new byte[count]; + } + catch (OutOfMemoryException) + { + throw new InvalidDataException(); + } + + ReadExactly(bytes); + return bytes; + } + + internal void ReadExactly(byte[] buffer) + => ReadExactly(buffer, buffer.Length); + + internal void ReadExactly(byte[] buffer, int count) + { + var totalRead = 0; + while (totalRead < count) + { + var read = stream.Read(buffer, totalRead, count - totalRead); + if (read == 0) + { + throw new EndOfStreamException(); + } + + totalRead += read; + } + } +} diff --git a/src/Microsoft.Build.Tasks.Git/GitDataReader/GitRefTableReader.Structs.cs b/src/Microsoft.Build.Tasks.Git/GitDataReader/GitRefTableReader.Structs.cs new file mode 100644 index 00000000..629eb4d4 --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git/GitDataReader/GitRefTableReader.Structs.cs @@ -0,0 +1,52 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.Build.Tasks.Git; + +internal sealed partial class GitRefTableReader +{ + internal readonly struct Header + { + /// + /// Size of the header (differs between versions). + /// + public int Size { get; init; } + + /// + /// Block size or 0 if blocks are unaligned. + /// + public int BlockSize { get; init; } + + public ObjectNameFormat ObjectNameFormat { get; init; } + } + + internal readonly struct Footer + { + public const int SizeExcludingHeader = 44; + + public Header Header { get; init; } + public long RefIndexPosition { get; init; } + } + + internal readonly struct RefRecord + { + public string RefName { get; init; } + public string? ObjectName { get; init; } + public string? SymbolicRef { get; init; } + } + + internal readonly struct RefIndexRecord + { + /// + /// Last reference in the target block. + /// + public string LastRefName { get; init; } + + /// + /// Position of leaf RefBlock or next level RefIndexBlock from the start of the file. + /// + public long BlockPosition { get; init; } + } + +} diff --git a/src/Microsoft.Build.Tasks.Git/GitDataReader/GitRefTableReader.cs b/src/Microsoft.Build.Tasks.Git/GitDataReader/GitRefTableReader.cs new file mode 100644 index 00000000..e84a4a8c --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git/GitDataReader/GitRefTableReader.cs @@ -0,0 +1,545 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Hashing; +using System.Text; + +namespace Microsoft.Build.Tasks.Git; + +/// +/// Implements reftable data structure used by Git to store references. +/// See https://git-scm.com/docs/reftable +/// +internal sealed partial class GitRefTableReader(Stream stream) : IDisposable +{ + private static readonly Encoding s_utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + private const uint Magic = 'R' << 24 | 'E' << 16 | 'F' << 8 | 'T'; + private const uint HashId_SHA1 = 's' << 24 | 'h' << 16 | 'a' << 8 | '1'; + private const uint HashId_SHA256 = 's' << 24 | '2' << 16 | '5' << 8 | '6'; + + private const byte BlockTypeRef = (byte)'r'; + private const byte BlockTypeIndex = (byte)'i'; + + private const int MinRefBlockSize = + 1 + // block_type + 3 + // uint24(block_len) + 3 + // uint24(restart_offset)+ + 2; // uint16(restart_count) + + public void Dispose() + { + stream.Dispose(); + } + + /// + /// + /// + public bool TryFindReference(string referenceName, out string? objectName, out string? symbolicReference) + { + var record = FindRefRecord(referenceName); + if (record == null) + { + objectName = null; + symbolicReference = null; + return false; + } + + objectName = record.Value.ObjectName; + symbolicReference = record.Value.SymbolicRef; + return true; + } + + private RefRecord? FindRefRecord(string referenceName) + { + Position = 0; + + // Readers are required to read header and validate version it before reading the footer. + // https://git-scm.com/docs/reftable#_footer + var header = ReadHeader(); + + var footerPosition = Length - header.Size - Footer.SizeExcludingHeader; + + // Skip ahead to read the footer. + // It contains a copy of header and position of the RefIndex. + Position = footerPosition; + var footer = ReadFooter(); + + if (footer.RefIndexPosition > 0) + { + Position = footer.RefIndexPosition; + if (ReadByte() != BlockTypeIndex) + { + throw new InvalidDataException(); + } + + return SearchRefIndex(footer.Header, referenceName); + } + + // No RefIndex, read RefBlocks sequentially. + + var blockStartPosition = 0L; + + while (true) + { + var isFirstBlock = blockStartPosition == 0; + + // The first block starts with header. + Position = blockStartPosition + (isFirstBlock ? header.Size : 0); + switch (ReadByte()) + { + case BlockTypeRef: + break; + + case BlockTypeIndex: + throw new InvalidDataException(); + + default: + return isFirstBlock ? throw new InvalidDataException() : null; + } + + var result = SearchRefBlock(footer.Header, referenceName, out var blockEndPosition); + if (result != null) + { + return result; + } + + // If the file is unaligned the next block starts at the end of the current block, + // otherwise the current block its padded to block size and the next block starts after the padding. + blockStartPosition = footer.Header.BlockSize > 0 ? blockStartPosition + footer.Header.BlockSize : blockEndPosition; + if (blockStartPosition >= footerPosition) + { + return null; + } + } + } + + internal Header ReadHeader() + { + // https://git-scm.com/docs/reftable#_header_version_1 + + var headerStartPosition = Position; + + var magic = ReadUInt32BE(); + if (magic != Magic) + { + throw new InvalidDataException(); + } + + var version = ReadByte(); + if (version is not (1 or 2)) + { + throw new InvalidDataException(); + } + + // block_size + var blockSize = ReadUInt24BE(); + + // min_update_index + _ = ReadUInt64BE(); + + // max_update_index + _ = ReadUInt64BE(); + + ObjectNameFormat objectNameFormat; + if (version == 1) + { + objectNameFormat = ObjectNameFormat.Sha1; + } + else + { + objectNameFormat = ReadUInt32BE() switch + { + HashId_SHA1 => ObjectNameFormat.Sha1, + HashId_SHA256 => ObjectNameFormat.Sha256, + _ => throw new InvalidDataException(), + }; + } + + return new Header + { + Size = (int)(Position - headerStartPosition), + BlockSize = blockSize, + ObjectNameFormat = objectNameFormat + }; + } + + internal Footer ReadFooter() + { + var footerStartPosition = Position; + + // header + var header = ReadHeader(); + + // uint64(ref_index_position) + var refIndexPosition = ReadUInt64BE(); + if (refIndexPosition > (ulong)Length) + { + throw new InvalidDataException(); + } + + // obj_position (not need for reference lookup): + _ = ReadUInt64BE(); + + // obj_index_position (not need for reference lookup): + _ = ReadUInt64BE(); + + // log_position (not need for reference lookup): + _ = ReadUInt64BE(); + + // log_index_position (not need for reference lookup): + _ = ReadUInt64BE(); + + var checksumedSectionEndPosition = Position; + + // uint32(CRC - 32 of above) + var checksum = ReadUInt32BE(); + + // Validate checksum: + Position = footerStartPosition; + var buffer = ReadBytes((int)(checksumedSectionEndPosition - footerStartPosition)); + var computedChecksum = Crc32.HashToUInt32(buffer); + if (computedChecksum != checksum) + { + throw new InvalidDataException(); + } + + return new Footer + { + Header = header, + RefIndexPosition = (long)refIndexPosition + }; + } + + /// + /// RefIndex stores the last reference name of each RefBlock. + /// + private RefRecord? SearchRefIndex(Header header, string referenceName) + { + // 'i' (already read) + // uint24(block_len) + // index_record+ + // uint24(restart_offset)+ + // uint16(restart_count) + // padding? + + // The index block may exceed the block size specified in the header. + var restartOffsets = ReadRestartOffsets(header, blockLengthLimited: false, out var blockStartPosition, out _); + + var (record, firstGreater) = restartOffsets.BinarySearch( + index: 0, + length: restartOffsets.Length - 1, + selector: restartOffset => + { + Position = blockStartPosition + restartOffset; + return ReadRefIndexRecord(priorName: ""); + }, + compareItemToSearchValue: item => StringComparer.Ordinal.Compare(item.LastRefName, referenceName)); + + if (firstGreater < 0) + { + // the last reference of the block is the one we are looking for: + Position = record.BlockPosition; + return SearchBlock(header, referenceName); + } + + // firstGreater points to the first record at a restart offset that contains references with last name larger than the searched value. + // The reference is either in the record at firstGreater, or in the previous run. + + if (firstGreater < restartOffsets.Length - 1) + { + Position = blockStartPosition + restartOffsets[firstGreater]; + record = ReadRefIndexRecord(priorName: ""); + + Debug.Assert(StringComparer.Ordinal.Compare(referenceName, record.LastRefName) < 0); + + Position = record.BlockPosition; + var result = SearchBlock(header, referenceName); + if (result != null) + { + return result; + } + } + + firstGreater--; + + if (firstGreater == -1) + { + // reference is not found - it would be ordered before the first record + return null; + } + + Position = blockStartPosition + restartOffsets[firstGreater]; + var endPosition = blockStartPosition + restartOffsets[firstGreater + 1]; + + var priorName = ""; + while (Position < endPosition) + { + record = ReadRefIndexRecord(priorName); + + if (StringComparer.Ordinal.Compare(referenceName, record.LastRefName) <= 0) + { + // the last reference of the block is the one we are looking for: + Position = record.BlockPosition; + return SearchBlock(header, referenceName); + } + + priorName = record.LastRefName; + } + + return null; + } + + public RefRecord? SearchBlock(Header header, string referenceName) + { + return ReadByte() switch + { + BlockTypeRef => SearchRefBlock(header, referenceName, out _), + BlockTypeIndex => SearchRefIndex(header, referenceName), + _ => throw new InvalidDataException(), + }; + } + + private RefRecord? SearchRefBlock(Header header, string referenceName, out long blockEndPosition) + { + // 'r' (already read) + // uint24(block_len) + // ref_record+ + // uint24(restart_offset)+ + // uint16(restart_count) + // padding? + + var restartOffsets = ReadRestartOffsets(header, blockLengthLimited: true, out var blockStartPosition, out blockEndPosition); + + var (record, firstGreater) = restartOffsets.BinarySearch( + index: 0, + length: restartOffsets.Length - 1, + selector: restartOffset => + { + Position = blockStartPosition + restartOffset; + return ReadRefRecord(header, priorName: ""); + }, + compareItemToSearchValue: item => StringComparer.Ordinal.Compare(item.RefName, referenceName)); + + if (firstGreater < 0) + { + // The record at the index has the reference we are looking for. + return record; + } + + // firstGreater points to the first record at a restart offset that has reference name greater than the searched value. + // Record runs are sorted by *first* reference name of the run (run starts at a restart offset and ends at the next restart offset). + // Hence, firstGreater - 1 points to the run that contains the reference we are looking for. + if (firstGreater == 0) + { + // reference is not found - it would be ordered before the first record + return null; + } + + Position = blockStartPosition + restartOffsets[firstGreater - 1]; + var endPosition = blockStartPosition + restartOffsets[firstGreater]; + + var priorName = ""; + while (Position < endPosition) + { + record = ReadRefRecord(header, priorName); + + if (record.RefName == referenceName) + { + return record; + } + + priorName = record.RefName; + } + + return null; + } + + /// + /// Returns offsets (relative to the start of the block). + /// Each offset points at a record with no prefix optionally followed by records that use prefix compression. + /// The last offset points at the end of records in the block. + /// Offset values are increasing. + /// + internal int[] ReadRestartOffsets(Header header, bool blockLengthLimited, out long blockStartPosition, out long blockEndPosition) + { + const int SizeOfBlockType = sizeof(byte); + const int SizeOfRestartCount = sizeof(ushort); + const int SizeOfRestartOffset = SizeOfUInt24; + const int SizeOfBlockLength = SizeOfUInt24; + + // The first block includes the header. + // block_type is already read. + var isFirstBlock = Position == header.Size + SizeOfBlockType; + + // Block starts with a block_type: + blockStartPosition = isFirstBlock ? 0 : Position - SizeOfBlockType; + + // block_len of the first block includes the file header. + // Must be less than or equal to block size, unless the file is unaligned. + var blockLength = ReadUInt24BE(); + if (blockLengthLimited && header.BlockSize > 0 && blockLength > header.BlockSize || + blockLength < MinRefBlockSize) + { + throw new InvalidDataException(); + } + + // block_len excludes padding: + blockEndPosition = blockStartPosition + blockLength; + + // uint16(restart_count) + Position = blockStartPosition + blockLength - SizeOfRestartCount; + var restartCount = ReadUInt16BE(); + if (restartCount == 0) + { + throw new InvalidDataException(); + } + + // uint24(restart_offset)+ + var endOffset = blockLength - SizeOfRestartCount - SizeOfRestartOffset * restartCount; + Position = blockStartPosition + endOffset; + + int[] offsets; + try + { + offsets = new int[restartCount + 1]; + } + catch (OutOfMemoryException) + { + throw new InvalidDataException(); + } + + for (var i = 0; i < restartCount; i++) + { + // Offset relative to the start of the block: + var offset = ReadUInt24BE(); + + // first offset points to the first record: + if (i == 0 && offset != (isFirstBlock ? header.Size : 0) + SizeOfBlockType + SizeOfBlockLength) + { + throw new InvalidDataException(); + } + + // offsets must be increasing: + if (i > 0 && offset <= offsets[i - 1]) + { + throw new InvalidDataException(); + } + + if (offset >= endOffset) + { + throw new InvalidDataException(); + } + + offsets[i] = offset; + } + + offsets[^1] = endOffset; + + return offsets; + } + + private (string name, byte valueType) ReadNameAndValueType(string priorName) + { + // varint(prefix_length) + var prefixLength = ReadVarInt(); + + // varint((suffix_length << 3) | value_type) + var suffixLengthAndValueType = ReadVarInt(); + var suffixLength = suffixLengthAndValueType >> 3; + var valueType = (byte)(suffixLengthAndValueType & 0x07); + + // suffix + var suffixBytes = ReadBytes(suffixLength); + try + { + var name = priorName[0..prefixLength] + s_utf8.GetString(suffixBytes); + return (name, valueType); + } + catch + { + throw new InvalidDataException(); + } + } + + internal RefIndexRecord ReadRefIndexRecord(string priorName) + { + var (name, valueType) = ReadNameAndValueType(priorName); + if (valueType != 0) + { + throw new InvalidDataException(); + } + + // position of RefBlock from the start of the file: + var blockPosition = ReadVarInt(); + + return new RefIndexRecord + { + LastRefName = name, + BlockPosition = blockPosition + }; + } + + internal RefRecord ReadRefRecord(Header header, string priorName) + { + var (name, valueType) = ReadNameAndValueType(priorName); + + // varint(update_index_delta) + _ = ReadVarInt(); + + string? symbolicRef = null; + string? objectName = null; + + var objectIdSize = header.ObjectNameFormat.HashSize; + + switch (valueType) + { + case 0: // deletion, no value -- stops lookup from opening previous reftable file + break; + + case 1: // object name + objectName = ReadObjectName(objectIdSize); + break; + + case 2: // value, peeled target + objectName = ReadObjectName(objectIdSize); + + // peeled target object name + _ = ReadObjectName(objectIdSize); + break; + + case 3: // symbolic ref + symbolicRef = ReadSymbolicRef(); + break; + + default: + throw new InvalidDataException(); + } + + return new RefRecord + { + RefName = name, + ObjectName = objectName, + SymbolicRef = symbolicRef + }; + } + + private string ReadSymbolicRef() + { + var length = ReadVarInt(); + var bytes = ReadBytes(length); + + try + { + return s_utf8.GetString(bytes); + } + catch + { + throw new InvalidDataException(); + } + } +} diff --git a/src/Microsoft.Build.Tasks.Git/GitDataReader/GitReferenceResolver.cs b/src/Microsoft.Build.Tasks.Git/GitDataReader/GitReferenceResolver.cs index 4f3687e3..8f06c0f4 100644 --- a/src/Microsoft.Build.Tasks.Git/GitDataReader/GitReferenceResolver.cs +++ b/src/Microsoft.Build.Tasks.Git/GitDataReader/GitReferenceResolver.cs @@ -12,30 +12,54 @@ namespace Microsoft.Build.Tasks.Git { - internal sealed class GitReferenceResolver + internal sealed class GitReferenceResolver : IDisposable { // See https://git-scm.com/docs/gitrepository-layout#Documentation/gitrepository-layout.txt-HEAD private const string PackedRefsFileName = "packed-refs"; + private const string TablesListFileName = "tables.list"; private const string RefsPrefix = "refs/"; + private const string RefTableDirectoryName = "reftable"; private readonly string _commonDirectory; private readonly string _gitDirectory; + private readonly ReferenceStorageFormat _storageFormat; + private readonly ObjectNameFormat _objectNameFormat; // maps refs/heads references to the correspondign object ids: private readonly Lazy> _lazyPackedReferences; + private readonly Lazy> _lazyRefTableReferenceReaders; - public GitReferenceResolver(string gitDirectory, string commonDirectory) + // lock on access: + private readonly List _openedRefTableReaders = []; + + public GitReferenceResolver(string gitDirectory, string commonDirectory, ReferenceStorageFormat storageFormat, ObjectNameFormat objectNameFormat) { Debug.Assert(PathUtils.IsNormalized(gitDirectory)); Debug.Assert(PathUtils.IsNormalized(commonDirectory)); _gitDirectory = gitDirectory; _commonDirectory = commonDirectory; - _lazyPackedReferences = new Lazy>(() => ReadPackedReferences(_gitDirectory)); + _storageFormat = storageFormat; + _objectNameFormat = objectNameFormat; + _lazyPackedReferences = new(() => ReadPackedReferences(_gitDirectory)); + _lazyRefTableReferenceReaders = new(() => CreateRefTableReaders(_gitDirectory, _openedRefTableReaders)); } - private static ImmutableDictionary ReadPackedReferences(string gitDirectory) + public void Dispose() + { + lock (_openedRefTableReaders) + { + foreach (var reader in _openedRefTableReaders) + { + reader.Dispose(); + } + + _openedRefTableReaders.Clear(); + } + } + + private ImmutableDictionary ReadPackedReferences(string gitDirectory) { // https://git-scm.com/docs/git-pack-refs @@ -62,7 +86,7 @@ private static ImmutableDictionary ReadPackedReferences(string g } // internal for testing - internal static ImmutableDictionary ReadPackedReferences(TextReader reader, string path) + internal ImmutableDictionary ReadPackedReferences(TextReader reader, string path) { var builder = ImmutableDictionary.CreateBuilder(); @@ -150,20 +174,67 @@ internal static ImmutableDictionary ReadPackedReferences(TextRea /// /// - public string? ResolveHeadReference() - => ResolveReference(ReadReferenceFromFile(Path.Combine(_gitDirectory, GitRepository.GitHeadFileName))); + private static IEnumerable CreateRefTableReaders(string gitDirectory, List openReaders) + { + var refTableDirectory = Path.Combine(gitDirectory, RefTableDirectoryName); + var tablesFilePath = Path.Combine(refTableDirectory, TablesListFileName); + + // Create lazily-evaluated sequence of readers for each entry in the tables.list file (in reverse order). + // Only evaluate the first one that exists. + // Reference resolution will open the subsequent files as needed. + + var readers = File.ReadAllLines(tablesFilePath) + .Where(fileName => fileName.EndsWith(".ref")) + .Reverse() + .Select(fileName => + { + var path = Path.Combine(refTableDirectory, fileName); + + Stream stream; + try + { + stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete); + } + catch (FileNotFoundException) + { + return null; + } + + var reader = new GitRefTableReader(stream); + lock (openReaders) + { + openReaders.Add(reader); + } + + return reader; + }) + .Where(s => s != null); + + if (!readers.Any()) + { + throw new InvalidDataException(); + } + + return readers!; + } public string? GetBranchForHead() - { - var reference = ReadReferenceFromFile(Path.Combine(_gitDirectory, GitRepository.GitHeadFileName)); + => FindHeadReference().referenceName; - return TryGetReferenceName(reference, out var name) ? name : null; + /// + /// + public string? ResolveHeadReference() + { + var (objectName, referenceName) = FindHeadReference(); + return objectName ?? ResolveReferenceName(referenceName!); } + /// + /// public string? ResolveReference(string reference) { - HashSet? lazyVisitedReferences = null; - return ResolveReference(reference, ref lazyVisitedReferences); + var (objectName, referenceName) = ParseObjectNameOrReference(reference); + return objectName ?? ResolveReferenceName(referenceName!); } private static bool TryGetReferenceName(string reference, [NotNullWhen(true)] out string? name) @@ -179,24 +250,101 @@ private static bool TryGetReferenceName(string reference, [NotNullWhen(true)] ou return false; } + private (string? objectName, string? referenceName) ParseObjectNameOrReference(string value) + { + if (TryGetReferenceName(value, out var referenceName)) + { + return (objectName: null, referenceName); + } + + if (IsObjectId(value)) + { + return (value, referenceName: null); + } + + throw new InvalidDataException(string.Format(Resources.InvalidReference, value)); + } + /// /// - private string? ResolveReference(string reference, ref HashSet? lazyVisitedReferences) + private string? ResolveReferenceName(string referenceName) { - // See https://git-scm.com/docs/gitrepository-layout#Documentation/gitrepository-layout.txt-HEAD + HashSet? lazyVisitedReferences = null; + return Recurse(referenceName); - if (TryGetReferenceName(reference, out var symRef)) + string? Recurse(string currentReferenceName) { - if (lazyVisitedReferences != null && !lazyVisitedReferences.Add(symRef)) + // See https://git-scm.com/docs/gitrepository-layout#Documentation/gitrepository-layout.txt-HEAD + + if (lazyVisitedReferences != null && !lazyVisitedReferences.Add(currentReferenceName)) { // infinite recursion - throw new InvalidDataException(string.Format(Resources.RecursionDetectedWhileResolvingReference, reference)); + throw new InvalidDataException(string.Format(Resources.RecursionDetectedWhileResolvingReference, referenceName)); + } + + var (objectName, foundReferenceName) = FindReference(currentReferenceName); + if (objectName != null) + { + return objectName; + } + + if (foundReferenceName == null) + { + return null; + } + + lazyVisitedReferences ??= []; + return Recurse(foundReferenceName); + } + } + + private (string? objectName, string? referenceName) FindHeadReference() + => _storageFormat switch + { + ReferenceStorageFormat.LooseFiles => ParseObjectNameOrReference(ReadReferenceFromFile(Path.Combine(_gitDirectory, GitRepository.GitHeadFileName))), + ReferenceStorageFormat.RefTable => FindReferenceInRefTable(GitRepository.GitHeadFileName), + _ => throw new InvalidOperationException(), + }; + + private (string? objectName, string? referenceName) FindReference(string referenceName) + => _storageFormat switch + { + ReferenceStorageFormat.LooseFiles => FindReferenceInLooseFile(referenceName), + ReferenceStorageFormat.RefTable => FindReferenceInRefTable(referenceName), + _ => throw new InvalidOperationException() + }; + + private (string? objectName, string? referenceName) FindReferenceInRefTable(string referenceName) + { + foreach (var reader in _lazyRefTableReferenceReaders.Value) + { + if (!reader.TryFindReference(referenceName, out var objectName, out var symbolicReference)) + { + continue; } + return (objectName, symbolicReference); + } + + return default; + } + + private (string? objectName, string? referenceName) FindReferenceInLooseFile(string referenceName) + { + var content = Find() ?? FindPackedReference(referenceName); + if (content == null) + { + return default; + } + + return ParseObjectNameOrReference(content); + + string? Find() + { string path; try { - path = Path.Combine(_commonDirectory, symRef); + path = Path.Combine(_commonDirectory, referenceName); } catch { @@ -205,36 +353,19 @@ private static bool TryGetReferenceName(string reference, [NotNullWhen(true)] ou if (!File.Exists(path)) { - return ResolvePackedReference(symRef); + return null; } - string content; try { - content = ReadReferenceFromFile(path); + return ReadReferenceFromFile(path); } catch (Exception e) when (e is ArgumentException or FileNotFoundException or DirectoryNotFoundException) { // invalid path or file doesn't exist: - return ResolvePackedReference(symRef); - } - - if (IsObjectId(content)) - { - return content; + return null; } - - lazyVisitedReferences ??= new HashSet(); - - return ResolveReference(content, ref lazyVisitedReferences); } - - if (IsObjectId(reference)) - { - return reference; - } - - throw new InvalidDataException(string.Format(Resources.InvalidReference, reference)); } /// @@ -251,10 +382,10 @@ internal static string ReadReferenceFromFile(string path) } } - private string? ResolvePackedReference(string reference) + private string? FindPackedReference(string reference) => _lazyPackedReferences.Value.TryGetValue(reference, out var objectId) ? objectId : null; - private static bool IsObjectId(string reference) - => reference.Length == 40 && reference.All(CharUtils.IsHexadecimalDigit); + private bool IsObjectId(string reference) + => reference.Length == _objectNameFormat.HashSize * 2 && reference.All(CharUtils.IsHexadecimalDigit); } } diff --git a/src/Microsoft.Build.Tasks.Git/GitDataReader/GitRepository.cs b/src/Microsoft.Build.Tasks.Git/GitDataReader/GitRepository.cs index a0979fc2..1308a16e 100644 --- a/src/Microsoft.Build.Tasks.Git/GitDataReader/GitRepository.cs +++ b/src/Microsoft.Build.Tasks.Git/GitDataReader/GitRepository.cs @@ -11,10 +11,8 @@ namespace Microsoft.Build.Tasks.Git { - internal sealed class GitRepository + internal sealed class GitRepository : IDisposable { - private const int SupportedGitRepoFormatVersion = 1; - private const string CommonDirFileName = "commondir"; private const string GitDirName = ".git"; private const string GitDirPrefix = "gitdir: "; @@ -24,9 +22,6 @@ internal sealed class GitRepository private const string GitModulesFileName = ".gitmodules"; - private static readonly ImmutableArray s_knownExtensions = - ImmutableArray.Create("noop", "preciousObjects", "partialclone", "worktreeConfig"); - public GitConfig Config { get; } public GitIgnore Ignore => _lazyIgnore.Value; @@ -56,8 +51,6 @@ internal sealed class GitRepository internal GitRepository(GitEnvironment environment, GitConfig config, string gitDirectory, string commonDirectory, string? workingDirectory) { - NullableDebug.Assert(environment != null); - NullableDebug.Assert(config != null); NullableDebug.Assert(PathUtils.IsNormalized(gitDirectory)); NullableDebug.Assert(PathUtils.IsNormalized(commonDirectory)); NullableDebug.Assert(workingDirectory == null || PathUtils.IsNormalized(workingDirectory)); @@ -68,11 +61,11 @@ internal GitRepository(GitEnvironment environment, GitConfig config, string gitD WorkingDirectory = workingDirectory; Environment = environment; - _referenceResolver = new GitReferenceResolver(gitDirectory, commonDirectory); - _lazySubmodules = new Lazy<(ImmutableArray, ImmutableArray)>(ReadSubmodules); - _lazyIgnore = new Lazy(LoadIgnore); - _lazyHeadCommitSha = new Lazy(ReadHeadCommitSha); - _lazyBranchName = new Lazy(ReadBranchName); + _referenceResolver = new GitReferenceResolver(gitDirectory, commonDirectory, config.ReferenceStorageFormat, config.ObjectNameFormat); + _lazySubmodules = new(ReadSubmodules); + _lazyIgnore = new(LoadIgnore); + _lazyHeadCommitSha = new(ReadHeadCommitSha); + _lazyBranchName = new(ReadBranchName); } // test only @@ -89,11 +82,16 @@ internal GitRepository( string? branchName) : this(environment, config, gitDirectory, commonDirectory, workingDirectory) { - _lazySubmodules = new Lazy<(ImmutableArray, ImmutableArray)>(() => (submodules, submoduleDiagnostics)); - _lazyIgnore = new Lazy(() => ignore); - _lazyHeadCommitSha = new Lazy(() => headCommitSha); - _lazyBranchName = new Lazy(() => branchName); - } + _lazySubmodules = new(() => (submodules, submoduleDiagnostics)); + _lazyIgnore = new(() => ignore); + _lazyHeadCommitSha = new(() => headCommitSha); + _lazyBranchName = new(() => branchName); + } + + public void Dispose() + { + _referenceResolver.Dispose(); + } /// /// Opens a repository at the specified location. @@ -119,31 +117,9 @@ public static GitRepository OpenRepository(GitRepositoryLocation location, GitEn // See https://git-scm.com/docs/gitrepository-layout - var reader = new GitConfig.Reader(location.GitDirectory, location.CommonDirectory, environment); - var config = reader.Load(); - + var config = GitConfig.ReadRepositoryConfig(location.GitDirectory, location.CommonDirectory, environment); var workingDirectory = GetWorkingDirectory(config, location); - // See https://github.com/git/git/blob/master/Documentation/technical/repository-version.txt - var versionStr = config.GetVariableValue("core", "repositoryformatversion"); - if (GitConfig.TryParseInt64Value(versionStr, out var version) && version > SupportedGitRepoFormatVersion) - { - throw new NotSupportedException(string.Format(Resources.UnsupportedRepositoryVersion, versionStr, SupportedGitRepoFormatVersion)); - } - - if (version == 1) - { - // All variables defined under extensions section must be known, otherwise a git implementation is not allowed to proced. - foreach (var variable in config.Variables) - { - if (variable.Key.SectionNameEquals("extensions") && !s_knownExtensions.Contains(variable.Key.VariableName, StringComparer.OrdinalIgnoreCase)) - { - throw new NotSupportedException(string.Format( - Resources.UnsupportedRepositoryExtension, variable.Key.VariableName, string.Join(", ", s_knownExtensions))); - } - } - } - return new GitRepository(environment, config, location.GitDirectory, location.CommonDirectory, workingDirectory); } @@ -197,7 +173,7 @@ public static GitRepository OpenRepository(GitRepositoryLocation location, GitEn /// /// /// Null if the submodule can't be located. - public static GitReferenceResolver? GetSubmoduleReferenceResolver(string submoduleWorkingDirectoryFullPath) + public static GitReferenceResolver? GetSubmoduleReferenceResolver(string submoduleWorkingDirectoryFullPath, GitEnvironment environment) { // Submodules don't usually have their own .git directories but this is still legal. // This can occur with older versions of Git or other tools, or when a user clones one @@ -214,7 +190,8 @@ public static GitRepository OpenRepository(GitRepositoryLocation location, GitEn return null; } - return new GitReferenceResolver(gitDirectory, commonDirectory); + var config = GitConfig.ReadRepositoryConfig(gitDirectory, commonDirectory, environment); + return new GitReferenceResolver(gitDirectory, commonDirectory, config.ReferenceStorageFormat, config.ObjectNameFormat); } private string GetWorkingDirectory() @@ -275,7 +252,7 @@ void reportDiagnostic(string diagnostic) string? headCommitSha; try { - var resolver = GetSubmoduleReferenceResolver(fullPath); + using var resolver = GetSubmoduleReferenceResolver(fullPath, Environment); if (resolver == null) { // If we can't locate the submodule directory then it won't have any source files @@ -307,8 +284,7 @@ void reportDiagnostic(string diagnostic) return null; } - var reader = new GitConfig.Reader(GitDirectory, CommonDirectory, Environment); - return reader.LoadFrom(submodulesConfigFile); + return GitConfig.ReadSubmoduleConfig(GitDirectory, CommonDirectory, Environment, submodulesConfigFile); } // internal for testing @@ -478,7 +454,7 @@ private static string ReadDotGitFile(string path) } } - private static bool IsGitDirectory(string directory, [NotNullWhen(true)]out string? commonDirectory) + private static bool IsGitDirectory(string directory, [NotNullWhen(true)] out string? commonDirectory) { // HEAD file is required if (!File.Exists(Path.Combine(directory, GitHeadFileName))) diff --git a/src/Microsoft.Build.Tasks.Git/GitDataReader/GitVariableName.cs b/src/Microsoft.Build.Tasks.Git/GitDataReader/GitVariableName.cs index ddddbc35..ca039083 100644 --- a/src/Microsoft.Build.Tasks.Git/GitDataReader/GitVariableName.cs +++ b/src/Microsoft.Build.Tasks.Git/GitDataReader/GitVariableName.cs @@ -3,54 +3,43 @@ // See the License.txt file in the project root for more information. using System; -using System.Diagnostics; -namespace Microsoft.Build.Tasks.Git +namespace Microsoft.Build.Tasks.Git; + +internal readonly struct GitVariableName(string sectionName, string subsectionName, string variableName) : IEquatable { - internal readonly struct GitVariableName : IEquatable - { - public static readonly StringComparer SectionNameComparer = StringComparer.OrdinalIgnoreCase; - public static readonly StringComparer SubsectionNameComparer = StringComparer.Ordinal; - public static readonly StringComparer VariableNameComparer = StringComparer.OrdinalIgnoreCase; - - public readonly string SectionName; - public readonly string SubsectionName; - public readonly string VariableName; - - public GitVariableName(string sectionName, string subsectionName, string variableName) - { - NullableDebug.Assert(sectionName != null); - NullableDebug.Assert(subsectionName != null); - NullableDebug.Assert(variableName != null); - - SectionName = sectionName; - SubsectionName = subsectionName; - VariableName = variableName; - } - - public bool SectionNameEquals(string name) - => SectionNameComparer.Equals(SectionName, name); - - public bool SubsectionNameEquals(string name) - => SubsectionNameComparer.Equals(SubsectionName, name); - - public bool VariableNameEquals(string name) - => VariableNameComparer.Equals(VariableName, name); - - public bool Equals(GitVariableName other) - => SectionNameEquals(other.SectionName) && - SubsectionNameEquals(other.SubsectionName) && - VariableNameEquals(other.VariableName); - - public override bool Equals(object? obj) - => obj is GitVariableName other && Equals(other); - - public override int GetHashCode() - => SectionName.GetHashCode() ^ SubsectionName.GetHashCode() ^ VariableName.GetHashCode(); - - public override string ToString() - => (SubsectionName.Length == 0) ? - SectionName + "." + VariableName : - SectionName + "." + SubsectionName + "." + VariableName; - } + public static readonly StringComparer SectionNameComparer = StringComparer.OrdinalIgnoreCase; + public static readonly StringComparer SubsectionNameComparer = StringComparer.Ordinal; + public static readonly StringComparer VariableNameComparer = StringComparer.OrdinalIgnoreCase; + + public readonly string SectionName = sectionName; + public readonly string SubsectionName = subsectionName; + public readonly string VariableName = variableName; + + public bool SectionNameEquals(string name) + => SectionNameComparer.Equals(SectionName, name); + + public bool SubsectionNameEquals(string name) + => SubsectionNameComparer.Equals(SubsectionName, name); + + public bool VariableNameEquals(string name) + => VariableNameComparer.Equals(VariableName, name); + + public bool Equals(GitVariableName other) + => SectionNameEquals(other.SectionName) && + SubsectionNameEquals(other.SubsectionName) && + VariableNameEquals(other.VariableName); + + public override bool Equals(object? obj) + => obj is GitVariableName other && Equals(other); + + public override int GetHashCode() + => SectionNameComparer.GetHashCode(SectionName) ^ + SubsectionNameComparer.GetHashCode(SubsectionName) ^ + VariableNameComparer.GetHashCode(VariableName); + + public override string ToString() + => (SubsectionName.Length == 0) ? + SectionName + "." + VariableName : + SectionName + "." + SubsectionName + "." + VariableName; } diff --git a/src/Microsoft.Build.Tasks.Git/GitDataReader/ObjectNameFormat.cs b/src/Microsoft.Build.Tasks.Git/GitDataReader/ObjectNameFormat.cs new file mode 100644 index 00000000..2e338216 --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git/GitDataReader/ObjectNameFormat.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.Build.Tasks.Git; + +/// +/// Format of object names. +/// https://git-scm.com/docs/hash-function-transition.html#_object_format +/// +internal enum ObjectNameFormat +{ + Sha1, + Sha256 +} + +internal static class ObjectNameFormatExtensions +{ + extension(ObjectNameFormat format) + { + public int HashSize + => format switch + { + ObjectNameFormat.Sha1 => 20, + ObjectNameFormat.Sha256 => 32, + _ => throw new InvalidOperationException() + }; + } +} + + diff --git a/src/Microsoft.Build.Tasks.Git/GitDataReader/ReferenceStorageFormat.cs b/src/Microsoft.Build.Tasks.Git/GitDataReader/ReferenceStorageFormat.cs new file mode 100644 index 00000000..74ab09be --- /dev/null +++ b/src/Microsoft.Build.Tasks.Git/GitDataReader/ReferenceStorageFormat.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.Build.Tasks.Git; + +/// +/// The format of storage for references. +/// +internal enum ReferenceStorageFormat +{ + /// + /// References stored as files in refs directory or packed-refs. + /// + LooseFiles = 0, + + /// + /// References stored in RefTable (). + /// + RefTable = 1, +} diff --git a/src/Microsoft.Build.Tasks.Git/GitOperations.cs b/src/Microsoft.Build.Tasks.Git/GitOperations.cs index 78fcb2e6..fc411c6b 100644 --- a/src/Microsoft.Build.Tasks.Git/GitOperations.cs +++ b/src/Microsoft.Build.Tasks.Git/GitOperations.cs @@ -339,8 +339,9 @@ public static ITaskItem[] GetUntrackedFiles(GitRepository repository, ITaskItem[ => GetUntrackedFiles(repository, files, projectDirectory, CreateSubmoduleRepository); private static GitRepository? CreateSubmoduleRepository(GitEnvironment environment, string directoryFullPath) - => GitRepository.TryGetRepositoryLocation(directoryFullPath, out var location) ? - GitRepository.OpenRepository(location, environment) : null; + => GitRepository.TryGetRepositoryLocation(directoryFullPath, out var location) + ? GitRepository.OpenRepository(location, environment) + : null; // internal for testing internal static ITaskItem[] GetUntrackedFiles(GitRepository repository, ITaskItem[] files, string projectDirectory, Func repositoryFactory) @@ -388,8 +389,14 @@ internal static DirectoryNode BuildDirectoryTree(GitRepository repository, Func< { var submoduleWorkingDirectory = submodule.WorkingDirectoryFullPath; - AddTreeNode(treeRoot, submoduleWorkingDirectory, - new Lazy(() => repositoryFactory(repository.Environment, submoduleWorkingDirectory)?.Ignore.CreateMatcher())); + AddTreeNode( + treeRoot, + submoduleWorkingDirectory, + matcher: new Lazy(() => + { + using var submoduleRepository = repositoryFactory(repository.Environment, submoduleWorkingDirectory); + return submoduleRepository?.Ignore.CreateMatcher(); + })); } return treeRoot; diff --git a/src/Microsoft.Build.Tasks.Git/Microsoft.Build.Tasks.Git.csproj b/src/Microsoft.Build.Tasks.Git/Microsoft.Build.Tasks.Git.csproj index c6176c5f..3ea8e7ee 100644 --- a/src/Microsoft.Build.Tasks.Git/Microsoft.Build.Tasks.Git.csproj +++ b/src/Microsoft.Build.Tasks.Git/Microsoft.Build.Tasks.Git.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Microsoft.Build.Tasks.Git/RepositoryTask.cs b/src/Microsoft.Build.Tasks.Git/RepositoryTask.cs index eb19f467..efb060c5 100644 --- a/src/Microsoft.Build.Tasks.Git/RepositoryTask.cs +++ b/src/Microsoft.Build.Tasks.Git/RepositoryTask.cs @@ -5,7 +5,6 @@ using System; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Runtime.CompilerServices; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; @@ -13,6 +12,15 @@ namespace Microsoft.Build.Tasks.Git { public abstract class RepositoryTask : Task { + private sealed class RepositoryContainer(GitRepository? repository) : IDisposable + { + public GitRepository? Repository + => repository; + + public void Dispose() + => repository?.Dispose(); + } + /// /// Sets the scope of git repository configuration. By default (no scope specified) configuration is read from environment variables /// and system and global user git/ssh configuration files. @@ -144,11 +152,11 @@ private Tuple GetCacheKey(string repositoryId) private bool TryGetCachedRepositoryInstance(Tuple cacheKey, bool requireCached, [NotNullWhen(true)]out GitRepository? repository) { - var entry = (StrongBox?)BuildEngine4.GetRegisteredTaskObject(cacheKey, RegisteredTaskObjectLifetime.Build); + var entry = (RepositoryContainer?)BuildEngine4.GetRegisteredTaskObject(cacheKey, RegisteredTaskObjectLifetime.Build); if (entry != null) { Log.LogMessage(MessageImportance.Low, $"SourceLink: Reusing cached git repository information."); - repository = entry.Value; + repository = entry.Repository; return repository != null; } @@ -170,7 +178,7 @@ private void CacheRepositoryInstance(Tuple cacheKey, GitRepository { BuildEngine4.RegisterTaskObject( cacheKey, - new StrongBox(repository), + new RepositoryContainer(repository), RegisteredTaskObjectLifetime.Build, allowEarlyCollection: true); } diff --git a/src/SourceLink.Git.IntegrationTests/CloudHostedProvidersTests.cs b/src/SourceLink.Git.IntegrationTests/CloudHostedProvidersTests.cs index 56cac303..2fa94ba4 100644 --- a/src/SourceLink.Git.IntegrationTests/CloudHostedProvidersTests.cs +++ b/src/SourceLink.Git.IntegrationTests/CloudHostedProvidersTests.cs @@ -255,7 +255,7 @@ public void CustomTranslation() ", customTargets: @" - @@ -275,7 +275,7 @@ public void CustomTranslation() - + <_SourceLinkFileWrites Include=""@(FileWrites)"" Condition=""$([MSBuild]::ValueOrDefault('%(Identity)', '').EndsWith('sourcelink.json'))""/> diff --git a/src/SourceLink.Git.IntegrationTests/TargetTests.cs b/src/SourceLink.Git.IntegrationTests/TargetTests.cs index 4264ee3b..59280ed2 100644 --- a/src/SourceLink.Git.IntegrationTests/TargetTests.cs +++ b/src/SourceLink.Git.IntegrationTests/TargetTests.cs @@ -35,7 +35,7 @@ public void GenerateSourceLinkFileTarget_EnableSourceLinkCondition() ", customTargets: @" - + false