553 lines
28 KiB
C#
553 lines
28 KiB
C#
// Copyright (C) 2026, 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 System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Duplicati.Library.Interface;
|
|
using Duplicati.Library.Main;
|
|
using NUnit.Framework;
|
|
|
|
namespace Duplicati.UnitTest
|
|
{
|
|
public class SoftDeleteTests : BasicSetupHelper
|
|
{
|
|
/// <summary>
|
|
/// Tests that soft-delete works with a simple prefix (like "deleted-") using a backend that supports rename.
|
|
/// </summary>
|
|
[Test]
|
|
[Category("SoftDelete")]
|
|
public void TestSoftDeleteWithSimplePrefix()
|
|
{
|
|
// Use a simple prefix (not a folder path)
|
|
var softDeletePrefix = "deleted-";
|
|
var testFile = Path.Combine(this.DATAFOLDER, "testfile.txt");
|
|
|
|
// 1. Create initial backup
|
|
File.WriteAllText(testFile, "test content version 1");
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, keep_versions = 2 }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 1 should have no errors");
|
|
}
|
|
|
|
// Get the files in the target folder after first backup
|
|
var filesAfterBackup1 = Directory.GetFiles(this.TARGETFOLDER);
|
|
Assert.That(filesAfterBackup1.Length, Is.GreaterThan(0), "Should have files after first backup");
|
|
|
|
// 2. Modify file and create second backup
|
|
Thread.Sleep(1100); // Ensure timestamp difference
|
|
File.WriteAllText(testFile, "test content version 2");
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, keep_versions = 2 }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 2 should have no errors");
|
|
}
|
|
|
|
// 3. Modify file and create third backup
|
|
Thread.Sleep(1100); // Ensure timestamp difference
|
|
File.WriteAllText(testFile, "test content version 3");
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, keep_versions = 2 }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 3 should have no errors");
|
|
}
|
|
|
|
// At this point, with keep-versions=2, the first backup should be deleted (soft-deleted)
|
|
// Check if any files have the soft-delete prefix
|
|
var allFiles = Directory.GetFiles(this.TARGETFOLDER);
|
|
var softDeletedFiles = allFiles.Where(f => Path.GetFileName(f).StartsWith(softDeletePrefix)).ToArray();
|
|
|
|
Assert.That(softDeletedFiles.Length, Is.GreaterThan(0),
|
|
$"Should have soft-deleted files with prefix '{softDeletePrefix}'. Files in target: {string.Join(", ", allFiles.Select(Path.GetFileName))}");
|
|
|
|
// Verify that the soft-deleted files include dlist files (from the deleted version)
|
|
Assert.That(softDeletedFiles.Any(f => f.Contains(".dlist.")), Is.True,
|
|
$"Should have soft-deleted dlist files. Soft-deleted files: {string.Join(", ", softDeletedFiles.Select(Path.GetFileName))}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests that soft-delete works with a folder-based prefix (like "recycled/") using a backend that supports rename.
|
|
/// The folder should be created automatically if it doesn't exist.
|
|
/// </summary>
|
|
[Test]
|
|
[Category("SoftDelete")]
|
|
public void TestSoftDeleteWithFolderPrefix()
|
|
{
|
|
// Use a folder-based prefix
|
|
var softDeletePrefix = "recycled/";
|
|
var testFile = Path.Combine(this.DATAFOLDER, "testfile.txt");
|
|
|
|
// Create the recycled folder manually as it is required
|
|
Directory.CreateDirectory(Path.Combine(this.TARGETFOLDER, "recycled"));
|
|
|
|
// 1. Create initial backup
|
|
File.WriteAllText(testFile, "test content version 1");
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, keep_versions = 2 }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 1 should have no errors");
|
|
}
|
|
|
|
// 2. Modify file and create second backup
|
|
Thread.Sleep(1100); // Ensure timestamp difference
|
|
File.WriteAllText(testFile, "test content version 2");
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, keep_versions = 2 }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 2 should have no errors");
|
|
}
|
|
|
|
// 3. Modify file and create third backup
|
|
Thread.Sleep(1100); // Ensure timestamp difference
|
|
File.WriteAllText(testFile, "test content version 3");
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, keep_versions = 2 }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 3 should have no errors");
|
|
}
|
|
|
|
// At this point, with keep-versions=2, the first backup should be deleted (soft-deleted)
|
|
// Check if the recycled folder exists and contains files
|
|
var recycledFolder = Path.Combine(this.TARGETFOLDER, "recycled");
|
|
Assert.That(Directory.Exists(recycledFolder), Is.True,
|
|
$"Recycled folder should exist. Contents of target: {string.Join(", ", Directory.GetFileSystemEntries(this.TARGETFOLDER))}");
|
|
|
|
var softDeletedFiles = Directory.GetFiles(recycledFolder);
|
|
Assert.That(softDeletedFiles.Length, Is.GreaterThan(0),
|
|
$"Should have soft-deleted files in recycled folder");
|
|
|
|
// Verify that the soft-deleted files include dlist files (from the deleted version)
|
|
Assert.That(softDeletedFiles.Any(f => f.Contains(".dlist.")), Is.True,
|
|
$"Should have soft-deleted dlist files. Soft-deleted files: {string.Join(", ", softDeletedFiles.Select(Path.GetFileName))}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests that soft-delete works with a backend that does NOT support rename (IRenameEnabledBackend).
|
|
/// In this case, the fallback mechanism (download, upload with new name, delete old) should be used.
|
|
/// </summary>
|
|
[Test]
|
|
[Category("SoftDelete")]
|
|
public void TestSoftDeleteWithoutRenameEnabledBackend()
|
|
{
|
|
// Register the NoRenameBackend
|
|
Library.DynamicLoader.BackendLoader.AddBackend(new NoRenameBackend());
|
|
|
|
// Use a simple prefix
|
|
var softDeletePrefix = "deleted-";
|
|
var testFile = Path.Combine(this.DATAFOLDER, "testfile.txt");
|
|
|
|
// 1. Create initial backup
|
|
File.WriteAllText(testFile, "test content version 1");
|
|
using (var c = new Controller(new NoRenameBackend().ProtocolKey + "://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, keep_versions = 2 }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 1 should have no errors");
|
|
}
|
|
|
|
// Get the files in the target folder after first backup
|
|
var filesAfterBackup1 = Directory.GetFiles(this.TARGETFOLDER);
|
|
Assert.That(filesAfterBackup1.Length, Is.GreaterThan(0), "Should have files after first backup");
|
|
|
|
// 2. Modify file and create second backup
|
|
Thread.Sleep(1100); // Ensure timestamp difference
|
|
File.WriteAllText(testFile, "test content version 2");
|
|
using (var c = new Controller(new NoRenameBackend().ProtocolKey + "://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, keep_versions = 2 }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 2 should have no errors");
|
|
}
|
|
|
|
// 3. Modify file and create third backup
|
|
Thread.Sleep(1100); // Ensure timestamp difference
|
|
File.WriteAllText(testFile, "test content version 3");
|
|
using (var c = new Controller(new NoRenameBackend().ProtocolKey + "://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, keep_versions = 2 }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 3 should have no errors");
|
|
}
|
|
|
|
// At this point, with keep-versions=2, the first backup should be deleted (soft-deleted)
|
|
// Check if any files have the soft-delete prefix
|
|
var allFiles = Directory.GetFiles(this.TARGETFOLDER);
|
|
var softDeletedFiles = allFiles.Where(f => Path.GetFileName(f).StartsWith(softDeletePrefix)).ToArray();
|
|
|
|
Assert.That(softDeletedFiles.Length, Is.GreaterThan(0),
|
|
$"Should have soft-deleted files with prefix '{softDeletePrefix}'. Files in target: {string.Join(", ", allFiles.Select(Path.GetFileName))}");
|
|
|
|
// Verify that the soft-deleted files include dlist files
|
|
Assert.That(softDeletedFiles.Any(f => f.Contains(".dlist.")), Is.True,
|
|
$"Should have soft-deleted dlist files. Soft-deleted files: {string.Join(", ", softDeletedFiles.Select(Path.GetFileName))}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests that soft-delete with folder prefix works with a backend that does NOT support rename.
|
|
/// When the backend doesn't support rename and can't create directories, it falls back to using
|
|
/// a flat prefix (e.g., "recycled-" instead of "recycled/").
|
|
/// </summary>
|
|
[Test]
|
|
[Category("SoftDelete")]
|
|
public void TestSoftDeleteWithFolderPrefixWithoutRenameBackend()
|
|
{
|
|
// Register the NoRenameBackend
|
|
Library.DynamicLoader.BackendLoader.AddBackend(new NoRenameBackend());
|
|
|
|
// Use a folder-based prefix
|
|
var softDeletePrefix = "recycled/";
|
|
var testFile = Path.Combine(this.DATAFOLDER, "testfile.txt");
|
|
|
|
// Create the recycled folder manually as it is required
|
|
Directory.CreateDirectory(Path.Combine(this.TARGETFOLDER, "recycled"));
|
|
|
|
// 1. Create initial backup
|
|
File.WriteAllText(testFile, "test content version 1");
|
|
using (var c = new Controller(new NoRenameBackend().ProtocolKey + "://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, keep_versions = 2 }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 1 should have no errors");
|
|
}
|
|
|
|
// 2. Modify file and create second backup
|
|
Thread.Sleep(1100); // Ensure timestamp difference
|
|
File.WriteAllText(testFile, "test content version 2");
|
|
using (var c = new Controller(new NoRenameBackend().ProtocolKey + "://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, keep_versions = 2 }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 2 should have no errors");
|
|
}
|
|
|
|
// 3. Modify file and create third backup
|
|
Thread.Sleep(1100); // Ensure timestamp difference
|
|
File.WriteAllText(testFile, "test content version 3");
|
|
using (var c = new Controller(new NoRenameBackend().ProtocolKey + "://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, keep_versions = 2 }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 3 should have no errors");
|
|
}
|
|
|
|
// At this point, with keep-versions=2, the first backup should be deleted (soft-deleted)
|
|
// Check if the recycled folder exists and contains files
|
|
var recycledFolder = Path.Combine(this.TARGETFOLDER, "recycled");
|
|
Assert.That(Directory.Exists(recycledFolder), Is.True,
|
|
$"Recycled folder should exist. Contents of target: {string.Join(", ", Directory.GetFileSystemEntries(this.TARGETFOLDER))}");
|
|
|
|
var softDeletedFiles = Directory.GetFiles(recycledFolder);
|
|
Assert.That(softDeletedFiles.Length, Is.GreaterThan(0),
|
|
$"Should have soft-deleted files in recycled folder");
|
|
|
|
// Verify that the soft-deleted files include dlist files
|
|
Assert.That(softDeletedFiles.Any(f => f.Contains(".dlist.")), Is.True,
|
|
$"Should have soft-deleted dlist files. Soft-deleted files: {string.Join(", ", softDeletedFiles.Select(Path.GetFileName))}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests that soft-delete works with the prevent-backend-rename option.
|
|
/// This should force the fallback mechanism even if the backend supports rename.
|
|
/// </summary>
|
|
[Test]
|
|
[Category("SoftDelete")]
|
|
public void TestSoftDeleteWithPreventBackendRename()
|
|
{
|
|
// Use a simple prefix
|
|
var softDeletePrefix = "deleted-";
|
|
var testFile = Path.Combine(this.DATAFOLDER, "testfile.txt");
|
|
|
|
// 1. Create initial backup
|
|
File.WriteAllText(testFile, "test content version 1");
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, keep_versions = 2, prevent_backend_rename = "true" }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 1 should have no errors");
|
|
}
|
|
|
|
// 2. Modify file and create second backup
|
|
Thread.Sleep(1100); // Ensure timestamp difference
|
|
File.WriteAllText(testFile, "test content version 2");
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, keep_versions = 2, prevent_backend_rename = "true" }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 2 should have no errors");
|
|
}
|
|
|
|
// 3. Modify file and create third backup
|
|
Thread.Sleep(1100); // Ensure timestamp difference
|
|
File.WriteAllText(testFile, "test content version 3");
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, keep_versions = 2, prevent_backend_rename = "true" }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 3 should have no errors");
|
|
}
|
|
|
|
// At this point, with keep-versions=2, the first backup should be deleted (soft-deleted)
|
|
// Check if any files have the soft-delete prefix
|
|
var allFiles = Directory.GetFiles(this.TARGETFOLDER);
|
|
var softDeletedFiles = allFiles.Where(f => Path.GetFileName(f).StartsWith(softDeletePrefix)).ToArray();
|
|
|
|
Assert.That(softDeletedFiles.Length, Is.GreaterThan(0),
|
|
$"Should have soft-deleted files with prefix '{softDeletePrefix}'. Files in target: {string.Join(", ", allFiles.Select(Path.GetFileName))}");
|
|
|
|
// Verify that the soft-deleted files include dlist files
|
|
Assert.That(softDeletedFiles.Any(f => f.Contains(".dlist.")), Is.True,
|
|
$"Should have soft-deleted dlist files. Soft-deleted files: {string.Join(", ", softDeletedFiles.Select(Path.GetFileName))}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests that without soft-delete prefix, files are actually deleted (not renamed).
|
|
/// </summary>
|
|
[Test]
|
|
[Category("SoftDelete")]
|
|
public void TestWithoutSoftDeleteFilesAreActuallyDeleted()
|
|
{
|
|
var testFile = Path.Combine(this.DATAFOLDER, "testfile.txt");
|
|
|
|
// 1. Create initial backup
|
|
File.WriteAllText(testFile, "test content version 1");
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { keep_versions = 2 }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 1 should have no errors");
|
|
}
|
|
|
|
// Get the files in the target folder after first backup
|
|
var filesAfterBackup1 = Directory.GetFiles(this.TARGETFOLDER).Select(Path.GetFileName).ToHashSet();
|
|
Assert.That(filesAfterBackup1.Count, Is.GreaterThan(0), "Should have files after first backup");
|
|
|
|
// 2. Modify file and create second backup
|
|
Thread.Sleep(1100); // Ensure timestamp difference
|
|
File.WriteAllText(testFile, "test content version 2");
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { keep_versions = 2 }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 2 should have no errors");
|
|
}
|
|
|
|
// 3. Modify file and create third backup
|
|
Thread.Sleep(1100); // Ensure timestamp difference
|
|
File.WriteAllText(testFile, "test content version 3");
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { keep_versions = 2 }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 3 should have no errors");
|
|
}
|
|
|
|
// At this point, with keep-versions=2, the first backup should be deleted
|
|
// Without soft-delete, the files should be gone completely
|
|
var allFiles = Directory.GetFiles(this.TARGETFOLDER).Select(Path.GetFileName).ToArray();
|
|
|
|
// Check that no files have a "deleted-" prefix (since we didn't use soft-delete)
|
|
var softDeletedFiles = allFiles.Where(f => f.StartsWith("deleted-")).ToArray();
|
|
Assert.That(softDeletedFiles.Length, Is.EqualTo(0), "Should not have any soft-deleted files when soft-delete is not enabled");
|
|
|
|
// Verify that some files from the first backup are no longer present
|
|
// (The dlist file from the first backup should be deleted)
|
|
var dlistFiles = allFiles.Where(f => f.Contains(".dlist.")).ToArray();
|
|
Assert.That(dlistFiles.Length, Is.EqualTo(2), "Should have exactly 2 dlist files (for the 2 kept versions)");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests soft-delete with compact operation.
|
|
/// </summary>
|
|
[Test]
|
|
[Category("SoftDelete")]
|
|
public void TestSoftDeleteWithCompact()
|
|
{
|
|
var softDeletePrefix = "deleted-";
|
|
var testFile = Path.Combine(this.DATAFOLDER, "testfile.txt");
|
|
|
|
// 1. Create initial backup with a larger file
|
|
var largeContent = new string('A', 100000);
|
|
File.WriteAllText(testFile, largeContent);
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 1 should have no errors");
|
|
}
|
|
|
|
// 2. Delete the file and backup again
|
|
File.Delete(testFile);
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, "Backup 2 should have no errors");
|
|
}
|
|
|
|
// 3. Delete the first version to trigger compact
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix }), null))
|
|
{
|
|
var result = c.Delete();
|
|
Assert.That(result.Errors, Is.Empty, "Delete should have no errors");
|
|
}
|
|
|
|
// 4. Run compact with aggressive settings
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, small_file_size = "1", small_file_max_count = "0" }), null))
|
|
{
|
|
var result = c.Compact();
|
|
Assert.That(result.Errors, Is.Empty, "Compact should have no errors");
|
|
}
|
|
|
|
// Check if any files have the soft-delete prefix
|
|
var allFiles = Directory.GetFiles(this.TARGETFOLDER);
|
|
var softDeletedFiles = allFiles.Where(f => Path.GetFileName(f).StartsWith(softDeletePrefix)).ToArray();
|
|
|
|
// We should have some soft-deleted files from the compact operation
|
|
Assert.That(softDeletedFiles.Length, Is.GreaterThanOrEqualTo(0),
|
|
$"Compact operation completed. Files in target: {string.Join(", ", allFiles.Select(Path.GetFileName))}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests that soft-delete handles duplicate filenames by adding a random suffix.
|
|
/// </summary>
|
|
[Test]
|
|
[Category("SoftDelete")]
|
|
public void TestSoftDeleteHandlesDuplicateFilenames()
|
|
{
|
|
var softDeletePrefix = "deleted-";
|
|
var testFile = Path.Combine(this.DATAFOLDER, "testfile.txt");
|
|
|
|
// Create multiple backups that will result in files being soft-deleted
|
|
for (int i = 1; i <= 5; i++)
|
|
{
|
|
File.WriteAllText(testFile, $"test content version {i}");
|
|
Thread.Sleep(1100); // Ensure timestamp difference
|
|
using (var c = new Controller("file://" + this.TARGETFOLDER, TestOptions.Expand(new { soft_delete_prefix = softDeletePrefix, keep_versions = 2 }), null))
|
|
{
|
|
var result = c.Backup(new[] { this.DATAFOLDER });
|
|
Assert.That(result.Errors, Is.Empty, $"Backup {i} should have no errors");
|
|
}
|
|
}
|
|
|
|
// Check that we have multiple soft-deleted files
|
|
var allFiles = Directory.GetFiles(this.TARGETFOLDER);
|
|
var softDeletedFiles = allFiles.Where(f => Path.GetFileName(f).StartsWith(softDeletePrefix)).ToArray();
|
|
|
|
Assert.That(softDeletedFiles.Length, Is.GreaterThan(0),
|
|
$"Should have soft-deleted files. Files in target: {string.Join(", ", allFiles.Select(Path.GetFileName))}");
|
|
|
|
// Verify that we have multiple dlist files soft-deleted (from different versions)
|
|
var softDeletedDlistFiles = softDeletedFiles.Where(f => f.Contains(".dlist.")).ToArray();
|
|
Assert.That(softDeletedDlistFiles.Length, Is.GreaterThanOrEqualTo(2),
|
|
$"Should have multiple soft-deleted dlist files. Soft-deleted dlist files: {string.Join(", ", softDeletedDlistFiles.Select(Path.GetFileName))}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A backend wrapper that does NOT implement IRenameEnabledBackend.
|
|
/// This is used to test the soft-delete fallback mechanism (download, upload with new name, delete old).
|
|
/// </summary>
|
|
public class NoRenameBackend : IBackend, IStreamingBackend
|
|
{
|
|
static NoRenameBackend() { WrappedBackend = "file"; }
|
|
|
|
public static string WrappedBackend { get; set; }
|
|
|
|
private IStreamingBackend m_backend;
|
|
|
|
public NoRenameBackend()
|
|
{
|
|
}
|
|
|
|
// ReSharper disable once UnusedMember.Global
|
|
public NoRenameBackend(string url, Dictionary<string, string> options)
|
|
{
|
|
var u = new Library.Utility.Uri(url).SetScheme(WrappedBackend).ToString();
|
|
m_backend = (IStreamingBackend)Library.DynamicLoader.BackendLoader.GetBackend(u, options);
|
|
}
|
|
|
|
#region IStreamingBackend implementation
|
|
public Task PutAsync(string remotename, Stream stream, CancellationToken cancelToken)
|
|
{
|
|
return m_backend.PutAsync(remotename, stream, cancelToken);
|
|
}
|
|
|
|
public Task GetAsync(string remotename, Stream stream, CancellationToken cancelToken)
|
|
{
|
|
return m_backend.GetAsync(remotename, stream, cancelToken);
|
|
}
|
|
#endregion
|
|
|
|
#region IBackend implementation
|
|
public IAsyncEnumerable<IFileEntry> ListAsync(CancellationToken cancellationToken)
|
|
{
|
|
return m_backend.ListAsync(cancellationToken);
|
|
}
|
|
|
|
public Task PutAsync(string remotename, string filename, CancellationToken cancelToken)
|
|
{
|
|
return m_backend.PutAsync(remotename, filename, cancelToken);
|
|
}
|
|
|
|
public Task GetAsync(string remotename, string filename, CancellationToken cancelToken)
|
|
{
|
|
return m_backend.GetAsync(remotename, filename, cancelToken);
|
|
}
|
|
|
|
public Task DeleteAsync(string remotename, CancellationToken cancelToken)
|
|
{
|
|
return m_backend.DeleteAsync(remotename, cancelToken);
|
|
}
|
|
|
|
public Task TestAsync(CancellationToken cancelToken)
|
|
{
|
|
return m_backend.TestAsync(cancelToken);
|
|
}
|
|
|
|
public Task CreateFolderAsync(CancellationToken cancelToken)
|
|
{
|
|
return m_backend.CreateFolderAsync(cancelToken);
|
|
}
|
|
|
|
public Task<string[]> GetDNSNamesAsync(CancellationToken cancelToken) => m_backend.GetDNSNamesAsync(cancelToken);
|
|
|
|
public string DisplayName => "No Rename Backend";
|
|
|
|
public string ProtocolKey => "norename";
|
|
|
|
public IList<ICommandLineArgument> SupportedCommands
|
|
{
|
|
get
|
|
{
|
|
if (m_backend == null)
|
|
try { return Duplicati.Library.DynamicLoader.BackendLoader.GetSupportedCommands(WrappedBackend + "://").ToList(); }
|
|
catch { }
|
|
|
|
return m_backend.SupportedCommands;
|
|
}
|
|
}
|
|
|
|
public string Description => "A testing backend that does not support rename operations";
|
|
|
|
public bool SupportsStreaming => m_backend?.SupportsStreaming ?? false;
|
|
#endregion
|
|
|
|
#region IDisposable implementation
|
|
public void Dispose()
|
|
{
|
|
if (m_backend != null)
|
|
try { m_backend.Dispose(); }
|
|
finally { m_backend = null; }
|
|
}
|
|
#endregion
|
|
}
|
|
}
|