Route container image save reference list to stderr in stdout mode (#1804)

- Fixes #1801.
- When `container image save` runs without `--output`,
  stdout carries the OCI tar archive. The command writes 
  the archive bytes to stdout and then `print(reference)`s
  each saved image reference to stdout afterward,
  appending non-archive text after the tar EOF marker,
  which will cause strict tar/OCI consumers to fail.
- This routes the saved-reference list to stderr in the
  no-`--output` branch, so stdout contains only archive
  bytes. When saving to a file via `--output`, stdout is
  free, so the references continue to print to stdout
  exactly as before.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
John Costa
2026-06-26 10:04:30 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 27acdbe2f8
commit aa7fef3d1d
2 changed files with 47 additions and 1 deletions
@@ -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)
}
}
}
}
@@ -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