feat: Implements new robust/pluggable backup/archive system. (#2388)
## Summary
Overhauls the backup/archive system to address stability, robustness, fault tolerance, performance, and pluggability.
### Problems solved
- **Critical bug**: `Interlocked.CompareExchange` arguments were reversed — the concurrent archive guard never actually prevented concurrent archives
- **Brittle compression**: Relied on spawning external `zstd.exe`/`tar.exe`/`bsdtar.exe` processes with platform-specific workarounds (Windows two-step hack, auto-downloading bsdtar from libarchive.org)
- **No fault tolerance**: Partial archives possible if process killed mid-write; no recovery mechanism; originals deleted before verification
- **No extensibility**: No way to add remote backup destinations (S3, rsync) without modifying core code
- **Silent failures**: Bare `catch` blocks swallowed exceptions with no diagnostics
### What changed
**Cross-platform streaming compression** — Replaced external process spawning with `System.Formats.Tar` (built-in .NET) + `ZstdNet` (native libzstd P/Invoke). Single-pass streaming: `TarWriter → CompressionStream → FileStream`. No intermediate `.tar` file, no platform-specific code paths.
**Archive journal** — JSON-based operation journal (`Archives/.archive-journal.json`) tracks state machine: `Started → Archived → Distributed → Completed` (or `Failed`). On startup, recovers interrupted operations (cleans temp files, completes pruning).
**Verify before delete** — Archives are verified (entry count check) before deleting source backups. Temp-file-then-atomic-rename pattern prevents partial archives.
**Retry logic** — File operations (moves, deletes) retry with linear backoff for transient I/O failures (antivirus locks, file copy operations).
**Plugin architecture** — `IArchiveDestination` interface in `Projects/Server/` enables external plugins (loaded via `assemblies.json`) to receive completed archives. `ArchiveDestinationRegistry` tracks destinations. Conservative pruning: source backups preserved if any destination with retention fails.
**Configurable** — Retention counts (hourly/daily/monthly), compression level, retry settings, backup max age all configurable via `ServerConfiguration`.
### New admin commands
- `[ArchiveStatus` — Shows journal state, destinations, next scheduled archive times
- `[ArchiveNow` — Forces immediate rollup regardless of schedule
### Files
| Change | File |
|--------|------|
| New | `Server/Saves/ArchiveEnums.cs`, `IArchiveDestination.cs`, `ArchiveDestinationRegistry.cs`, `ArchiveEventArgs.cs` |
| New | `Server/Events/ArchiveEvents.cs` (partial EventSink) |
| New | `UOContent/Compression/ManagedArchive.cs` |
| New | `UOContent/World Saves/ArchiveJournal.cs`, `LocalArchiveDestination.cs` |
| Rewrite | `UOContent/World Saves/AutoArchive.cs` |
| Modified | `UOContent/World Saves/SaveCommands.cs`, `Server/Utilities/PathUtility.cs`, `UOContent.csproj` |
| Deleted | `UOContent/Compression/TarArchive.cs`, `ZstdArchive.cs` |
| Tests | 29 new tests (ManagedArchive, ArchiveJournal, AutoArchiveHelpers) |
### Backward compatibility
- Existing `.tar.zst` archives are fully readable by the new managed code
- Config keys preserved; new keys use sensible defaults
- `autoArchive.compressionFormat` setting removed (Zstd only)
- Legacy `bsdtar/` directory logged as removable on startup
### Plugin example (external repo)
```csharp
public static class S3Plugin
{
public static void Configure()
{
ArchiveDestinationRegistry.Register(new S3Destination("my-bucket", "us-east-1"));
}
}
```
## Test plan
- [x] 29 new unit tests covering archive create/extract/count, journal state machine, recovery, destinations
- [x] All 969 tests pass (671 Server + 298 UOContent)
- [x] Manual: run server, trigger save, verify backup created and archive rolled up
- [x] Manual: kill server mid-archive, restart, verify journal recovery
- [x] Manual: test with large save files (~500MB+) to benchmark streaming vs old approach
This commit is contained in:
parent
61e41df00c
commit
3b4e1137f6
15 changed files with 2421 additions and 615 deletions
230
Projects/UOContent.Tests/Tests/WorldSaves/ArchiveJournalTests.cs
Normal file
230
Projects/UOContent.Tests/Tests/WorldSaves/ArchiveJournalTests.cs
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Server.Saves;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests;
|
||||
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class ArchiveJournalTests : IDisposable
|
||||
{
|
||||
private readonly string _testDir;
|
||||
|
||||
public ArchiveJournalTests()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), $"modernuo-journal-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testDir))
|
||||
{
|
||||
Directory.Delete(_testDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_LoadsCleanState()
|
||||
{
|
||||
// Arrange & Act
|
||||
ArchiveJournal.Configure(_testDir);
|
||||
|
||||
// Assert - fresh directory should have no operations
|
||||
// Note: ArchiveJournal is static, so we check the journal file doesn't exist
|
||||
var journalPath = Path.Combine(_testDir, ".archive-journal.json");
|
||||
Assert.False(File.Exists(journalPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeginOperation_CreatesStartedEntry()
|
||||
{
|
||||
// Arrange
|
||||
ArchiveJournal.Configure(_testDir);
|
||||
|
||||
// Act
|
||||
var entry = ArchiveJournal.BeginOperation(
|
||||
ArchivePeriod.Hourly,
|
||||
new DateTime(2026, 3, 22, 14, 0, 0, DateTimeKind.Utc),
|
||||
"/tmp/archive.tar.zst.tmp",
|
||||
"/archives/hourly/2026-03-22-14.tar.zst",
|
||||
["backup1", "backup2"]
|
||||
);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ArchiveOperationState.Started, entry.State);
|
||||
Assert.Equal(ArchivePeriod.Hourly, entry.Period);
|
||||
Assert.Equal("/tmp/archive.tar.zst.tmp", entry.TempFile);
|
||||
Assert.Equal(2, entry.SourceDirectories.Count);
|
||||
Assert.Contains(entry, ArchiveJournal.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordArchived_TransitionsState()
|
||||
{
|
||||
// Arrange
|
||||
ArchiveJournal.Configure(_testDir);
|
||||
var entry = ArchiveJournal.BeginOperation(
|
||||
ArchivePeriod.Daily,
|
||||
new DateTime(2026, 3, 22, 0, 0, 0, DateTimeKind.Utc),
|
||||
"/tmp/test.tmp",
|
||||
"/archives/daily/2026-03-22.tar.zst",
|
||||
["backup1"]
|
||||
);
|
||||
|
||||
// Act
|
||||
ArchiveJournal.RecordArchived(entry, 42, 1024000);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ArchiveOperationState.Archived, entry.State);
|
||||
Assert.Equal(42, entry.EntryCount);
|
||||
Assert.Equal(1024000, entry.ArchiveBytes);
|
||||
Assert.Null(entry.TempFile); // Temp file cleared after rename
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordDistributed_StoresResults()
|
||||
{
|
||||
// Arrange
|
||||
ArchiveJournal.Configure(_testDir);
|
||||
var entry = ArchiveJournal.BeginOperation(
|
||||
ArchivePeriod.Monthly,
|
||||
new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
"/tmp/test.tmp",
|
||||
"/archives/monthly/2026-03.tar.zst",
|
||||
["backup1"]
|
||||
);
|
||||
ArchiveJournal.RecordArchived(entry, 100, 5000000);
|
||||
|
||||
var results = new Dictionary<string, bool>
|
||||
{
|
||||
{ "Local Filesystem", true },
|
||||
{ "S3 us-east-1", false }
|
||||
};
|
||||
|
||||
// Act
|
||||
ArchiveJournal.RecordDistributed(entry, results);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ArchiveOperationState.Distributed, entry.State);
|
||||
Assert.True(entry.DestinationResults["Local Filesystem"]);
|
||||
Assert.False(entry.DestinationResults["S3 us-east-1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordCompleted_IsTerminal()
|
||||
{
|
||||
// Arrange
|
||||
ArchiveJournal.Configure(_testDir);
|
||||
var entry = ArchiveJournal.BeginOperation(
|
||||
ArchivePeriod.Hourly,
|
||||
new DateTime(2026, 3, 22, 14, 0, 0, DateTimeKind.Utc),
|
||||
"/tmp/test.tmp",
|
||||
"/archives/hourly/test.tar.zst",
|
||||
["backup1"]
|
||||
);
|
||||
ArchiveJournal.RecordArchived(entry, 10, 500);
|
||||
ArchiveJournal.RecordDistributed(entry, new Dictionary<string, bool>());
|
||||
|
||||
// Act
|
||||
ArchiveJournal.RecordCompleted(entry);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ArchiveOperationState.Completed, entry.State);
|
||||
Assert.NotEqual(DateTime.MaxValue, entry.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordFailure_RecordsReason()
|
||||
{
|
||||
// Arrange
|
||||
ArchiveJournal.Configure(_testDir);
|
||||
var entry = ArchiveJournal.BeginOperation(
|
||||
ArchivePeriod.Hourly,
|
||||
new DateTime(2026, 3, 22, 14, 0, 0, DateTimeKind.Utc),
|
||||
"/tmp/test.tmp",
|
||||
"/archives/hourly/test.tar.zst",
|
||||
["backup1"]
|
||||
);
|
||||
|
||||
// Act
|
||||
ArchiveJournal.RecordFailure(entry, "Disk full");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ArchiveOperationState.Failed, entry.State);
|
||||
Assert.Equal("Disk full", entry.FailureReason);
|
||||
Assert.NotEqual(DateTime.MaxValue, entry.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Journal_PersistsToJsonFile()
|
||||
{
|
||||
// Arrange
|
||||
ArchiveJournal.Configure(_testDir);
|
||||
ArchiveJournal.BeginOperation(
|
||||
ArchivePeriod.Hourly,
|
||||
new DateTime(2026, 3, 22, 14, 0, 0, DateTimeKind.Utc),
|
||||
"/tmp/test.tmp",
|
||||
"/archives/hourly/test.tar.zst",
|
||||
["backup1"]
|
||||
);
|
||||
|
||||
// Assert - journal file should exist
|
||||
var journalPath = Path.Combine(_testDir, ".archive-journal.json");
|
||||
Assert.True(File.Exists(journalPath));
|
||||
|
||||
var content = File.ReadAllText(journalPath);
|
||||
Assert.Contains("Hourly", content);
|
||||
Assert.Contains("Started", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecoverInterrupted_CleansUpStartedOperations()
|
||||
{
|
||||
// Arrange
|
||||
ArchiveJournal.Configure(_testDir);
|
||||
|
||||
// Create a temp file that simulates an interrupted archive
|
||||
var tempFile = Path.Combine(_testDir, "interrupted.tar.zst.tmp");
|
||||
File.WriteAllText(tempFile, "partial archive data");
|
||||
|
||||
var entry = ArchiveJournal.BeginOperation(
|
||||
ArchivePeriod.Hourly,
|
||||
new DateTime(2026, 3, 22, 14, 0, 0, DateTimeKind.Utc),
|
||||
tempFile,
|
||||
Path.Combine(_testDir, "final.tar.zst"),
|
||||
["backup1"]
|
||||
);
|
||||
|
||||
// Act
|
||||
ArchiveJournal.RecoverInterrupted();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ArchiveOperationState.Failed, entry.State);
|
||||
Assert.Equal("Interrupted during archive creation", entry.FailureReason);
|
||||
Assert.False(File.Exists(tempFile)); // Temp file should be cleaned up
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecoverInterrupted_CompletesDistributedOperations()
|
||||
{
|
||||
// Arrange
|
||||
ArchiveJournal.Configure(_testDir);
|
||||
var entry = ArchiveJournal.BeginOperation(
|
||||
ArchivePeriod.Daily,
|
||||
new DateTime(2026, 3, 22, 0, 0, 0, DateTimeKind.Utc),
|
||||
"/tmp/test.tmp",
|
||||
"/archives/daily/test.tar.zst",
|
||||
["backup1"]
|
||||
);
|
||||
ArchiveJournal.RecordArchived(entry, 50, 2000);
|
||||
ArchiveJournal.RecordDistributed(entry, new Dictionary<string, bool> { { "Local", true } });
|
||||
|
||||
// Act - simulate restart
|
||||
ArchiveJournal.RecoverInterrupted();
|
||||
|
||||
// Assert - should be marked completed
|
||||
Assert.Equal(ArchiveOperationState.Completed, entry.State);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
using System;
|
||||
using Server.Saves;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests;
|
||||
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class AutoArchiveHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void ArchiveDestinationRegistry_RegisterAndRetrieve()
|
||||
{
|
||||
// Arrange
|
||||
var dest = new LocalArchiveDestination(24, 30, 12);
|
||||
|
||||
// Act
|
||||
ArchiveDestinationRegistry.Register(dest);
|
||||
|
||||
// Assert
|
||||
Assert.Contains(dest, ArchiveDestinationRegistry.Destinations);
|
||||
|
||||
// Cleanup
|
||||
ArchiveDestinationRegistry.Unregister(dest);
|
||||
Assert.DoesNotContain(dest, ArchiveDestinationRegistry.Destinations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalArchiveDestination_SendArchive_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var dest = new LocalArchiveDestination(24, 30, 12);
|
||||
|
||||
// Act
|
||||
var result = dest.SendArchive("/fake/path.tar.zst", ArchivePeriod.Hourly, DateTime.UtcNow);
|
||||
|
||||
// Assert
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ArchivePeriod.Hourly, 24)]
|
||||
[InlineData(ArchivePeriod.Daily, 30)]
|
||||
[InlineData(ArchivePeriod.Monthly, 12)]
|
||||
public void LocalArchiveDestination_RetentionCounts(ArchivePeriod period, int expected)
|
||||
{
|
||||
// Arrange
|
||||
var dest = new LocalArchiveDestination(24, 30, 12);
|
||||
|
||||
// Act & Assert
|
||||
Assert.Equal(expected, dest.GetRetentionCount(period));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArchiveCompletedEventArgs_StoresValues()
|
||||
{
|
||||
// Arrange & Act
|
||||
var args = new ArchiveCompletedEventArgs(
|
||||
"/archives/hourly/test.tar.zst",
|
||||
ArchivePeriod.Hourly,
|
||||
new DateTime(2026, 3, 22, 14, 0, 0, DateTimeKind.Utc),
|
||||
1048576,
|
||||
2.5
|
||||
);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("/archives/hourly/test.tar.zst", args.ArchiveFilePath);
|
||||
Assert.Equal(ArchivePeriod.Hourly, args.Period);
|
||||
Assert.Equal(1048576, args.FileSizeBytes);
|
||||
Assert.Equal(2.5, args.ElapsedSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArchiveFailedEventArgs_StoresException()
|
||||
{
|
||||
// Arrange
|
||||
var ex = new InvalidOperationException("disk full");
|
||||
|
||||
// Act
|
||||
var args = new ArchiveFailedEventArgs(
|
||||
ArchivePeriod.Daily,
|
||||
new DateTime(2026, 3, 22, 0, 0, 0, DateTimeKind.Utc),
|
||||
ex
|
||||
);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ArchivePeriod.Daily, args.Period);
|
||||
Assert.Same(ex, args.Exception);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArchiveJournalEntry_DefaultState()
|
||||
{
|
||||
// Arrange & Act
|
||||
var entry = new ArchiveJournalEntry();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(entry.SourceDirectories);
|
||||
Assert.Empty(entry.SourceDirectories);
|
||||
Assert.NotNull(entry.DestinationResults);
|
||||
Assert.Empty(entry.DestinationResults);
|
||||
Assert.Null(entry.FailureReason);
|
||||
Assert.Equal(DateTime.MaxValue, entry.CompletedAt);
|
||||
}
|
||||
}
|
||||
325
Projects/UOContent.Tests/Tests/WorldSaves/ManagedArchiveTests.cs
Normal file
325
Projects/UOContent.Tests/Tests/WorldSaves/ManagedArchiveTests.cs
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
using System;
|
||||
using System.Formats.Tar;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using Server.Compression;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests;
|
||||
|
||||
[Collection("Sequential UOContent Tests")]
|
||||
public class ManagedArchiveTests : IDisposable
|
||||
{
|
||||
private readonly string _testDir;
|
||||
|
||||
public ManagedArchiveTests()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), $"modernuo-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_testDir))
|
||||
{
|
||||
Directory.Delete(_testDir, true);
|
||||
}
|
||||
}
|
||||
|
||||
private string CreateTestDirectory(string name, params (string fileName, string content)[] files)
|
||||
{
|
||||
var dir = Path.Combine(_testDir, name);
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
foreach (var (fileName, content) in files)
|
||||
{
|
||||
var filePath = Path.Combine(dir, fileName);
|
||||
var fileDir = Path.GetDirectoryName(filePath);
|
||||
if (fileDir != null)
|
||||
{
|
||||
Directory.CreateDirectory(fileDir);
|
||||
}
|
||||
|
||||
File.WriteAllText(filePath, content);
|
||||
}
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTarZstd_SingleDirectory_RoundTrips()
|
||||
{
|
||||
// Arrange
|
||||
var sourceDir = CreateTestDirectory("source",
|
||||
("file1.txt", "Hello World"),
|
||||
("file2.bin", "Binary content here"),
|
||||
("subdir/nested.txt", "Nested file")
|
||||
);
|
||||
|
||||
var archivePath = Path.Combine(_testDir, "test.tar.zst");
|
||||
|
||||
// Act
|
||||
var entryCount = ManagedArchive.CreateTarZstd(
|
||||
[sourceDir], archivePath, _testDir, compressionLevel: 1
|
||||
);
|
||||
|
||||
// Assert
|
||||
Assert.True(entryCount > 0);
|
||||
Assert.True(File.Exists(archivePath));
|
||||
Assert.True(new FileInfo(archivePath).Length > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTarZstd_MultipleDirectories_IncludesAll()
|
||||
{
|
||||
// Arrange
|
||||
var dir1 = CreateTestDirectory("backup1",
|
||||
("items.bin", "items data"),
|
||||
("items.idx", "items index")
|
||||
);
|
||||
var dir2 = CreateTestDirectory("backup2",
|
||||
("mobiles.bin", "mobiles data"),
|
||||
("mobiles.idx", "mobiles index")
|
||||
);
|
||||
|
||||
var archivePath = Path.Combine(_testDir, "multi.tar.zst");
|
||||
|
||||
// Act
|
||||
var entryCount = ManagedArchive.CreateTarZstd(
|
||||
[dir1, dir2], archivePath, _testDir, compressionLevel: 1
|
||||
);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(4, entryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTarZstd_RecreatesFiles()
|
||||
{
|
||||
// Arrange
|
||||
var sourceDir = CreateTestDirectory("source",
|
||||
("data.txt", "Test data content"),
|
||||
("config/settings.json", "{\"key\":\"value\"}")
|
||||
);
|
||||
|
||||
var archivePath = Path.Combine(_testDir, "extract-test.tar.zst");
|
||||
ManagedArchive.CreateTarZstd([sourceDir], archivePath, _testDir, compressionLevel: 1);
|
||||
|
||||
var extractDir = Path.Combine(_testDir, "extracted");
|
||||
|
||||
// Act
|
||||
var success = ManagedArchive.ExtractTarZstd(archivePath, extractDir);
|
||||
|
||||
// Assert
|
||||
Assert.True(success);
|
||||
Assert.True(Directory.Exists(extractDir));
|
||||
|
||||
// Verify extracted content matches
|
||||
var extractedDataPath = Path.Combine(extractDir, "source", "data.txt");
|
||||
Assert.True(File.Exists(extractedDataPath));
|
||||
Assert.Equal("Test data content", File.ReadAllText(extractedDataPath));
|
||||
|
||||
var extractedConfigPath = Path.Combine(extractDir, "source", "config", "settings.json");
|
||||
Assert.True(File.Exists(extractedConfigPath));
|
||||
Assert.Equal("{\"key\":\"value\"}", File.ReadAllText(extractedConfigPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountEntries_MatchesCreateCount()
|
||||
{
|
||||
// Arrange
|
||||
var sourceDir = CreateTestDirectory("source",
|
||||
("a.txt", "a"),
|
||||
("b.txt", "b"),
|
||||
("c/d.txt", "d"),
|
||||
("c/e.txt", "e")
|
||||
);
|
||||
|
||||
var archivePath = Path.Combine(_testDir, "count-test.tar.zst");
|
||||
var createCount = ManagedArchive.CreateTarZstd(
|
||||
[sourceDir], archivePath, _testDir, compressionLevel: 1
|
||||
);
|
||||
|
||||
// Act
|
||||
var verifyCount = ManagedArchive.CountEntries(archivePath);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(createCount, verifyCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTarZstd_EmptyDirectory_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var sourceDir = Path.Combine(_testDir, "empty");
|
||||
Directory.CreateDirectory(sourceDir);
|
||||
|
||||
var archivePath = Path.Combine(_testDir, "empty.tar.zst");
|
||||
|
||||
// Act
|
||||
var entryCount = ManagedArchive.CreateTarZstd(
|
||||
[sourceDir], archivePath, _testDir, compressionLevel: 1
|
||||
);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, entryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTarZstd_NonexistentSource_SkipsGracefully()
|
||||
{
|
||||
// Arrange
|
||||
var archivePath = Path.Combine(_testDir, "nonexistent.tar.zst");
|
||||
|
||||
// Act
|
||||
var entryCount = ManagedArchive.CreateTarZstd(
|
||||
[Path.Combine(_testDir, "does-not-exist")], archivePath, _testDir, compressionLevel: 1
|
||||
);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, entryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateTarZstd_TempFileCleanedUp()
|
||||
{
|
||||
// Arrange
|
||||
var sourceDir = CreateTestDirectory("source", ("file.txt", "content"));
|
||||
var archivePath = Path.Combine(_testDir, "cleanup-test.tar.zst");
|
||||
|
||||
// Act
|
||||
ManagedArchive.CreateTarZstd([sourceDir], archivePath, _testDir, compressionLevel: 1);
|
||||
|
||||
// Assert - no .tmp file should remain
|
||||
Assert.False(File.Exists(archivePath + ".tmp"));
|
||||
Assert.True(File.Exists(archivePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTarZstd_InvalidFile_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var badFile = Path.Combine(_testDir, "bad.tar.zst");
|
||||
File.WriteAllText(badFile, "this is not a valid archive");
|
||||
var extractDir = Path.Combine(_testDir, "extract-bad");
|
||||
|
||||
// Act
|
||||
var result = ManagedArchive.ExtractTarZstd(badFile, extractDir);
|
||||
|
||||
// Assert
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountEntries_InvalidFile_ReturnsNegative()
|
||||
{
|
||||
// Arrange
|
||||
var badFile = Path.Combine(_testDir, "bad.tar.zst");
|
||||
File.WriteAllText(badFile, "not a valid archive");
|
||||
|
||||
// Act
|
||||
var count = ManagedArchive.CountEntries(badFile);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(-1, count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(3)]
|
||||
[InlineData(10)]
|
||||
public void CreateTarZstd_DifferentCompressionLevels_AllWork(int level)
|
||||
{
|
||||
// Arrange
|
||||
var sourceDir = CreateTestDirectory($"level-{level}", ("data.txt", new string('X', 10000)));
|
||||
var archivePath = Path.Combine(_testDir, $"level-{level}.tar.zst");
|
||||
|
||||
// Act
|
||||
var entryCount = ManagedArchive.CreateTarZstd(
|
||||
[sourceDir], archivePath, _testDir, compressionLevel: level
|
||||
);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, entryCount);
|
||||
Assert.True(File.Exists(archivePath));
|
||||
|
||||
// Verify we can extract it back
|
||||
var extractDir = Path.Combine(_testDir, $"extract-level-{level}");
|
||||
Assert.True(ManagedArchive.ExtractTarZstd(archivePath, extractDir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTarGz_RoundTrips()
|
||||
{
|
||||
// Arrange — create a .tar.gz using built-in .NET APIs
|
||||
var sourceDir = CreateTestDirectory("gz-source",
|
||||
("data.txt", "GZip test data"),
|
||||
("sub/nested.txt", "Nested content")
|
||||
);
|
||||
|
||||
var archivePath = Path.Combine(_testDir, "test.tar.gz");
|
||||
|
||||
using (var fileStream = new FileStream(archivePath, FileMode.Create))
|
||||
using (var gzStream = new GZipStream(fileStream, System.IO.Compression.CompressionLevel.Fastest))
|
||||
using (var tarWriter = new TarWriter(gzStream, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
foreach (var file in new DirectoryInfo(sourceDir).EnumerateFiles("*", SearchOption.AllDirectories))
|
||||
{
|
||||
var entryName = Path.GetRelativePath(sourceDir, file.FullName).Replace('\\', '/');
|
||||
tarWriter.WriteEntry(file.FullName, entryName);
|
||||
}
|
||||
}
|
||||
|
||||
var extractDir = Path.Combine(_testDir, "gz-extracted");
|
||||
|
||||
// Act
|
||||
var success = ManagedArchive.ExtractTarGz(archivePath, extractDir);
|
||||
|
||||
// Assert
|
||||
Assert.True(success);
|
||||
Assert.Equal("GZip test data", File.ReadAllText(Path.Combine(extractDir, "data.txt")));
|
||||
Assert.Equal("Nested content", File.ReadAllText(Path.Combine(extractDir, "sub", "nested.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTar_PlainTarRoundTrips()
|
||||
{
|
||||
// Arrange — create a plain .tar using built-in .NET APIs
|
||||
var sourceDir = CreateTestDirectory("tar-source",
|
||||
("plain.txt", "Plain tar test data")
|
||||
);
|
||||
|
||||
var archivePath = Path.Combine(_testDir, "test.tar");
|
||||
|
||||
using (var fileStream = new FileStream(archivePath, FileMode.Create))
|
||||
using (var tarWriter = new TarWriter(fileStream, TarEntryFormat.Pax, leaveOpen: true))
|
||||
{
|
||||
foreach (var file in new DirectoryInfo(sourceDir).EnumerateFiles("*", SearchOption.AllDirectories))
|
||||
{
|
||||
var entryName = Path.GetRelativePath(sourceDir, file.FullName).Replace('\\', '/');
|
||||
tarWriter.WriteEntry(file.FullName, entryName);
|
||||
}
|
||||
}
|
||||
|
||||
var extractDir = Path.Combine(_testDir, "tar-extracted");
|
||||
|
||||
// Act
|
||||
var success = ManagedArchive.ExtractTar(archivePath, extractDir);
|
||||
|
||||
// Assert
|
||||
Assert.True(success);
|
||||
Assert.Equal("Plain tar test data", File.ReadAllText(Path.Combine(extractDir, "plain.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTarGz_InvalidFile_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var badFile = Path.Combine(_testDir, "bad.tar.gz");
|
||||
File.WriteAllText(badFile, "not a valid gzip archive");
|
||||
var extractDir = Path.Combine(_testDir, "extract-bad-gz");
|
||||
|
||||
// Act & Assert
|
||||
Assert.False(ManagedArchive.ExtractTarGz(badFile, extractDir));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue