This PR adds an option to give a soft-delete prefix. If the prefix is set, files are renamed instead of actually being deleted. For backends that support life-cycle rules, this can be used to provide protection agains accidental deletion. The intended use is that the client credentials do not have permissions to delete files, but can rename files. When a file is soft-deleted, it is then renamed and no longer "visible" to Duplicati. The destination can then have lifecycle rules that deletes files with the designated prefix after a set interval, or by another service that has permissions to actually delete files.
553 lines
28 KiB
C#
553 lines
28 KiB
C#
// 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 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
|
|
}
|
|
}
|