Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions Sources/libhostmgr/Platforms/VMManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions Tests/libhostmgrTests/Platforms/VMManagerTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}