diff --git a/Sources/ContainerCommands/Image/ImageLoad.swift b/Sources/ContainerCommands/Image/ImageLoad.swift index a70d127e4..64af770f2 100644 --- a/Sources/ContainerCommands/Image/ImageLoad.swift +++ b/Sources/ContainerCommands/Image/ImageLoad.swift @@ -50,30 +50,25 @@ extension Application { try? FileManager.default.removeItem(at: tempFile) } - // Read from stdin; otherwise read from the input file + // Stage inputs that cannot be reopened by the image service process. let resolvedPath: FilePath if let input { guard FileManager.default.fileExists(atPath: input.string) else { log.error("file does not exist", metadata: ["path": "\(input)"]) Application.exit(withError: ArgumentParser.ExitCode(1)) } - resolvedPath = input - } else { - guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else { - throw ContainerizationError(.internalError, message: "unable to create temporary file") - } - - guard let fileHandle = try? FileHandle(forWritingTo: tempFile) else { - throw ContainerizationError(.internalError, message: "unable to open temporary file for writing") - } - - let bufferSize = 4096 - while true { - let chunk = FileHandle.standardInput.readData(ofLength: bufferSize) - if chunk.isEmpty { break } - fileHandle.write(chunk) + if try Self.shouldStageInput(input) { + let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: input.string)) + defer { + try? fileHandle.close() + } + try Self.copyArchive(from: fileHandle, to: tempFile) + resolvedPath = FilePath(tempFile.path()) + } else { + resolvedPath = input } - try fileHandle.close() + } else { + try Self.copyArchive(from: .standardInput, to: tempFile) resolvedPath = FilePath(tempFile.path()) } @@ -109,5 +104,34 @@ extension Application { print(image.reference) } } + + static func shouldStageInput(_ input: FilePath) throws -> Bool { + if input.string.hasPrefix("/dev/fd/") || input.string == "/dev/stdin" { + return true + } + + let attributes = try FileManager.default.attributesOfItem(atPath: input.string) + return attributes[.type] as? FileAttributeType != .typeRegular + } + + static func copyArchive(from input: FileHandle, to outputURL: URL) throws { + guard FileManager.default.createFile(atPath: outputURL.path(), contents: nil) else { + throw ContainerizationError(.internalError, message: "unable to create temporary file") + } + + guard let output = try? FileHandle(forWritingTo: outputURL) else { + throw ContainerizationError(.internalError, message: "unable to open temporary file for writing") + } + defer { + try? output.close() + } + + let bufferSize = 1024 * 1024 + while true { + let chunk = input.readData(ofLength: bufferSize) + if chunk.isEmpty { break } + output.write(chunk) + } + } } } diff --git a/Tests/ContainerCommandsTests/ImageLoadTests.swift b/Tests/ContainerCommandsTests/ImageLoadTests.swift new file mode 100644 index 000000000..da8427675 --- /dev/null +++ b/Tests/ContainerCommandsTests/ImageLoadTests.swift @@ -0,0 +1,96 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Darwin +import Foundation +import SystemPackage +import Testing + +@testable import ContainerCommands + +struct ImageLoadTests { + @Test + func regularInputDoesNotNeedStaging() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + let input = tempDir.appendingPathComponent("image.tar") + try Data("archive".utf8).write(to: input) + + #expect(try Application.ImageLoad.shouldStageInput(FilePath(input.path)) == false) + } + + @Test + func nonRegularInputNeedsStaging() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + let input = tempDir.appendingPathComponent("image.tar") + try #require(mkfifo(input.path, 0o600) == 0) + + #expect(try Application.ImageLoad.shouldStageInput(FilePath(input.path))) + } + + @Test + func fileDescriptorInputNeedsStaging() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + let input = tempDir.appendingPathComponent("image.tar") + try Data("archive".utf8).write(to: input) + + let inputHandle = try FileHandle(forReadingFrom: input) + defer { + try? inputHandle.close() + } + + let descriptorPath = "/dev/fd/\(inputHandle.fileDescriptor)" + + #expect(try Application.ImageLoad.shouldStageInput(FilePath(descriptorPath))) + } + + @Test + func copyArchivePreservesBytes() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + let input = tempDir.appendingPathComponent("input.tar") + let output = tempDir.appendingPathComponent("output.tar") + let payload = Data((0..<8192).map { UInt8($0 % 251) }) + try payload.write(to: input) + + let inputHandle = try FileHandle(forReadingFrom: input) + defer { + try? inputHandle.close() + } + + try Application.ImageLoad.copyArchive(from: inputHandle, to: output) + + #expect(try Data(contentsOf: output) == payload) + } +}