// Copyright (C) 2025, The Duplicati Team // https://duplicati.com, hello@duplicati.com // // Permission is hereby granted, free of charge, to any person obtaining a // copy of this software and associated documentation files (the "Software"), // to deal in the Software without restriction, including without limitation // the rights to use, copy, modify, merge, publish, distribute, sublicense, // and/or sell copies of the Software, and to permit persons to whom the // Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using NUnit.Framework; using System; using System.IO; using System.Linq; using System.Net.Http; using System.Threading.Tasks; using Duplicati.Library.Utility; namespace Duplicati.UnitTest; public class Issue6705 : BasicSetupHelper { public static void DeleteDirectoryContentsForcefully(string directoryPath) { var di = new DirectoryInfo(directoryPath); var files = di.GetFiles(); foreach (FileInfo file in files) { file.Attributes = FileAttributes.Normal; file.Delete(); } var dirs = di.GetDirectories(); foreach (DirectoryInfo dir in dirs) { dir.Attributes = FileAttributes.Normal; DeleteDirectoryContentsForcefully(dir.FullName); dir.Delete(); } } // Copied from CommandLineOperationsTests.cs public static async Task DownloadS3FileIfNewerAsync(string destinationFilePath, string url, int retries = 5) { do { try { using var httpClient = new HttpClient(); // Because it's a test unit, will use a new instance created via default constructor. using var request = new HttpRequestMessage(HttpMethod.Get, url); if (systemIO.FileExists(destinationFilePath)) request.Headers.IfModifiedSince = systemIO.FileGetLastWriteTimeUtc(destinationFilePath); using var response = await httpClient.SendAsync(request); if (response.StatusCode == System.Net.HttpStatusCode.NotModified) { Console.WriteLine("File has not been modified since last download."); return; } else { using var tmpFile = new TempFile(); Console.WriteLine($"Downloading file from {url} to: {tmpFile}"); var contentStream = await response.Content.ReadAsStreamAsync(); var fileInfo = new FileInfo(tmpFile); using (var fileStream = fileInfo.OpenWrite()) await contentStream.CopyToAsync(fileStream); // After download, check if the file length matches the response length long responseLength = response.Content.Headers.ContentLength ?? 0; long fileLength = new FileInfo(tmpFile).Length; if (responseLength != fileLength) throw new Exception($"Downloaded file length {fileLength} does not match response length {responseLength}"); Console.WriteLine($"Download completed, moving to {destinationFilePath}"); File.Move(tmpFile, destinationFilePath, true); return; } } catch (Exception ex) { retries--; Console.WriteLine($"Download failed: {ex.Message}"); if (retries <= 0) throw; await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false); try { System.Net.Dns.GetHostEntry(new System.Uri(url).Host); } catch (Exception) { } Console.WriteLine($"Retrying download, {retries} retries left."); } } while (retries > 0); } static string ExtractedBasePath(string os) => Path.Combine(BASEFOLDER, os); static string ExtractedPath(string os) => Path.Combine(ExtractedBasePath(os), "cross-os-backup"); static string ZipPath(string os) => Path.Combine(BASEFOLDER, $"{os}.zip"); // The backups were created using the following "generic" script on each OS: /* Small backup: git clone git@github.com:duplicati/documentation.git duplicati-docs rm -rf duplicati-docs/.gitbook/assets dotnet build mkdir cross-os-backup Executables/Duplicati.CommandLine/bin/Debug/net10.0/Duplicati.CommandLine backup "file://cross-os-backup/backup" ./duplicati-docs --passphrase=123456 --blocksize=10kb --dblock-size=10mb --dbpath="cross-os-backup/db.sqlite" mv duplicati-docs cross-os-backup/original zip -r {OS}.zip cross-os-backup */ /* Large backup: git clone git@github.com:duplicati/duplicati.git duplicati cd duplicati dotnet build dotnet build -c Release cd .. dotnet build mkdir cross-os-backup Executables/Duplicati.CommandLine/bin/Debug/net10.0/Duplicati.CommandLine backup "file://cross-os-backup/backup" ./duplicati-docs --passphrase=123456 --blocksize=10kb --dblock-size=10mb --dbpath="cross-os-backup/db.sqlite" mv duplicati cross-os-backup/original zip -r {OS}_large.zip cross-os-backup */ [OneTimeSetUp] public void DownloadAndExtractBackups() { bool remote = true; // Set to false to use local files instead of downloading from S3 var localZipPath = "D:\\git\\duplicati-carl"; var zipPostfix = ""; // Set to "" for small backup, "_large" for large backup foreach (var os in new[] { "Windows", "Linux", "MacOS" }) { string zipFilepath = Path.Combine(localZipPath, $"{os}{zipPostfix}.zip"); if (remote) { zipFilepath = ZipPath(os); var url = $"https://testfiles.duplicati.com/cross-os-backups/{os}{zipPostfix}.zip"; DownloadS3FileIfNewerAsync(zipFilepath, url).GetAwaiter().GetResult(); } var extractedPath = ExtractedBasePath(os); Directory.CreateDirectory(extractedPath); ZipFileExtractToDirectory(zipFilepath, extractedPath); } } [Test] [Category("Targeted")] public async Task RestoreAcrossOperatingSystems( [Values("Windows", "Linux", "MacOS")] string original_os, [Values(true, false)] bool use_packed_db, [Values(true, false)] bool use_legacy_restore, [Values(true, false)] bool skip_metadata ) { var extractedPath = ExtractedPath(original_os); var testopts = TestOptions.Expand(new { // Everything is local at this point, so no need for retries. If there's an issue, fail fast. retry_delay = "0", number_of_retries = "0", restore_path = RESTOREFOLDER, restore_legacy = use_legacy_restore, skip_metadata = skip_metadata, }); if (use_packed_db) testopts["dbpath"] = Path.Combine(extractedPath, "db.sqlite"); using var c = new Library.Main.Controller($"file://{extractedPath}/backup", testopts, null); TestUtils.AssertResults(c.Restore(null)); // Verify restored files TestUtils.AssertDirectoryTreesAreEquivalent(Path.Combine(extractedPath, "original"), RESTOREFOLDER, false, "Restored files do not match original files"); DeleteDirectoryContentsForcefully(RESTOREFOLDER); } [OneTimeTearDown] public void Cleanup() { foreach (var os in new[] { "Windows", "Linux", "MacOS" }) { if (systemIO.DirectoryExists(ExtractedBasePath(os))) systemIO.DirectoryDelete(ExtractedBasePath(os), true); if (systemIO.FileExists(ZipPath(os))) systemIO.FileDelete(ZipPath(os)); } } }