2025-01-07 09:40:39 +01:00
// Copyright (C) 2025, The Duplicati Team
2024-02-28 15:45:30 +01:00
// https://duplicati.com, hello@duplicati.com
2025-08-08 15:45:35 +02:00
//
// 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
2024-02-28 15:45:30 +01:00
// Software is furnished to do so, subject to the following conditions:
2025-08-08 15:45:35 +02:00
//
// The above copyright notice and this permission notice shall be included in
2024-02-28 15:45:30 +01:00
// all copies or substantial portions of the Software.
2025-08-08 15:45:35 +02:00
//
// 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
2024-02-28 15:45:30 +01:00
// DEALINGS IN THE SOFTWARE.
2025-08-08 15:45:35 +02:00
2024-05-30 02:14:05 +02:00
using System ;
2019-12-29 12:11:47 -08:00
using System.Collections.Generic ;
using System.IO ;
2020-07-05 15:00:08 -07:00
using System.Linq ;
2020-07-01 16:32:07 -07:00
using Duplicati.Library.Common.IO ;
2020-07-19 19:51:05 -07:00
using Duplicati.Library.Interface ;
using Duplicati.Library.Main ;
using NUnit.Framework ;
2025-06-13 11:48:24 +02:00
using Assert = NUnit . Framework . Legacy . ClassicAssert ;
2019-12-29 12:11:47 -08:00
2025-08-10 21:00:48 +02:00
#nullable enable
2019-12-29 12:11:47 -08:00
namespace Duplicati.UnitTest
{
public class RestoreHandlerTests : BasicSetupHelper
{
2024-09-28 09:26:00 +02:00
[Test]
[Category("RestoreHandler")]
public void DisablePipedStreaming ()
{
string filePath = Path . Combine ( this . DATAFOLDER , "file" );
File . WriteAllBytes ( filePath , new byte [] { 0 });
Dictionary < string , string > options = new Dictionary < string , string >( this . TestOptions );
using ( Controller c = new Controller ( "file://" + this . TARGETFOLDER , options , null ))
{
c . Backup ( new [] { this . DATAFOLDER });
}
Dictionary < string , string > restoreOptions = new Dictionary < string , string >( this . TestOptions ) { [ "restore-path" ] = this . RESTOREFOLDER };
// This is now the default behavior, so we cannot explicitly disable it
//restoreOptions["disable-piped-streaming"] = "true";
using ( Controller c = new Controller ( "file://" + this . TARGETFOLDER , restoreOptions , null ))
{
IRestoreResults restoreResults = c . Restore ( new [] { filePath });
Assert . AreEqual ( 0 , restoreResults . Errors . Count ());
Assert . AreEqual ( 0 , restoreResults . Warnings . Count ());
}
string restoredFilePath = Path . Combine ( this . RESTOREFOLDER , "file" );
Assert . IsTrue ( File . Exists ( restoredFilePath ));
}
2020-07-01 16:32:07 -07:00
[Test]
[Category("RestoreHandler")]
public void RestoreEmptyFile ()
{
string folderPath = Path . Combine ( this . DATAFOLDER , "folder" );
Directory . CreateDirectory ( folderPath );
string filePath = Path . Combine ( folderPath , "empty_file" );
2020-07-19 19:51:05 -07:00
File . WriteAllBytes ( filePath , new byte [] { });
2020-07-01 16:32:07 -07:00
Dictionary < string , string > options = new Dictionary < string , string >( this . TestOptions );
using ( Controller c = new Controller ( "file://" + this . TARGETFOLDER , options , null ))
{
2024-09-28 09:26:00 +02:00
IBackupResults backupResults = c . Backup ( new [] { this . DATAFOLDER });
2020-07-19 19:51:05 -07:00
Assert . AreEqual ( 0 , backupResults . Errors . Count ());
Assert . AreEqual ( 0 , backupResults . Warnings . Count ());
2020-07-01 16:32:07 -07:00
}
// Issue #4148 described a situation where the folders containing the empty file were not recreated properly.
Dictionary < string , string > restoreOptions = new Dictionary < string , string >( this . TestOptions )
{
2020-07-20 20:08:14 -07:00
["restore-path"] = this . RESTOREFOLDER ,
2020-07-01 16:32:07 -07:00
["dont-compress-restore-paths"] = "true"
};
using ( Controller c = new Controller ( "file://" + this . TARGETFOLDER , restoreOptions , null ))
{
2024-09-28 09:26:00 +02:00
IRestoreResults restoreResults = c . Restore ( new [] { filePath });
2020-07-19 19:51:05 -07:00
Assert . AreEqual ( 0 , restoreResults . Errors . Count ());
2025-01-03 21:38:03 +01:00
// TODO The expected warning is expected, as the 'dont-compress-restore-paths' option results in a warning about a folder not being created before restoring a file.
Assert . AreEqual ( 1 , restoreResults . Warnings . Count ());
2020-07-02 15:39:56 -07:00
}
2020-07-01 16:32:07 -07:00
2024-07-26 00:08:45 -04:00
// We need to strip the root part of the path. Otherwise, Path.Combine will simply return the second argument
2020-07-02 15:39:56 -07:00
// if it's determined to be an absolute path.
string rootString = SystemIO . IO_OS . GetPathRoot ( filePath );
string newPathPart = filePath . Substring ( rootString . Length );
2024-05-30 02:14:05 +02:00
if ( OperatingSystem . IsWindows ())
2020-07-02 15:39:56 -07:00
{
2020-07-02 15:40:06 -07:00
// On Windows, the drive letter is included in the path when the dont-compress-restore-paths option is used.
2020-07-02 15:39:56 -07:00
// The drive letter is assumed to be the first character of the path root (e.g., C:\).
newPathPart = Path . Combine ( rootString . Substring ( 0 , 1 ), filePath . Substring ( rootString . Length ));
2020-07-01 16:32:07 -07:00
}
2020-07-02 15:39:56 -07:00
string restoredFilePath = Path . Combine ( restoreOptions [ "restore-path" ], newPathPart );
Assert . IsTrue ( File . Exists ( restoredFilePath ));
2020-07-01 16:32:07 -07:00
}
2019-12-29 12:11:47 -08:00
[Test]
[Category("RestoreHandler")]
public void RestoreInheritanceBreaks ()
{
2024-05-30 02:14:05 +02:00
if (! OperatingSystem . IsWindows ())
2019-12-29 12:11:47 -08:00
{
return ;
}
2025-05-12 13:07:53 +02:00
var folderPath = Path . Combine ( this . DATAFOLDER , "folder" );
2019-12-29 12:11:47 -08:00
Directory . CreateDirectory ( folderPath );
2025-05-12 13:07:53 +02:00
var filePath = Path . Combine ( folderPath , "file" );
File . WriteAllBytes ( filePath , new byte [] { 0 });
2019-12-29 12:11:47 -08:00
// Protect access rules on the file.
2025-05-12 13:07:53 +02:00
var fileSecurity = new FileInfo ( filePath ). GetAccessControl ();
2019-12-29 12:11:47 -08:00
fileSecurity . SetAccessRuleProtection ( true , true );
2024-03-03 15:41:49 +01:00
new FileInfo ( filePath ). SetAccessControl ( fileSecurity );
2019-12-29 12:11:47 -08:00
2025-05-12 13:07:53 +02:00
var options = new Dictionary < string , string >( this . TestOptions );
using ( var c = new Controller ( "file://" + this . TARGETFOLDER , options , null ))
TestUtils . AssertResults ( c . Backup ( new [] { this . DATAFOLDER }));
2019-12-29 12:11:47 -08:00
// First, restore without restoring permissions.
2025-05-12 13:07:53 +02:00
var restoreOptions = new Dictionary < string , string >( this . TestOptions ) { [ "restore-path" ] = this . RESTOREFOLDER };
using ( var c = new Controller ( "file://" + this . TARGETFOLDER , restoreOptions , null ))
2019-12-29 12:11:47 -08:00
{
2025-05-12 13:07:53 +02:00
TestUtils . AssertResults ( c . Restore ( new [] { filePath }));
2020-07-05 15:00:08 -07:00
2025-05-12 13:07:53 +02:00
var restoredFilePath = Path . Combine ( this . RESTOREFOLDER , "file" );
2019-12-29 12:11:47 -08:00
Assert . IsTrue ( File . Exists ( restoredFilePath ));
2025-05-12 13:07:53 +02:00
var restoredFileSecurity = new FileInfo ( restoredFilePath ). GetAccessControl ();
2019-12-29 12:11:47 -08:00
Assert . IsFalse ( restoredFileSecurity . AreAccessRulesProtected );
2020-07-06 19:31:54 -07:00
// Remove the restored file so that the later restore avoids the "Restore completed
// without errors but no files were restored" warning.
File . Delete ( restoredFilePath );
2019-12-29 12:11:47 -08:00
}
// Restore with restoring permissions.
restoreOptions [ "overwrite" ] = "true" ;
restoreOptions [ "restore-permissions" ] = "true" ;
2025-05-12 13:07:53 +02:00
using ( var c = new Controller ( "file://" + this . TARGETFOLDER , restoreOptions , null ))
2019-12-29 12:11:47 -08:00
{
2025-05-12 13:07:53 +02:00
TestUtils . AssertResults ( c . Restore ( new [] { filePath }));
2020-07-05 15:00:08 -07:00
2025-05-12 13:07:53 +02:00
var restoredFilePath = Path . Combine ( this . RESTOREFOLDER , "file" );
2019-12-29 12:11:47 -08:00
Assert . IsTrue ( File . Exists ( restoredFilePath ));
2025-05-12 13:07:53 +02:00
var restoredFileSecurity = new FileInfo ( restoredFilePath ). GetAccessControl ();
2019-12-29 12:11:47 -08:00
Assert . IsTrue ( restoredFileSecurity . AreAccessRulesProtected );
}
}
2021-08-16 12:58:54 -07:00
2025-08-08 15:45:52 +02:00
[Test]
[Category("RestoreHandler")]
2025-08-10 07:41:02 +02:00
public async System . Threading . Tasks . Task RestoreVolumeCache ([ Values ( "0b" , "1mb" , "5mb" , "1gb" )] string cache_size , [ Values ( "0" , "1" , null )] string? channel_size )
2025-08-08 15:45:52 +02:00
{
var opts = TestOptions ;
opts [ "dblock-size" ] = "1mb" ;
opts [ "blocksize" ] = "1kb" ;
2025-08-14 17:10:56 +02:00
opts [ "restore-volume-cache-hint" ] = cache_size ;
2025-08-08 15:45:52 +02:00
if ( channel_size != null )
opts [ "restore-channel-buffer-size" ] = channel_size ;
opts [ "restore-legacy" ] = "false" ;
opts [ "restore-file-processors" ] = "4" ;
opts [ "restore-volume-decryptors" ] = "4" ;
opts [ "restore-volume-decompressors" ] = "4" ;
opts [ "restore-volume-downloaders" ] = "4" ;
opts [ "restore-path" ] = RESTOREFOLDER ;
// Write a bunch of files
2025-08-10 20:56:05 +02:00
Random rng = new ();
2025-08-08 15:45:52 +02:00
byte [] data = new byte [ 1024 ];
for ( int i = 0 ; i < 1000 ; i ++)
{
rng . NextBytes ( data );
var filePath = Path . Combine ( this . DATAFOLDER , $"file{i}" );
File . WriteAllBytes ( filePath , data );
}
2025-08-10 20:56:05 +02:00
using var c = new Controller ( "file://" + this . TARGETFOLDER , opts , null );
TestUtils . AssertResults ( c . Backup ([ this . DATAFOLDER ]));
2025-08-08 15:45:52 +02:00
// Start a 30 second timeout
var timeout_task = System . Threading . Tasks . Task . Delay ( TimeSpan . FromSeconds ( 30 ));
var restore_task = System . Threading . Tasks . Task . Run (() =>
{
TestUtils . AssertResults ( c . Restore ([ "*" ]));
});
2025-08-10 07:41:02 +02:00
var t = await System . Threading . Tasks . Task . WhenAny ( timeout_task , restore_task );
if ( t == timeout_task )
2025-08-10 20:56:05 +02:00
{
c . Abort ();
await restore_task ; // Ensure we wait for the restore task to complete
2025-08-10 07:41:02 +02:00
Assert . Fail ( "Restore timed out" );
2025-08-10 20:56:05 +02:00
}
2025-08-10 07:41:02 +02:00
else if ( t == restore_task )
// Throw any exceptions it might have
t . GetAwaiter (). GetResult ();
2025-08-08 15:45:52 +02:00
TestUtils . AssertDirectoryTreesAreEquivalent ( this . DATAFOLDER , this . RESTOREFOLDER , true , "Restoring with different volume cache sizes" );
}
2021-08-16 12:58:54 -07:00
[Test]
[Category("RestoreHandler")]
2025-05-12 13:05:26 +02:00
public void RestoreWithoutLocalData ([ Values ( "true" , "false" )] string noLocalDb , [ Values ( "true" , "false" )] string patchWithLocalBlocks )
2021-08-16 12:58:54 -07:00
{
2025-05-12 13:05:26 +02:00
var file1Path = Path . Combine ( this . DATAFOLDER , "file1" );
2021-08-16 12:58:54 -07:00
File . WriteAllBytes ( file1Path , new byte [] { 1 , 2 , 3 });
2025-05-12 13:05:26 +02:00
var file2Path = Path . Combine ( this . DATAFOLDER , "file2" );
2021-08-16 12:58:54 -07:00
File . WriteAllBytes ( file2Path , new byte [] { 3 , 4 , 6 });
2025-05-12 13:05:26 +02:00
var folderPath = Path . Combine ( this . DATAFOLDER , "folder" );
2021-08-16 12:58:54 -07:00
Directory . CreateDirectory ( folderPath );
systemIO . FileCopy ( file1Path , Path . Combine ( folderPath , "file1 copy" ), true );
using ( Controller c = new Controller ( "file://" + this . TARGETFOLDER , new Dictionary < string , string >( this . TestOptions ), null ))
2025-05-12 13:05:26 +02:00
TestUtils . AssertResults ( c . Backup ( new [] { this . DATAFOLDER }));
2021-08-16 12:58:54 -07:00
2025-05-12 13:05:26 +02:00
var restoreOptions = new Dictionary < string , string >( this . TestOptions )
2021-08-16 12:58:54 -07:00
{
["restore-path"] = this . RESTOREFOLDER ,
["no-local-db"] = noLocalDb ,
2025-05-12 13:05:26 +02:00
["restore-with-local-blocks"] = patchWithLocalBlocks
2021-08-16 12:58:54 -07:00
};
2025-05-12 13:05:26 +02:00
2025-05-12 13:07:53 +02:00
using ( var c = new Controller ( "file://" + this . TARGETFOLDER , restoreOptions , null ))
2025-05-12 13:05:26 +02:00
TestUtils . AssertResults ( c . Restore ( new [] { "*" }));
2021-08-16 12:58:54 -07:00
TestUtils . AssertDirectoryTreesAreEquivalent ( this . DATAFOLDER , this . RESTOREFOLDER , true , "Restoring without local data" );
}
2025-09-01 14:00:49 +02:00
2026-03-14 12:02:53 +01:00
[Test]
[Category("RestoreHandler")]
public void RestoreThreeVersionsWithoutLocalDb ([ Values ( 0 , 1 , 2 )] int version )
{
// Create three versions with different file contents
var file1Path = Path . Combine ( this . DATAFOLDER , "file1.txt" );
var file2Path = Path . Combine ( this . DATAFOLDER , "file2.txt" );
// Version 0 (oldest): Create initial files
File . WriteAllText ( file1Path , "version 0 content" );
File . WriteAllText ( file2Path , "version 0 file2" );
using ( Controller c = new Controller ( "file://" + this . TARGETFOLDER , new Dictionary < string , string >( this . TestOptions ), null ))
TestUtils . AssertResults ( c . Backup ( new [] { this . DATAFOLDER }));
// Version 1: Modify file1, keep file2
File . WriteAllText ( file1Path , "version 1 content" );
using ( Controller c = new Controller ( "file://" + this . TARGETFOLDER , new Dictionary < string , string >( this . TestOptions ), null ))
TestUtils . AssertResults ( c . Backup ( new [] { this . DATAFOLDER }));
// Version 2 (newest): Modify both files
File . WriteAllText ( file1Path , "version 2 content" );
File . WriteAllText ( file2Path , "version 2 file2" );
using ( Controller c = new Controller ( "file://" + this . TARGETFOLDER , new Dictionary < string , string >( this . TestOptions ), null ))
TestUtils . AssertResults ( c . Backup ( new [] { this . DATAFOLDER }));
// Prepare expected content based on version being restored
// Note: In Duplicati, version 0 is the MOST RECENT backup
string expectedFile1Content ;
string expectedFile2Content ;
switch ( version )
{
case 0 : // Most recent (version 2 in our creation order)
expectedFile1Content = "version 2 content" ;
expectedFile2Content = "version 2 file2" ;
break ;
case 1 : // Second most recent (version 1 in our creation order)
expectedFile1Content = "version 1 content" ;
expectedFile2Content = "version 0 file2" ;
break ;
case 2 : // Oldest (version 0 in our creation order)
expectedFile1Content = "version 0 content" ;
expectedFile2Content = "version 0 file2" ;
break ;
default :
throw new ArgumentException ( "Invalid version" );
}
// Restore with --version and --no-local-db
var restoreOptions = new Dictionary < string , string >( this . TestOptions )
{
["restore-path"] = this . RESTOREFOLDER ,
["version"] = version . ToString (),
["no-local-db"] = "true"
};
using ( var c = new Controller ( "file://" + this . TARGETFOLDER , restoreOptions , null ))
TestUtils . AssertResults ( c . Restore ( new [] { "*" }));
// Verify restored files match the expected version
var restoredFile1Path = Path . Combine ( this . RESTOREFOLDER , "file1.txt" );
var restoredFile2Path = Path . Combine ( this . RESTOREFOLDER , "file2.txt" );
Assert . IsTrue ( File . Exists ( restoredFile1Path ), "Restored file1 should exist" );
Assert . IsTrue ( File . Exists ( restoredFile2Path ), "Restored file2 should exist" );
Assert . AreEqual ( expectedFile1Content , File . ReadAllText ( restoredFile1Path ), $"File1 content mismatch for version {version}" );
Assert . AreEqual ( expectedFile2Content , File . ReadAllText ( restoredFile2Path ), $"File2 content mismatch for version {version}" );
}
2025-09-01 14:00:49 +02:00
[Test]
[Category("RestoreHandler")]
public void RestoreOtherProcessIsUsingFile ()
{
var file1Path = Path . Combine ( this . DATAFOLDER , "file1" );
byte [] original_contents = [ 1 , 2 , 3 ];
File . WriteAllBytes ( file1Path , original_contents );
var opts = new Dictionary < string , string >( this . TestOptions );
opts [ "overwrite" ] = "true" ;
using var c = new Controller ( "file://" + this . TARGETFOLDER , opts , null );
var res_backup = c . Backup ([ this . DATAFOLDER ]);
TestUtils . AssertResults ( res_backup );
File . WriteAllBytes ( file1Path , [ 4 , 5 , 6 ]);
2025-09-30 15:23:58 +02:00
2025-09-01 14:00:49 +02:00
using ( var fs = new FileStream ( file1Path , FileMode . Open , FileAccess . Read , FileShare . None ))
2025-09-30 15:23:58 +02:00
{
var res_failing = c . Restore ([ "*" ]);
2025-09-30 15:23:50 +02:00
Assert . AreEqual ( 4 , res_failing . Errors . Count ());
2025-09-30 15:23:58 +02:00
var first_error = res_failing . Errors . First ();
Assert . IsTrue (
first_error . Contains ( "IOException: The process cannot access the file" )
&&
first_error . EndsWith ( " because it is being used by another process." )
);
2025-09-01 14:00:49 +02:00
}
var res_restore = c . Restore ([ "*" ]);
TestUtils . AssertResults ( res_restore );
Assert . AreEqual ( original_contents , File . ReadAllBytes ( file1Path ));
}
2019-12-29 12:11:47 -08:00
}
}