diff --git a/Sources/ContainerCommands/Image/ImageSave.swift b/Sources/ContainerCommands/Image/ImageSave.swift index ee886846..c9a1fb99 100644 --- a/Sources/ContainerCommands/Image/ImageSave.swift +++ b/Sources/ContainerCommands/Image/ImageSave.swift @@ -140,7 +140,15 @@ extension Application { progress.finish() for reference in references { - print(reference) + if output == nil { + // stdout is carrying the OCI archive in this branch, so the + // saved-reference list goes to stderr via the logger. Printing + // it to stdout appends non-archive bytes after the tar EOF and + // corrupts the stream for redirection and pipelines (#1801). + log.info("\(reference)") + } else { + print(reference) + } } } } diff --git a/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift index 2b6ef908..32a30818 100644 --- a/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift +++ b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift @@ -266,6 +266,44 @@ class TestCLIImagesCommand: CLITest { } } + @Test func testImageSaveToStdoutProducesCleanArchive() throws { + do { + // 1. pull and tag an image to save + try doPull(imageName: alpine) + let alpineRef: Reference = try Reference.parse(alpine) + let alpineTagged = "\(alpineRef.name):testImageSaveToStdout" + try doImageTag(image: alpine, newName: alpineTagged) + defer { + try? doRemoveImages(images: [alpineTagged]) + } + + // 2. save to stdout (no --output): stdout is the archive stream + let saveArgs = [ + "image", + "save", + alpineTagged, + ] + let (outputData, _, error, status) = try run(arguments: saveArgs) + if status != 0 { + throw CLIError.executionFailed("save to stdout failed: \(error)") + } + + // 3. The archive on stdout must end at the tar EOF marker (two + // 512-byte zero blocks). With the bug, the saved-reference list + // is printed to stdout after the archive, so the trailing bytes + // are reference text rather than the tar EOF zeros (#1801). + #expect(outputData.count >= 1024, "stdout archive is too small to contain a tar EOF marker") + let trailer = outputData.suffix(1024) + #expect(trailer.allSatisfy { $0 == 0 }, "stdout archive has trailing non-archive bytes after the tar EOF marker") + + // 4. The saved-reference list is still surfaced, on stderr. + #expect(error.contains(alpineTagged), "expected the saved image reference on stderr") + } catch { + Issue.record("failed to save image to stdout \(error)") + return + } + } + @Test func testImageSaveMissingPlatform() throws { do { // 1. pull image