Repository navigation
Fix SaveFileAsync keeping old bytes when it overwrites a longer file - #84
Merged
Merged
Conversation
SaveFileAsync(Uri, string), which every SaveFileAsync and SaveFilesAsync overload goes through, opened the target with FileInfo.OpenWrite. That is FileMode.OpenOrCreate, which does not truncate, so a result saved over a longer file kept that file's trailing bytes: a 146,734-byte PDF saved over a 400,000-byte file stayed 400,000 bytes. It now opens the file with FileMode.Create, with the same access and sharing as before. SaveFilesAsync(outputDirectory) also creates the folder when it is missing, instead of throwing DirectoryNotFoundException. An empty path still means the current directory. Adds ConvertApi.Tests (NUnit), the repository's first test project. The tests serve result files from a fake IConvertApiHttpClient, so they need no network and no token. The build workflow runs them before the pack. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues block the file-save overwrite fix and output-folder handling changes.
No open issues 🎉
What changed in this PR
Fixes file-save corruption in the ConvertAPI .NET SDK when a downloaded result overwrites a larger file, without changing the public API.
Changes:
- Truncates existing destination files and creates missing folders for
SaveFilesAsync, preserving empty-path behavior. - Adds four network-free regression tests covering overwrites and output-folder handling.
- Runs tests in CI and updates release documentation and the changelog.
| File | Description |
|---|---|
RELEASING.md |
Updates the merge-check documentation to include unit tests. |
ConvertApi/ConvertApiExtension.cs |
Truncates overwritten files, creates output folders, and documents save behavior. |
ConvertApi.Tests/SaveFileTests.cs |
Adds four regression tests using a fake HTTP client. |
ConvertApi.Tests/ConvertApi.Tests.csproj |
Defines the NUnit test project and its dependencies. |
ConvertApi.sln |
Includes the test project in solution builds. |
CHANGELOG.md |
Records the overwrite fix, folder creation, and CI tests. |
.github/workflows/build.yml |
Runs unit tests between build and pack. |
Review effort: Balanced
kostas-jonauskas
approved these changes
Nov 9, 2026
tomasr78
added a commit
that referenced
this pull request
Nov 9, 2026
Brings in #84: SaveFileAsync replaces a longer file, SaveFilesAsync creates its folder, and unit tests run in the build check. The only conflict was CHANGELOG.md, where both branches opened [Unreleased]. It is now one section: #84's Fixed entry, then one Changed list with #84's entries first and this branch's after them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Nov 9, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
SaveFileAsyncandSaveFilesAsyncnow replace an existing file instead of writing over its start. The privateSaveFileAsync(Uri, string)that every overload goes through opens the target withFileMode.Create. It usedFileInfo.OpenWrite. Access and sharing stay as they were (FileAccess.Write,FileShare.None).SaveFilesAsync(outputDirectory)creates the folder if it does not exist. An empty path still means the current directory.ConvertApi.Tests(NUnit 4.6.1, as in the other ConvertAPI .NET repos), added toConvertApi.sln. The repository had no tests, so the build workflow now runs them between build and pack, and the comments inbuild.ymlandRELEASING.mdthat said there is no test suite are updated.CHANGELOG.md: an[Unreleased]section.The public API does not change.
Why
FileInfo.OpenWriteopens withFileMode.OpenOrCreate, which does not truncate. When the path already holds a longer file, its trailing bytes stay after the new content and the saved file is corrupt. That happens whenever a program saves where an earlier, larger result is, for example when it runs again after the input changed. The code has been like this since the first commit (657279b, 2018), so every released version has the bug. No issue tracked it.Verified on 2026-11-07 against staging with ConvertApi 3.2.2: a 146,734-byte DOCX to PDF result saved over a 400,000-byte file left a 400,000-byte file.
SaveFilesAsyncthrewDirectoryNotFoundExceptionwhen the output folder did not exist, so copied code failed on its first run. That is why the C# snippets on convertapi.com now create the folder themselves.Compatibility
Directory.CreateDirectorybeforeSaveFilesAsync, as the convertapi.com snippets now do and the README snippets do in README: code that runs as copied, a quick start and C# tasks; NuGet packs the same README #83, still works: creating a folder that exists does nothing. Those snippets should keep the line, because older SDK versions stay in use.SaveFilesAsync("")still saves to the current directory. A test covers it.IOException: The file '...' already exists.It used to fail after the download withDirectoryNotFoundException, which is a subclass ofIOException.SaveFilesAsync("out.pdf"), now creates a folder namedout.pdfinstead of throwing.SaveFileAsync(fileName)still needs the file's folder to exist, asFile.WriteAllBytesdoes.How it was tested
ConvertApiExtension.csfirst, 3 of the 4 fail there (Expected: 146734 But was: 400000, andDirectoryNotFoundExceptionfor the missing folder); the 4th guards the empty-path behaviour, which did not change. With this change all 4 pass:SaveFileAsync_ReplacesALongerExistingFile: 146,734 bytes over 400,000. Checks the length, the returnedFileInfoand every byte.SaveFilesAsync_ReplacesLongerExistingFiles: two result files over longer files of the same names.SaveFilesAsync_CreatesAMissingFolder: a nested folder that does not exist.SaveFilesAsync_EmptyFolderMeansTheCurrentDirectory.IConvertApiHttpClient, so they need no network and no API token.dotnet restore ConvertApi.sln,dotnet build ConvertApi.sln -c Release --no-restore -p:ContinuousIntegrationBuild=true,dotnet test ConvertApi.Tests/ConvertApi.Tests.csproj -c Release --no-build,dotnet pack ConvertApi/ConvertApi.csproj. 0 errors, only the 3 existing warnings in Examples, 4 of 4 tests passed, pack succeeded.ConvertApi.ApiBaseUri = "https://stag-v2.convertapi.com") with this branch's library:Examples/TestFiles/test.docxto PDF gave a 146,734-bytetest.pdf.response.SaveFileAsyncover a 400,000-byte file: 146,734 bytes on disk and in the returnedFileInfo, equal to the API'sFileSize, starting with%PDF-and ending with%%EOF.response.SaveFilesAsyncinto a folder that held a longertest.pdf: 146,734 bytes, the same bytes.response.SaveFilesAsyncintomissing/nested: the folder was created and the file saved.IOException: The file '<work>/single.pdf' already exists.Release
Not merged and not released. This belongs in the planned 3.3.0 release (first-run fixes and NuGet search metadata). Other 3.3.0 items, such as checking the download status in
AsStreamAsyncand clearer 401 and 403 messages, are not in this PR.The
CHANGELOG.mdhunk will conflict with #83, which also adds an[Unreleased]section. Whichever merges second keeps both entries under one heading.🤖 Generated with Claude Code