diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java index 7f2a3c28cd14..9c9806bdc77c 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java @@ -211,6 +211,7 @@ private String mountBackupDirectory(String backupRepoAddress, String backupRepoT mountCmd.add(backupRepoType); mountCmd.add(backupRepoAddress); mountCmd.add(mountDirectory); + mountOptions = normalizeMountOptions(mountOptions); if ("cifs".equals(backupRepoType)) { if (StringUtils.isBlank(mountOptions)) { mountOptions = "nobrl"; @@ -230,6 +231,31 @@ private String mountBackupDirectory(String backupRepoAddress, String backupRepoT return mountDirectory; } + /** + * Removes the whitespace around each option of a comma-separated mount option list. + * + * mount(8) is handed the list as a single argument and libmount splits it on commas only, so a + * blank next to a comma, or at either end of the list, stays glued to the neighbouring option + * and is rejected as part of its name or value. + * + * Only the whitespace around the delimiters is removed. The option text itself is passed + * through unchanged, as rejecting unsafe or malformed options belongs to the API layer, not + * to the agent. + */ + protected static String normalizeMountOptions(String mountOptions) { + if (StringUtils.isBlank(mountOptions)) { + return mountOptions; + } + List options = new ArrayList<>(); + for (String option : mountOptions.split(",")) { + String trimmedOption = option.trim(); + if (!trimmedOption.isEmpty()) { + options.add(trimmedOption); + } + } + return String.join(",", options); + } + private void unmountBackupDirectory(String backupDirectory) { try { String umountPath = Script.getExecutableAbsolutePath("umount"); diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapperTest.java index 4bbd040b0d1b..ca7c66f33ffc 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapperTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapperTest.java @@ -703,4 +703,79 @@ public void testAttachVolumePassesRbdXmlThroughAFile() throws Exception { Assert.assertFalse(args.stream().anyMatch(arg -> arg.contains("EOF"))); Assert.assertTrue(args.get(args.size() - 1).endsWith(".xml")); } + + private String[] captureMountCommand(String backupRepoType, String mountOptions) throws Exception { + Method method = LibvirtRestoreBackupCommandWrapper.class.getDeclaredMethod("mountBackupDirectory", + String.class, String.class, String.class, Integer.class); + method.setAccessible(true); + + final String[][] captured = new String[1][]; + try (MockedStatic filesMock = mockStatic(Files.class)) { + Path tempPath = Mockito.mock(Path.class); + when(tempPath.toString()).thenReturn("/tmp/csbackup.abc123"); + filesMock.when(() -> Files.createTempDirectory(anyString())).thenReturn(tempPath); + + try (MockedStatic