diff --git a/.codex/workflows/changelog-fragment.md b/.codex/workflows/changelog-fragment.md index 588985ad59..2a916ce7ed 100644 --- a/.codex/workflows/changelog-fragment.md +++ b/.codex/workflows/changelog-fragment.md @@ -42,6 +42,10 @@ with file-specific messages. For example, a non-issue fragment that writes `issues: null` fails with an invalid issue-number error; omit the `issues` field entirely instead. +The validator and release preparation tool reject oversized inputs before +parsing: at most 512 unreleased fragments, each fragment at most 131072 bytes, +`CHANGELOG.md` at most 8388608 bytes, and `version.json` at most 16384 bytes. + ## Template ```md diff --git a/changelog.d/unreleased/2909.fixed.md b/changelog.d/unreleased/2909.fixed.md new file mode 100644 index 0000000000..2ec68c063b --- /dev/null +++ b/changelog.d/unreleased/2909.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 2909 +affected: + - tools/CodeIndex.Changelog/Program.cs + - tests/CodeIndex.Tests/ChangelogToolTests.cs + - .codex/workflows/changelog-fragment.md +--- + +## English + +- **Changelog release tooling now bounds the inputs each command reads (#2909)** — `check` rejects excessive unreleased fragment counts and oversized fragment files, `prepare` also rejects oversized `CHANGELOG.md` and `version.json`, and `release-notes` rejects oversized `CHANGELOG.md` before parsing. + +## 日本語 + +- **changelog release tool が各 command の読み取る入力に上限を適用するようになりました (#2909)** — `check` は未リリース fragment 数と fragment file size、`prepare` はそれに加えて `CHANGELOG.md` と `version.json`、`release-notes` は `CHANGELOG.md` が過大な場合に parse 前に拒否します。 diff --git a/changelog.d/unreleased/2910.fixed.md b/changelog.d/unreleased/2910.fixed.md new file mode 100644 index 0000000000..ece18efb7a --- /dev/null +++ b/changelog.d/unreleased/2910.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 2910 +affected: + - tools/CodeIndex.Changelog/Program.cs + - tools/CodeIndex.Changelog/CodeIndex.Changelog.csproj + - tests/CodeIndex.Tests/ChangelogToolTests.cs +--- + +## English + +- **Changelog release preparation now stages writes before deleting fragments (#2910)** — `prepare` writes temporary changelog and version files, preserves symlinked release-file targets, deletes partial temp files when staged writes fail, rolls release files back when staging or replacement fails before fragment deletion starts, and deletes consumed fragments only after both release files have been replaced. + +## 日本語 + +- **changelog release preparation が fragment 削除前に書き込みを stage するようになりました (#2910)** — `prepare` は changelog と version の一時ファイルを書き、symlink された release file の target を保持し、stage 書き込み失敗時は部分的な temp file を削除し、fragment 削除が始まる前の stage / replacement 失敗時は release file を元へ戻し、両方の release file を置換してから consumed fragment を削除します。 diff --git a/tests/CodeIndex.Tests/ChangelogToolTests.cs b/tests/CodeIndex.Tests/ChangelogToolTests.cs index 9ed3cffeb4..afcd0da276 100644 --- a/tests/CodeIndex.Tests/ChangelogToolTests.cs +++ b/tests/CodeIndex.Tests/ChangelogToolTests.cs @@ -77,6 +77,43 @@ public void PrepareMovesFragmentsIntoReleaseAndUpdatesFooter() Assert.False(scope.Exists("changelog.d/unreleased/195.fixed.md")); } + [Fact] + public void PrepareWritesThroughSymlinkedReleaseFiles() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("actual-changelog.md", SampleChangelog); + scope.WriteFile("actual-version.json", """ + { + "version": "1.16.0" + } + """); + scope.WriteFile("changelog.d/unreleased/195.fixed.md", SampleFragment); + + var changelogLinkPath = Path.Combine(scope.Root, "CHANGELOG.md"); + var versionLinkPath = Path.Combine(scope.Root, "version.json"); + try + { + File.CreateSymbolicLink(changelogLinkPath, "actual-changelog.md"); + File.CreateSymbolicLink(versionLinkPath, "actual-version.json"); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var tool = new ChangelogTool(scope.Root); + tool.Prepare(new Version(1, 17, 0), new DateOnly(2026, 5, 1), writeChanges: true); + + Assert.NotNull(new FileInfo(changelogLinkPath).LinkTarget); + Assert.NotNull(new FileInfo(versionLinkPath).LinkTarget); + Assert.Contains("English release note", scope.ReadFile("actual-changelog.md")); + Assert.Contains("Japanese release note", scope.ReadFile("actual-changelog.md")); + Assert.Equal(scope.ReadFile("actual-changelog.md"), scope.ReadFile("CHANGELOG.md")); + Assert.Contains("\"version\": \"1.17.0\"", scope.ReadFile("actual-version.json")); + Assert.Equal(scope.ReadFile("actual-version.json"), scope.ReadFile("version.json")); + Assert.False(scope.Exists("changelog.d/unreleased/195.fixed.md")); + } + [Fact] public void PrepareRerunPreservesExistingReleaseAndAppendsNewFragments() { @@ -127,6 +164,126 @@ public void PrepareRerunPreservesExistingReleaseAndAppendsNewFragments() Assert.Equal(0, scope.ListFiles("changelog.d/unreleased").Count(path => Path.GetFileName(path) is "195.fixed.md" or "+release-process.docs.md")); } + [Fact] + public void PrepareFailureAfterStagingLeavesReleaseFilesAndFragmentsUntouched() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("CHANGELOG.md", SampleChangelog); + scope.WriteFile("version.json", """ + { + "version": "1.16.0" + } + """); + scope.WriteFile("changelog.d/unreleased/195.fixed.md", SampleFragment); + + var tool = new ChangelogTool(scope.Root); + ChangelogException? ex = null; + ChangelogTool.PrepareWritePhaseForTesting = phase => + { + if (phase == PrepareWritePhase.StagedFilesWritten) + throw new ChangelogException("injected staging failure"); + }; + try + { + ex = Assert.Throws(() => tool.Prepare(new Version(1, 17, 0), new DateOnly(2026, 5, 1), writeChanges: true)); + } + finally + { + ChangelogTool.PrepareWritePhaseForTesting = null; + } + + Assert.NotNull(ex); + Assert.Contains("injected staging failure", ex.Message); + Assert.DoesNotContain("English release note", scope.ReadFile("CHANGELOG.md")); + Assert.Equal(""" + { + "version": "1.16.0" + } + """.Replace("\r\n", "\n"), scope.ReadFile("version.json").Replace("\r\n", "\n")); + Assert.True(scope.Exists("changelog.d/unreleased/195.fixed.md")); + Assert.DoesNotContain(scope.ListFiles("."), path => Path.GetFileName(path).EndsWith(".tmp", StringComparison.Ordinal)); + } + + [Fact] + public void PrepareFailureDuringStagedWriteDeletesPartialTempFile() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("CHANGELOG.md", SampleChangelog); + scope.WriteFile("version.json", """ + { + "version": "1.16.0" + } + """); + scope.WriteFile("changelog.d/unreleased/195.fixed.md", SampleFragment); + + var tool = new ChangelogTool(scope.Root); + ChangelogException? ex = null; + ChangelogTool.PrepareWritePhaseForTesting = phase => + { + if (phase == PrepareWritePhase.StagedTempCreated) + throw new ChangelogException("injected staged write failure"); + }; + try + { + ex = Assert.Throws(() => tool.Prepare(new Version(1, 17, 0), new DateOnly(2026, 5, 1), writeChanges: true)); + } + finally + { + ChangelogTool.PrepareWritePhaseForTesting = null; + } + + Assert.NotNull(ex); + Assert.Contains("injected staged write failure", ex.Message); + Assert.DoesNotContain("English release note", scope.ReadFile("CHANGELOG.md")); + Assert.Equal(""" + { + "version": "1.16.0" + } + """.Replace("\r\n", "\n"), scope.ReadFile("version.json").Replace("\r\n", "\n")); + Assert.True(scope.Exists("changelog.d/unreleased/195.fixed.md")); + Assert.DoesNotContain(scope.ListFiles("."), path => Path.GetFileName(path).EndsWith(".tmp", StringComparison.Ordinal)); + } + + [Fact] + public void PrepareFailureBeforeFragmentDeletionRollsBackReleaseFiles() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("CHANGELOG.md", SampleChangelog); + scope.WriteFile("version.json", """ + { + "version": "1.16.0" + } + """); + scope.WriteFile("changelog.d/unreleased/195.fixed.md", SampleFragment); + + var tool = new ChangelogTool(scope.Root); + ChangelogException? ex = null; + ChangelogTool.PrepareWritePhaseForTesting = phase => + { + if (phase == PrepareWritePhase.BeforeFragmentsDeleted) + throw new ChangelogException("injected fragment deletion failure"); + }; + try + { + ex = Assert.Throws(() => tool.Prepare(new Version(1, 17, 0), new DateOnly(2026, 5, 1), writeChanges: true)); + } + finally + { + ChangelogTool.PrepareWritePhaseForTesting = null; + } + + Assert.NotNull(ex); + Assert.Contains("injected fragment deletion failure", ex.Message); + Assert.DoesNotContain("English release note", scope.ReadFile("CHANGELOG.md")); + Assert.Equal(""" + { + "version": "1.16.0" + } + """.Replace("\r\n", "\n"), scope.ReadFile("version.json").Replace("\r\n", "\n")); + Assert.True(scope.Exists("changelog.d/unreleased/195.fixed.md")); + Assert.DoesNotContain(scope.ListFiles("."), path => Path.GetFileName(path).EndsWith(".tmp", StringComparison.Ordinal)); + } + [Fact] public void RenderReleaseNotesExtractsMatchingEnglishAndJapaneseSections() { @@ -240,6 +397,114 @@ public void CheckFragmentsRejectsMissingJapaneseSection() Assert.Contains("missing '## 日本語' heading", ex.Message); } + [Fact] + public void CheckFragmentsRejectsTooManyFragmentsBeforeParsing() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("CHANGELOG.md", SampleChangelog); + scope.WriteFile("version.json", """ + { + "version": "1.16.0" + } + """); + + for (var i = 0; i <= ChangelogTool.MaxFragmentCount; i++) + scope.WriteFile($"changelog.d/unreleased/{1000 + i}.fixed.md", string.Empty); + + var tool = new ChangelogTool(scope.Root); + var ex = Assert.Throws(() => tool.CheckFragments()); + Assert.Contains("too many changelog fragments", ex.Message); + Assert.Contains($"maximum supported count is {ChangelogTool.MaxFragmentCount}", ex.Message); + } + + [Fact] + public void CheckFragmentsRejectsOversizedFragmentBeforeParsing() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("CHANGELOG.md", SampleChangelog); + scope.WriteFile("version.json", """ + { + "version": "1.16.0" + } + """); + scope.WriteFile("changelog.d/unreleased/+large.fixed.md", OversizedContent(ChangelogTool.MaxFragmentBytes)); + + var tool = new ChangelogTool(scope.Root); + var ex = Assert.Throws(() => tool.CheckFragments()); + Assert.Contains("changelog.d/unreleased/+large.fixed.md: file is", ex.Message); + Assert.Contains($"maximum supported size is {ChangelogTool.MaxFragmentBytes} bytes", ex.Message); + } + + [Fact] + public void CheckFragmentsRejectsOversizedSymlinkTargetBeforeParsing() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("CHANGELOG.md", SampleChangelog); + scope.WriteFile("version.json", """ + { + "version": "1.16.0" + } + """); + scope.WriteFile("large-fragment-target.md", OversizedContent(ChangelogTool.MaxFragmentBytes)); + + var linkPath = Path.Combine(scope.Root, "changelog.d", "unreleased", "+large-link.fixed.md"); + Directory.CreateDirectory(Path.GetDirectoryName(linkPath)!); + try + { + File.CreateSymbolicLink(linkPath, Path.Combine(scope.Root, "large-fragment-target.md")); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var tool = new ChangelogTool(scope.Root); + var thrown = Assert.Throws(() => tool.CheckFragments()); + Assert.Contains("changelog.d/unreleased/+large-link.fixed.md: file is larger than", thrown.Message); + Assert.Contains($"maximum supported size is {ChangelogTool.MaxFragmentBytes} bytes", thrown.Message); + } + + [Fact] + public void PrepareRejectsOversizedChangelogBeforeParsing() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("CHANGELOG.md", OversizedContent(ChangelogTool.MaxChangelogBytes)); + scope.WriteFile("version.json", """ + { + "version": "1.16.0" + } + """); + + var tool = new ChangelogTool(scope.Root); + var ex = Assert.Throws(() => tool.Prepare(new Version(1, 17, 0), new DateOnly(2026, 5, 1), writeChanges: true)); + Assert.Contains("CHANGELOG.md: file is", ex.Message); + Assert.Contains($"maximum supported size is {ChangelogTool.MaxChangelogBytes} bytes", ex.Message); + } + + [Fact] + public void ConfiguredChangelogLimitFitsRepositoryChangelog() + { + var repositoryRoot = FindRepositoryRootForTest(); + var changelogLength = new FileInfo(Path.Combine(repositoryRoot, "CHANGELOG.md")).Length; + + Assert.True( + changelogLength <= ChangelogTool.MaxChangelogBytes, + $"CHANGELOG.md is {changelogLength} bytes, but MaxChangelogBytes is {ChangelogTool.MaxChangelogBytes}."); + } + + [Fact] + public void PrepareRejectsOversizedVersionBeforeParsing() + { + using var scope = new TestRepositoryScope(); + scope.WriteFile("CHANGELOG.md", SampleChangelog); + scope.WriteFile("version.json", OversizedContent(ChangelogTool.MaxVersionJsonBytes)); + + var tool = new ChangelogTool(scope.Root); + var ex = Assert.Throws(() => tool.Prepare(new Version(1, 17, 0), new DateOnly(2026, 5, 1), writeChanges: true)); + Assert.Contains("version.json: file is", ex.Message); + Assert.Contains($"maximum supported size is {ChangelogTool.MaxVersionJsonBytes} bytes", ex.Message); + } + private static int CountOccurrences(string text, string value) { var count = 0; @@ -253,6 +518,25 @@ private static int CountOccurrences(string text, string value) return count; } + private static string OversizedContent(long maxBytes) => new('x', checked((int)maxBytes + 1)); + + private static string FindRepositoryRootForTest() + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + while (current is not null) + { + if (File.Exists(Path.Combine(current.FullName, "CHANGELOG.md")) && + File.Exists(Path.Combine(current.FullName, "CodeIndex.sln"))) + { + return current.FullName; + } + + current = current.Parent; + } + + throw new InvalidOperationException("Could not locate repository root."); + } + private sealed class TestRepositoryScope : IDisposable { private readonly string _previousDirectory; diff --git a/tools/CodeIndex.Changelog/CodeIndex.Changelog.csproj b/tools/CodeIndex.Changelog/CodeIndex.Changelog.csproj index 2b8d4aed18..839391dd3c 100644 --- a/tools/CodeIndex.Changelog/CodeIndex.Changelog.csproj +++ b/tools/CodeIndex.Changelog/CodeIndex.Changelog.csproj @@ -8,4 +8,8 @@ false + + + + diff --git a/tools/CodeIndex.Changelog/Program.cs b/tools/CodeIndex.Changelog/Program.cs index 87d6ab4f0a..db5d09d5b1 100644 --- a/tools/CodeIndex.Changelog/Program.cs +++ b/tools/CodeIndex.Changelog/Program.cs @@ -69,6 +69,12 @@ private static void PrintUsage() Console.Out.WriteLine(" dotnet run --project tools/CodeIndex.Changelog -- prepare --version X.Y.Z --date YYYY-MM-DD"); Console.Out.WriteLine(" dotnet run --project tools/CodeIndex.Changelog -- render --version X.Y.Z --date YYYY-MM-DD"); Console.Out.WriteLine(" dotnet run --project tools/CodeIndex.Changelog -- release-notes --version X.Y.Z"); + Console.Out.WriteLine(); + Console.Out.WriteLine("Limits:"); + Console.Out.WriteLine($" unreleased fragments: {ChangelogTool.MaxFragmentCount}"); + Console.Out.WriteLine($" fragment file size: {ChangelogTool.MaxFragmentBytes} bytes"); + Console.Out.WriteLine($" CHANGELOG.md size: {ChangelogTool.MaxChangelogBytes} bytes"); + Console.Out.WriteLine($" version.json size: {ChangelogTool.MaxVersionJsonBytes} bytes"); } private static ParsedOptions ParseOptions(string[] args, bool requireDate) @@ -146,6 +152,12 @@ private sealed record ParsedOptions(Version Version, DateOnly ReleaseDate); public sealed class ChangelogTool { + public const int MaxFragmentCount = 512; + public const long MaxFragmentBytes = 128 * 1024; + public const long MaxChangelogBytes = 8 * 1024 * 1024; + public const long MaxVersionJsonBytes = 16 * 1024; + internal static Action? PrepareWritePhaseForTesting { get; set; } + private static readonly string[] AllowedCategories = [ "added", @@ -208,10 +220,12 @@ public PrepareResult Prepare(Version targetVersion, DateOnly releaseDate, bool w { var fragments = LoadFragments(validate: true); var versionPath = Path.Combine(_repositoryRoot, "version.json"); - var currentVersion = ReadCurrentVersion(versionPath); + var originalVersionJson = ReadAllTextBounded(versionPath, _repositoryRoot, MaxVersionJsonBytes); + var currentVersion = ParseCurrentVersion(originalVersionJson); var changelogPath = Path.Combine(_repositoryRoot, "CHANGELOG.md"); - var changelogText = File.ReadAllText(changelogPath).Replace("\r\n", "\n", StringComparison.Ordinal); + var originalChangelogText = ReadAllTextBounded(changelogPath, _repositoryRoot, MaxChangelogBytes); + var changelogText = originalChangelogText.Replace("\r\n", "\n", StringComparison.Ordinal); var changelog = ParsedChangelog.Parse(changelogText); var targetHeading = $"### [{targetVersion}] - {releaseDate:yyyy-MM-dd}"; @@ -239,13 +253,17 @@ public PrepareResult Prepare(Version targetVersion, DateOnly releaseDate, bool w var updatedChangelog = changelog.Render(english, japanese, footerEntries); var consumedFragmentFiles = fragments.Select(fragment => fragment.RelativePath).ToList(); + var updatedVersionJson = JsonSerializer.Serialize(new { version = targetVersion.ToString() }, new JsonSerializerOptions { WriteIndented = true }) + Environment.NewLine; if (writeChanges) { - File.WriteAllText(changelogPath, updatedChangelog); - File.WriteAllText(versionPath, JsonSerializer.Serialize(new { version = targetVersion.ToString() }, new JsonSerializerOptions { WriteIndented = true }) + Environment.NewLine); - - foreach (var fragment in fragments) - File.Delete(fragment.AbsolutePath); + WritePreparedFiles( + changelogPath, + originalChangelogText, + updatedChangelog, + versionPath, + originalVersionJson, + updatedVersionJson, + fragments); } var changedFiles = new List @@ -268,7 +286,7 @@ public PrepareResult Prepare(Version targetVersion, DateOnly releaseDate, bool w public string RenderReleaseNotes(Version targetVersion) { var changelogPath = Path.Combine(_repositoryRoot, "CHANGELOG.md"); - var changelogText = File.ReadAllText(changelogPath).Replace("\r\n", "\n", StringComparison.Ordinal); + var changelogText = ReadAllTextBounded(changelogPath, _repositoryRoot, MaxChangelogBytes).Replace("\r\n", "\n", StringComparison.Ordinal); var changelog = ParsedChangelog.Parse(changelogText); var versionPrefix = $"### [{targetVersion}]"; @@ -304,13 +322,23 @@ private List LoadFragments(bool validate) var fragments = new List(); var errors = new List(); + var fragmentPaths = new List(); - foreach (var path in Directory.EnumerateFiles(fragmentDirectory, "*.md", SearchOption.TopDirectoryOnly).OrderBy(path => path, StringComparer.Ordinal)) + foreach (var path in Directory.EnumerateFiles(fragmentDirectory, "*.md", SearchOption.TopDirectoryOnly)) { var fileName = Path.GetFileName(path); if (fileName == ".gitkeep") continue; + fragmentPaths.Add(path); + if (fragmentPaths.Count > MaxFragmentCount) + throw new ChangelogException($"changelog.d/unreleased: too many changelog fragments ({fragmentPaths.Count}); maximum supported count is {MaxFragmentCount}."); + } + + fragmentPaths.Sort(StringComparer.Ordinal); + + foreach (var path in fragmentPaths) + { try { fragments.Add(ParseFragment(path, _repositoryRoot)); @@ -344,7 +372,7 @@ private static Fragment ParseFragment(string absolutePath, string repositoryRoot var frontMatterIssues = new List(); var frontMatterAffected = new List(); - var text = File.ReadAllText(absolutePath).Replace("\r\n", "\n", StringComparison.Ordinal); + var text = ReadAllTextBounded(absolutePath, repositoryRoot, MaxFragmentBytes).Replace("\r\n", "\n", StringComparison.Ordinal); var lines = text.Split('\n'); if (lines.Length == 0) throw new ChangelogException($"{relativePath}: fragment is empty."); @@ -516,9 +544,8 @@ private static string[] TrimTrailingAndLeadingBlankLines(string[] lines) return start <= end ? lines[start..(end + 1)] : []; } - private static Version ReadCurrentVersion(string versionPath) + private static Version ParseCurrentVersion(string text) { - var text = File.ReadAllText(versionPath); using var document = JsonDocument.Parse(text); if (!document.RootElement.TryGetProperty("version", out var versionElement)) throw new ChangelogException("version.json is missing the version property."); @@ -526,6 +553,240 @@ private static Version ReadCurrentVersion(string versionPath) return Version.Parse(versionElement.GetString() ?? throw new ChangelogException("version.json contains an empty version.")); } + private static string ReadAllTextBounded(string absolutePath, string repositoryRoot, long maxBytes) + { + var fileInfo = new FileInfo(absolutePath); + var length = fileInfo.Length; + if (fileInfo.LinkTarget is null && length > maxBytes) + { + var relativePath = string.IsNullOrWhiteSpace(repositoryRoot) + ? Path.GetFileName(absolutePath) + : Path.GetRelativePath(repositoryRoot, absolutePath).Replace('\\', '/'); + throw new ChangelogException($"{relativePath}: file is {length} bytes; maximum supported size is {maxBytes} bytes."); + } + + using var stream = File.Open(absolutePath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var memory = new MemoryStream(); + var buffer = new byte[8192]; + var totalBytesRead = 0L; + + while (true) + { + var remainingBytes = maxBytes + 1 - totalBytesRead; + var readLength = (int)Math.Min(buffer.Length, remainingBytes); + var bytesRead = stream.Read(buffer.AsSpan(0, readLength)); + if (bytesRead == 0) + break; + + totalBytesRead += bytesRead; + if (totalBytesRead > maxBytes) + { + var relativePath = string.IsNullOrWhiteSpace(repositoryRoot) + ? Path.GetFileName(absolutePath) + : Path.GetRelativePath(repositoryRoot, absolutePath).Replace('\\', '/'); + throw new ChangelogException($"{relativePath}: file is larger than {maxBytes} bytes; maximum supported size is {maxBytes} bytes."); + } + + memory.Write(buffer.AsSpan(0, bytesRead)); + } + + memory.Position = 0; + using var reader = new StreamReader(memory, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + return reader.ReadToEnd(); + } + + private static void WritePreparedFiles( + string changelogPath, + string originalChangelog, + string updatedChangelog, + string versionPath, + string originalVersionJson, + string updatedVersionJson, + IReadOnlyList fragments) + { + var changelogTempPath = string.Empty; + var versionTempPath = string.Empty; + var changelogReplaced = false; + var versionReplaced = false; + var fragmentDeletionStarted = false; + var changelogWritePath = ResolveWriteTargetPath(changelogPath); + var versionWritePath = ResolveWriteTargetPath(versionPath); + + try + { + changelogTempPath = WriteStagedText(changelogWritePath, updatedChangelog); + versionTempPath = WriteStagedText(versionWritePath, updatedVersionJson); + + NotifyPrepareWritePhase(PrepareWritePhase.StagedFilesWritten); + + ReplaceWithStagedFile(changelogTempPath, changelogWritePath); + changelogTempPath = string.Empty; + changelogReplaced = true; + NotifyPrepareWritePhase(PrepareWritePhase.ChangelogReplaced); + + ReplaceWithStagedFile(versionTempPath, versionWritePath); + versionTempPath = string.Empty; + versionReplaced = true; + NotifyPrepareWritePhase(PrepareWritePhase.VersionReplaced); + + NotifyPrepareWritePhase(PrepareWritePhase.BeforeFragmentsDeleted); + fragmentDeletionStarted = true; + foreach (var fragment in fragments) + DeleteConsumedFragment(fragment); + } + catch (Exception) when (!fragmentDeletionStarted) + { + RollBackPreparedFiles( + changelogWritePath, + originalChangelog, + changelogReplaced, + versionWritePath, + originalVersionJson, + versionReplaced); + throw; + } + finally + { + TryDelete(changelogTempPath); + TryDelete(versionTempPath); + } + } + + private static string WriteStagedText(string targetPath, string contents) + { + var tempPath = BuildTempPath(targetPath); + var tempCreated = false; + var writeCompleted = false; + try + { + using (var stream = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + tempCreated = true; + NotifyPrepareWritePhase(PrepareWritePhase.StagedTempCreated); + + using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), bufferSize: 1024, leaveOpen: true); + writer.Write(contents); + writer.Flush(); + stream.Flush(flushToDisk: true); + } + + writeCompleted = true; + } + finally + { + if (tempCreated && !writeCompleted) + TryDelete(tempPath); + } + + return tempPath; + } + + private static string ResolveWriteTargetPath(string targetPath) + { + var fileInfo = new FileInfo(targetPath); + if (fileInfo.LinkTarget is null) + return targetPath; + + var finalTarget = fileInfo.ResolveLinkTarget(returnFinalTarget: true); + if (finalTarget is not null) + return finalTarget.FullName; + + var linkTarget = fileInfo.LinkTarget; + if (string.IsNullOrEmpty(linkTarget)) + return targetPath; + + if (Path.IsPathFullyQualified(linkTarget)) + return linkTarget; + + var directory = fileInfo.DirectoryName; + return Path.GetFullPath(Path.Combine( + string.IsNullOrEmpty(directory) ? Directory.GetCurrentDirectory() : directory, + linkTarget)); + } + + private static void ReplaceWithStagedFile(string stagedPath, string targetPath) + { + File.Move(stagedPath, targetPath, overwrite: true); + } + + private static void DeleteConsumedFragment(Fragment fragment) + { + try + { + File.Delete(fragment.AbsolutePath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new ChangelogException($"{fragment.RelativePath}: failed to delete consumed changelog fragment after CHANGELOG.md and version.json were updated; delete this fragment manually before retrying prepare. {ex.Message}"); + } + } + + private static void RollBackPreparedFiles( + string changelogPath, + string originalChangelog, + bool changelogReplaced, + string versionPath, + string originalVersionJson, + bool versionReplaced) + { + try + { + if (versionReplaced) + RestoreText(versionPath, originalVersionJson); + + if (changelogReplaced) + RestoreText(changelogPath, originalChangelog); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw new ChangelogException($"prepare failed before fragment deletion and rollback also failed: {ex.Message}"); + } + } + + private static void RestoreText(string targetPath, string contents) + { + var tempPath = string.Empty; + try + { + tempPath = WriteStagedText(targetPath, contents); + ReplaceWithStagedFile(tempPath, targetPath); + tempPath = string.Empty; + } + finally + { + TryDelete(tempPath); + } + } + + private static string BuildTempPath(string targetPath) + { + var directory = Path.GetDirectoryName(targetPath); + var fileName = Path.GetFileName(targetPath); + var tempFileName = $".{fileName}.{Guid.NewGuid():N}.tmp"; + return string.IsNullOrEmpty(directory) + ? tempFileName + : Path.Combine(directory, tempFileName); + } + + private static void NotifyPrepareWritePhase(PrepareWritePhase phase) + { + PrepareWritePhaseForTesting?.Invoke(phase); + } + + private static void TryDelete(string path) + { + if (string.IsNullOrEmpty(path)) + return; + + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + private static List PrepareLanguageSection( IReadOnlyList existingBlocks, Version targetVersion, @@ -852,6 +1113,15 @@ private enum Language } } +internal enum PrepareWritePhase +{ + StagedTempCreated, + StagedFilesWritten, + ChangelogReplaced, + VersionReplaced, + BeforeFragmentsDeleted, +} + public sealed record PrepareResult(string Summary, string? RenderedChangelog); public sealed class ChangelogException : Exception