diff --git a/Sources/libhostmgr/Platforms/VMManager.swift b/Sources/libhostmgr/Platforms/VMManager.swift index e00352d9..aabfce7e 100644 --- a/Sources/libhostmgr/Platforms/VMManager.swift +++ b/Sources/libhostmgr/Platforms/VMManager.swift @@ -55,15 +55,32 @@ public struct VMManager { /// Unpack a packaged VM /// /// This method expects that the packaged VM is located in the `vm-images` directory – referencing it by name - /// will attempt to unpack the VM at that location. If there is no packaged VM at that location, this method will - /// emit an error. + /// will attempt to unpack the VM at that location. + /// - If there is no packaged VM at that location, this method will emit an error. + /// - If an extracted VM already exists at the final destination path, this method will emit an error. + /// - If an error occurs, this method will attempt to clean up any extracted files. public func unpackVM(name: String) async throws { + let finalDestination = Paths.toVMTemplate(named: name) + + guard !FileManager.default.fileExists(atPath: finalDestination.path) else { + throw CocoaError(.fileWriteFileExists) + } + + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("hostmgr-vm-unpack-\(name)-\(UUID().uuidString)") + + defer { + try? FileManager.default.removeItem(at: tempDirectory) + } + try Compressor.decompress( archiveAt: Paths.toArchivedVM(named: name), - to: Paths.toVMTemplate(named: name) + to: tempDirectory ) - try VMTemplate(at: Paths.toVMTemplate(named: name)).validate() + try VMTemplate(at: tempDirectory).validate() + + try FileManager.default.moveItem(at: tempDirectory, to: finalDestination) } /// Package a VM for use on other machines diff --git a/Tests/libhostmgrTests/Platforms/VMManagerTests.swift b/Tests/libhostmgrTests/Platforms/VMManagerTests.swift new file mode 100644 index 00000000..9efc805c --- /dev/null +++ b/Tests/libhostmgrTests/Platforms/VMManagerTests.swift @@ -0,0 +1,92 @@ +import XCTest +import Foundation +@testable import libhostmgr + +final class VMManagerTests: XCTestCase { + + /// Test that unpackVM throws the correct error when a VM already exists at the destination + func testUnpackVMThrowsWhenVMAlreadyExists() async throws { + let vmName = "test-vm" + let expectedVMPath = Paths.toVMTemplate(named: vmName) + + // Create the destination directory to simulate an existing VM + try FileManager.default.createDirectory(at: expectedVMPath, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: expectedVMPath) + } + + let vmManager = VMManager() + + do { + try await vmManager.unpackVM(name: vmName) + XCTFail("Expected CocoaError.fileWriteFileExists to be thrown") + } catch let error as CocoaError { + XCTAssertEqual(error.code, CocoaError.fileWriteFileExists) + } + } + + /// Test that unpackVM throws an error when the source archive doesn't exist + func testUnpackVMThrowsWhenArchiveDoesNotExist() async throws { + let vmName = "non-existent-vm" + let vmManager = VMManager() + + do { + try await vmManager.unpackVM(name: vmName) + XCTFail("Expected error when archive does not exist") + } catch { + XCTAssertTrue(true, "Should throw error for missing archive") + } + } + + /// Test that temporary directories are properly cleaned up when unpackVM fails + func testUnpackVMCleansUpTempDirectoryOnFailure() async throws { + let vmName = "test-vm-cleanup" + let vmManager = VMManager() + + do { + try await vmManager.unpackVM(name: vmName) + XCTFail("Expected unpack to fail due to missing archive") + } catch { + // Verify that no temporary directories remain after failure + let tempDirs = try FileManager.default.contentsOfDirectory( + at: FileManager.default.temporaryDirectory, + includingPropertiesForKeys: nil + ).filter { $0.lastPathComponent.contains("hostmgr-vm-unpack-\(vmName)") } + + XCTAssertTrue(tempDirs.isEmpty, "Temporary directories should be cleaned up on failure") + } + } + + /// Test that when unpacking an invalid archive fails, no folder is left at the final destination + func testUnpackVMDoesNotLeaveDestinationFolderOnInvalidArchive() async throws { + let vmName = "test-invalid-archive" + let archivePath = Paths.toArchivedVM(named: vmName) + let finalDestination = Paths.toVMTemplate(named: vmName) + + // Create an invalid archive (just a text file instead of proper .aar format) + try FileManager.default.createDirectory( + at: archivePath.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try "invalid archive content".write(to: archivePath, atomically: true, encoding: .utf8) + + defer { + try? FileManager.default.removeItem(at: archivePath.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: finalDestination) + } + + let vmManager = VMManager() + + // Verify destination doesn't exist before attempting unpack + XCTAssertFalse(FileManager.default.fileExists(atPath: finalDestination.path)) + + do { + try await vmManager.unpackVM(name: vmName) + XCTFail("Expected unpack to fail with invalid archive") + } catch { + // After failure, verify no folder was left at the final destination + XCTAssertFalse(FileManager.default.fileExists(atPath: finalDestination.path), + "Final destination should not exist after failed unpack") + } + } +}