5757@ RunWith (MockitoJUnitRunner .class )
5858public class LibvirtRestoreBackupCommandWrapperTest {
5959
60+ /** Trimmed {@code qemu-img info --output=json} of a plain qcow2: no top-level "encrypted" key. */
61+ private static final String PLAIN_QCOW2_INFO = "{\" virtual-size\" : 1048576, \" filename\" : \" volume-123\" , \" format\" : \" qcow2\" }" ;
62+ /** Same for a LUKS-encrypted qcow2 written by nasbackup.sh -e. */
63+ private static final String ENCRYPTED_QCOW2_INFO = "{\" virtual-size\" : 1048576, \" filename\" : \" volume-123\" , \" format\" : \" qcow2\" , \" encrypted\" : true}" ;
64+
6065 private LibvirtRestoreBackupCommandWrapper wrapper ;
6166 private LibvirtComputingResource libvirtComputingResource ;
6267 private RestoreBackupCommand command ;
@@ -96,7 +101,7 @@ public void testExecuteWithVmExistsNull() throws Exception {
96101 scriptMock .when (() -> Script .getExecutableAbsolutePath (anyString ()))
97102 .thenAnswer (invocation -> invocation .getArgument (0 ));
98103 scriptMock .when (() -> Script .executeCommand (any (String [].class )))
99- .thenReturn (null );
104+ .thenReturn (PLAIN_QCOW2_INFO );
100105 scriptMock .when (() -> Script .executeCommandForExitValue (any (String [].class )))
101106 .thenReturn (0 );
102107 scriptMock .when (() -> Script .runSimpleBashScriptForExitValue (anyString ()))
@@ -141,7 +146,7 @@ public void testExecuteWithVmExistsTrue() throws Exception {
141146
142147 try (MockedStatic <Script > scriptMock = mockStatic (Script .class )) {
143148 scriptMock .when (() -> Script .executeCommand (any (String [].class )))
144- .thenReturn (null );
149+ .thenReturn (PLAIN_QCOW2_INFO );
145150 scriptMock .when (() -> Script .executeCommandForExitValue (any (String [].class )))
146151 .thenReturn (0 );
147152 scriptMock .when (() -> Script .runSimpleBashScriptForExitValue (anyString ()))
@@ -182,7 +187,7 @@ public void testExecuteWithVmExistsFalse() throws Exception {
182187
183188 try (MockedStatic <Script > scriptMock = mockStatic (Script .class )) {
184189 scriptMock .when (() -> Script .executeCommand (any (String [].class )))
185- .thenReturn (null );
190+ .thenReturn (PLAIN_QCOW2_INFO );
186191 scriptMock .when (() -> Script .executeCommandForExitValue (any (String [].class )))
187192 .thenReturn (0 );
188193 scriptMock .when (() -> Script .runSimpleBashScriptForExitValue (anyString ()))
@@ -230,6 +235,8 @@ public void testExecuteWithCifsMountType() throws Exception {
230235 scriptMock .when (() ->
231236 Script .executePipedCommands (anyList (), anyLong ()))
232237 .thenReturn (new Pair <>(0 , "vda" )); // Current device
238+ // qemu-img info reads the header; a plain qcow2 has no "encrypted" key
239+ scriptMock .when (() -> Script .executeCommand (any (String [].class ))).thenReturn (PLAIN_QCOW2_INFO );
233240
234241 filesMock .when (() -> Files .deleteIfExists (any (Path .class ))).thenReturn (true );
235242
@@ -308,7 +315,7 @@ public void testExecuteWithBackupFileNotFound() throws Exception {
308315
309316 try (MockedStatic <Script > scriptMock = mockStatic (Script .class )) {
310317 scriptMock .when (() -> Script .executeCommand (any (String [].class )))
311- .thenReturn (null );
318+ .thenReturn (PLAIN_QCOW2_INFO );
312319 scriptMock .when (() -> Script .runSimpleBashScriptForExitValue (anyString ()))
313320 .thenAnswer (invocation -> {
314321 String command = invocation .getArgument (0 );
@@ -357,7 +364,7 @@ public void testExecuteWithCorruptBackupFile() throws Exception {
357364
358365 try (MockedStatic <Script > scriptMock = mockStatic (Script .class )) {
359366 scriptMock .when (() -> Script .executeCommand (any (String [].class )))
360- .thenReturn (null );
367+ .thenReturn (PLAIN_QCOW2_INFO );
361368 scriptMock .when (() -> Script .runSimpleBashScriptForExitValue (anyString ()))
362369 .thenAnswer (invocation -> {
363370 String command = invocation .getArgument (0 );
@@ -410,7 +417,7 @@ public void testExecuteWithRsyncFailure() throws Exception {
410417 scriptMock .when (() -> Script .getExecutableAbsolutePath (anyString ()))
411418 .thenAnswer (invocation -> invocation .getArgument (0 ));
412419 scriptMock .when (() -> Script .executeCommand (any (String [].class )))
413- .thenReturn (null );
420+ .thenReturn (PLAIN_QCOW2_INFO );
414421 scriptMock .when (() -> Script .executeCommandForExitValue (anyLong (), any (String [].class )))
415422 .thenAnswer (invocation -> {
416423 if (Arrays .stream (invocation .getArguments ()).map (String ::valueOf ).anyMatch ("rsync" ::equals )) {
@@ -472,7 +479,7 @@ public void testExecuteWithAttachVolumeFailure() throws Exception {
472479 scriptMock .when (() -> Script .getExecutableAbsolutePath (anyString ()))
473480 .thenAnswer (invocation -> invocation .getArgument (0 ));
474481 scriptMock .when (() -> Script .executeCommand (any (String [].class )))
475- .thenReturn (null );
482+ .thenReturn (PLAIN_QCOW2_INFO );
476483 scriptMock .when (() -> Script .executeCommandForExitValue (any (String [].class )))
477484 .thenAnswer (invocation -> {
478485 if (Arrays .stream (invocation .getArguments ()).map (String ::valueOf ).anyMatch ("attach-disk" ::equals )) {
@@ -573,7 +580,7 @@ public void testExecuteWithMultipleVolumes() throws Exception {
573580 scriptMock .when (() -> Script .runSimpleBashScriptForExitValue (anyString ()))
574581 .thenReturn (0 ); // All commands success
575582 scriptMock .when (() -> Script .executeCommand (any (String [].class )))
576- .thenReturn (null );
583+ .thenReturn (PLAIN_QCOW2_INFO );
577584 scriptMock .when (() -> Script .executeCommandForExitValue (any (String [].class )))
578585 .thenReturn (0 ); // All commands success
579586
@@ -642,7 +649,7 @@ public void testEncryptedBackupIsCheckedAndDecryptedWithTheSecret() throws Excep
642649 try (MockedStatic <Script > scriptMock = mockStatic (Script .class )) {
643650 scriptMock .when (() -> Script .runSimpleBashScriptForExitValue (anyString (), anyInt (), any (Boolean .class ))).thenReturn (0 );
644651 scriptMock .when (() -> Script .runSimpleBashScriptForExitValue (anyString ())).thenReturn (0 );
645- scriptMock .when (() -> Script .executeCommand (any (String [].class ))).thenReturn ("{ \" encrypted \" : true}" );
652+ scriptMock .when (() -> Script .executeCommand (any (String [].class ))).thenReturn (ENCRYPTED_QCOW2_INFO );
646653 // mount/umount also run through executeCommandForExitValue (#14006); only qemu-img matters here
647654 scriptMock .when (() -> Script .executeCommandForExitValue (any (String [].class )))
648655 .thenAnswer (inv -> { recordQemuImg (qemuImgCalls , joinArgs (inv .getArguments (), 0 )); return 0 ; });
@@ -663,6 +670,91 @@ public void testEncryptedBackupIsCheckedAndDecryptedWithTheSecret() throws Excep
663670 }
664671 }
665672
673+ private void stubPlainNfsRestoreOfExistingVm () {
674+ when (command .getVmName ()).thenReturn ("test-vm" );
675+ when (command .getBackupPath ()).thenReturn ("backup/path" );
676+ when (command .getBackupRepoAddress ()).thenReturn ("192.168.1.100:/backup" );
677+ when (command .getBackupRepoType ()).thenReturn ("nfs" );
678+ when (command .getMountOptions ()).thenReturn ("rw" );
679+ when (command .isVmExists ()).thenReturn (true );
680+ when (command .getWait ()).thenReturn (60 );
681+ PrimaryDataStoreTO primaryDataStore = Mockito .mock (PrimaryDataStoreTO .class );
682+ when (primaryDataStore .getPoolType ()).thenReturn (Storage .StoragePoolType .NetworkFilesystem );
683+ when (command .getRestoreVolumePools ()).thenReturn (Arrays .asList (primaryDataStore ));
684+ when (command .getRestoreVolumePaths ()).thenReturn (Arrays .asList ("/var/lib/libvirt/images/volume-123" ));
685+ when (command .getBackupVolumesUUIDs ()).thenReturn (Arrays .asList ("volume-123" ));
686+ when (command .getBackupFiles ()).thenReturn (Arrays .asList ("volume-123" ));
687+ when (command .getMountTimeout ()).thenReturn (30 );
688+ }
689+
690+ @ Test
691+ public void testRestoreFailsClosedWhenEncryptionStateCannotBeDetermined () throws Exception {
692+ stubPlainNfsRestoreOfExistingVm ();
693+ List <String > copyCalls = new ArrayList <>();
694+
695+ try (MockedStatic <Files > filesMock = mockStatic (Files .class )) {
696+ Path tempPath = Mockito .mock (Path .class );
697+ when (tempPath .toString ()).thenReturn ("/tmp/csbackup.abc123" );
698+ filesMock .when (() -> Files .createTempDirectory (anyString ())).thenReturn (tempPath );
699+ filesMock .when (() -> Files .deleteIfExists (any (Path .class ))).thenReturn (true );
700+
701+ try (MockedStatic <Script > scriptMock = mockStatic (Script .class )) {
702+ scriptMock .when (() -> Script .getExecutableAbsolutePath (anyString ())).thenAnswer (inv -> inv .getArgument (0 ));
703+ scriptMock .when (() -> Script .runSimpleBashScriptForExitValue (anyString ())).thenReturn (0 );
704+ // qemu-img info failed (missing binary, unreadable file, timeout): Script returns null
705+ scriptMock .when (() -> Script .executeCommand (any (String [].class ))).thenReturn (null );
706+ scriptMock .when (() -> Script .executeCommandForExitValue (anyLong (), any (String [].class )))
707+ .thenAnswer (inv -> { copyCalls .add (joinArgs (inv .getArguments (), 1 )); return 0 ; });
708+ scriptMock .when (() -> Script .executeCommandForExitValue (any (String [].class )))
709+ .thenAnswer (inv -> { copyCalls .add (joinArgs (inv .getArguments (), 0 )); return 0 ; });
710+
711+ BackupAnswer answer = (BackupAnswer ) wrapper .execute (command , libvirtComputingResource );
712+
713+ Assert .assertFalse ("restore must fail when the encryption state is unknown" , answer .getResult ());
714+ Assert .assertTrue (answer .getDetails (), answer .getDetails ().contains ("refusing to restore" ));
715+ Assert .assertTrue ("nothing may be copied onto the volume: " + copyCalls ,
716+ copyCalls .stream ().noneMatch (c -> c .startsWith ("rsync" ) || c .startsWith ("qemu-img convert" )));
717+ }
718+ }
719+ }
720+
721+ @ Test
722+ public void testRestoreAcceptsLeakedClustersAsAPass () throws Exception {
723+ stubPlainNfsRestoreOfExistingVm ();
724+
725+ try (MockedStatic <Files > filesMock = mockStatic (Files .class )) {
726+ Path tempPath = Mockito .mock (Path .class );
727+ when (tempPath .toString ()).thenReturn ("/tmp/csbackup.abc123" );
728+ filesMock .when (() -> Files .createTempDirectory (anyString ())).thenReturn (tempPath );
729+ filesMock .when (() -> Files .deleteIfExists (any (Path .class ))).thenReturn (true );
730+
731+ try (MockedStatic <Script > scriptMock = mockStatic (Script .class )) {
732+ scriptMock .when (() -> Script .getExecutableAbsolutePath (anyString ())).thenAnswer (inv -> inv .getArgument (0 ));
733+ scriptMock .when (() -> Script .executeCommand (any (String [].class ))).thenReturn (PLAIN_QCOW2_INFO );
734+ scriptMock .when (() -> Script .executeCommandForExitValue (anyLong (), any (String [].class ))).thenReturn (0 );
735+ scriptMock .when (() -> Script .executeCommandForExitValue (any (String [].class ))).thenReturn (0 );
736+ scriptMock .when (() -> Script .runSimpleBashScriptForExitValue (anyString ())).thenAnswer (inv -> {
737+ String cmd = inv .getArgument (0 );
738+ if (cmd .startsWith ("qemu-img check" )) {
739+ return 3 ; // leaked clusters only
740+ }
741+ if (cmd .contains ("qemu-img info" ) && cmd .contains ("backing-filename" )) {
742+ return 1 ; // no backing chain: plain full backup
743+ }
744+ return 0 ;
745+ });
746+
747+ BackupAnswer answer = (BackupAnswer ) wrapper .execute (command , libvirtComputingResource );
748+ Assert .assertTrue (answer .getDetails (), answer .getResult ());
749+
750+ scriptMock .when (() -> Script .runSimpleBashScriptForExitValue (Mockito .startsWith ("qemu-img check" ))).thenReturn (2 );
751+ BackupAnswer corrupt = (BackupAnswer ) wrapper .execute (command , libvirtComputingResource );
752+ Assert .assertFalse ("a corrupt backup (exit 2) must still be rejected" , corrupt .getResult ());
753+ Assert .assertTrue (corrupt .getDetails (), corrupt .getDetails ().contains ("is corrupt" ));
754+ }
755+ }
756+ }
757+
666758 private String invokeGetDeviceToAttachDisk (String vmName ) throws Exception {
667759 Method method = LibvirtRestoreBackupCommandWrapper .class .getDeclaredMethod ("getDeviceToAttachDisk" , String .class );
668760 method .setAccessible (true );
@@ -730,7 +822,7 @@ public void testEncryptedBackupWithoutPassphraseFailsClearly() throws Exception
730822 try (MockedStatic <Script > scriptMock = mockStatic (Script .class )) {
731823 scriptMock .when (() -> Script .runSimpleBashScriptForExitValue (anyString (), anyInt (), any (Boolean .class ))).thenReturn (0 );
732824 scriptMock .when (() -> Script .runSimpleBashScriptForExitValue (anyString ())).thenReturn (0 );
733- scriptMock .when (() -> Script .executeCommand (any (String [].class ))).thenReturn ("{ \" encrypted \" : true}" );
825+ scriptMock .when (() -> Script .executeCommand (any (String [].class ))).thenReturn (ENCRYPTED_QCOW2_INFO );
734826 List <String > qemuImgCalls = new ArrayList <>();
735827 scriptMock .when (() -> Script .executeCommandForExitValue (anyLong (), any (String [].class )))
736828 .thenAnswer (inv -> { recordQemuImg (qemuImgCalls , joinArgs (inv .getArguments (), 1 )); return 0 ; });
@@ -907,6 +999,7 @@ public void testUnmountIsBoundedByTheConfiguredTimeout() throws Exception {
907999 return 0 ;
9081000 });
9091001 scriptMock .when (() -> Script .runSimpleBashScriptForExitValue (anyString ())).thenReturn (0 );
1002+ scriptMock .when (() -> Script .executeCommand (any (String [].class ))).thenReturn (PLAIN_QCOW2_INFO );
9101003
9111004 wrapper .execute (command , libvirtComputingResource );
9121005
0 commit comments