diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/LZFSE.swift b/Packages/ContainerizationEngine/Sources/DoryHV/LZFSE.swift new file mode 100644 index 00000000..933ae5c3 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/LZFSE.swift @@ -0,0 +1,130 @@ +import Compression +import Foundation + +public enum LZFSEError: Error, CustomStringConvertible { + case openInput(String) + case openOutput(String) + case streamInit + case read + case write + case process + + public var description: String { + switch self { + case .openInput(let path): "cannot open input \(path)" + case .openOutput(let path): "cannot open output \(path)" + case .streamInit: "compression_stream_init failed" + case .read: "read failed" + case .write: "write failed" + case .process: "compression_stream_process failed" + } + } +} + +/// Streaming LZFSE codec over Apple's Compression framework. Release assembly uses this exact +/// implementation to compress guest payloads, while the installed app uses the same format when it +/// expands those payloads on first launch. Keeping the codec in the signed runner avoids an ambient +/// Homebrew or system-tool dependency in the public release path. +public enum LZFSE { + private static let chunk = 1 << 20 + + public static func compress(source: String, destination: String) throws { + try transform(source: source, destination: destination, operation: COMPRESSION_STREAM_ENCODE) + } + + public static func decompress(source: String, destination: String) throws { + try transform(source: source, destination: destination, operation: COMPRESSION_STREAM_DECODE) + } + + private static func transform( + source: String, + destination: String, + operation: compression_stream_operation + ) throws { + guard FileManager.default.isReadableFile(atPath: source), + let input = InputStream(fileAtPath: source) else { + throw LZFSEError.openInput(source) + } + guard let output = OutputStream(toFileAtPath: destination, append: false) else { + throw LZFSEError.openOutput(destination) + } + input.open() + output.open() + defer { + input.close() + output.close() + } + + let sourceBuffer = UnsafeMutablePointer.allocate(capacity: chunk) + let destinationBuffer = UnsafeMutablePointer.allocate(capacity: chunk) + defer { + sourceBuffer.deallocate() + destinationBuffer.deallocate() + } + + var stream = compression_stream( + dst_ptr: destinationBuffer, + dst_size: chunk, + src_ptr: UnsafePointer(sourceBuffer), + src_size: 0, + state: nil + ) + guard compression_stream_init(&stream, operation, COMPRESSION_LZFSE) + == COMPRESSION_STATUS_OK else { + throw LZFSEError.streamInit + } + defer { compression_stream_destroy(&stream) } + + // compression_stream_init resets the caller-owned source/destination fields. + stream.src_ptr = UnsafePointer(sourceBuffer) + stream.src_size = 0 + stream.dst_ptr = destinationBuffer + stream.dst_size = chunk + + var inputExhausted = false + while true { + if stream.src_size == 0, !inputExhausted { + let read = input.read(sourceBuffer, maxLength: chunk) + if read < 0 { throw LZFSEError.read } + if read == 0 { inputExhausted = true } + stream.src_ptr = UnsafePointer(sourceBuffer) + stream.src_size = read + } + + let flags = inputExhausted ? Int32(COMPRESSION_STREAM_FINALIZE.rawValue) : 0 + let status = compression_stream_process(&stream, flags) + guard status == COMPRESSION_STATUS_OK || status == COMPRESSION_STATUS_END else { + throw LZFSEError.process + } + + // Compression.framework owns the partially filled destination buffer while it returns + // OK. Publish and reset that buffer only once it is full, or publish the final partial + // buffer when the stream ends. Resetting a partially filled buffer between OK calls + // produces a malformed stream once a payload crosses the input chunk boundary. + let produced: Int + switch status { + case COMPRESSION_STATUS_OK where stream.dst_size == 0: + produced = chunk + case COMPRESSION_STATUS_END: + produced = chunk - stream.dst_size + default: + produced = 0 + } + var offset = 0 + while offset < produced { + let written = output.write( + destinationBuffer + offset, + maxLength: produced - offset + ) + if written <= 0 { throw LZFSEError.write } + offset += written + } + if status == COMPRESSION_STATUS_OK, stream.dst_size == 0 { + stream.dst_ptr = destinationBuffer + stream.dst_size = chunk + } + + if status == COMPRESSION_STATUS_END { return } + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift index 2deb95c7..dea163af 100644 --- a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift +++ b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift @@ -223,10 +223,28 @@ do { fail("application launch authority handoff failed: \(error)") } guard let command = arguments.first else { - fail("usage: dory-hv [options]") + fail("usage: dory-hv [options]") } switch command { +case "lzfse": + let operation = arguments.dropFirst().first + let paths = Array(arguments.dropFirst(2)) + guard let operation, paths.count == 2 else { + fail("usage: dory-hv lzfse ") + } + do { + switch operation { + case "compress": + try LZFSE.compress(source: paths[0], destination: paths[1]) + case "decompress": + try LZFSE.decompress(source: paths[0], destination: paths[1]) + default: + fail("usage: dory-hv lzfse ") + } + } catch { + fail("lzfse \(operation) failed: \(error)") + } case "data-drive": guard arguments.count >= 2 else { fail("usage: dory-hv data-drive [paths]") diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/LZFSETests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/LZFSETests.swift new file mode 100644 index 00000000..a6118372 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/LZFSETests.swift @@ -0,0 +1,38 @@ +import Foundation +import XCTest +@testable import DoryHV + +final class LZFSETests: XCTestCase { + func testRoundTripAcrossMultipleStreamChunks() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + + let input = directory.appendingPathComponent("input.bin") + let compressed = directory.appendingPathComponent("input.bin.lzfse") + let restored = directory.appendingPathComponent("restored.bin") + let bytes = Data((0..<(3 * 1_048_576 + 257)).map { UInt8(truncatingIfNeeded: $0) }) + try bytes.write(to: input) + + try LZFSE.compress(source: input.path, destination: compressed.path) + try LZFSE.decompress(source: compressed.path, destination: restored.path) + + XCTAssertEqual(try Data(contentsOf: restored), bytes) + } + + func testMissingInputReportsItsPath() { + let missing = "/tmp/dory-lzfse-missing-\(UUID().uuidString)" + XCTAssertThrowsError( + try LZFSE.compress(source: missing, destination: "\(missing).lzfse") + ) { error in + guard case .openInput(let path) = error as? LZFSEError else { + return XCTFail("unexpected error: \(error)") + } + XCTAssertEqual(path, missing) + } + } +}