Skip to content
Open
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
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ let package = Package(
.testTarget(
name: "ContainerCommandsTests",
dependencies: [
.product(name: "SystemPackage", package: "swift-system"),
"ContainerCommands",
"ContainerResource",
]
Expand Down
7 changes: 3 additions & 4 deletions Sources/ContainerCommands/Container/ContainerExport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import ArgumentParser
import ContainerAPIClient
import ContainerizationError
import Foundation
import SystemPackage
import TerminalProgress

extension Application {
Expand All @@ -35,9 +36,7 @@ extension Application {

@Option(
name: .shortAndLong, help: "Pathname for the saved container filesystem (defaults to stdout)", completion: .file(),
transform: { str in
URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false)
})
transform: { str in FilePath.absolute(str).string })
var output: String?

@Argument(help: "container ID")
Expand Down Expand Up @@ -67,7 +66,7 @@ extension Application {
}
try fileHandle.close()
} else {
try FileManager.default.moveItem(at: archive, to: URL(fileURLWithPath: output!))
try FileManager.default.moveItem(atPath: archive.path(), toPath: output!)
}
}
}
Expand Down
31 changes: 31 additions & 0 deletions Sources/ContainerCommands/FilePath+Absolute.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import SystemPackage

extension FilePath {
/// Resolve `str` to an absolute path. If already absolute, returns it lexically normalized.
/// Otherwise resolves against `cwd` (defaults to the current working directory).
static func absolute(
_ str: String,
relativeTo cwd: FilePath = FilePath(FileManager.default.currentDirectoryPath)
) -> FilePath {
let p = FilePath(str)
if p.isAbsolute { return p.lexicallyNormalized() }
return cwd.appending(p.components).lexicallyNormalized()
}
}
8 changes: 1 addition & 7 deletions Sources/ContainerCommands/Image/ImageLoad.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,7 @@ extension Application {

@Option(
name: .shortAndLong, help: "Path to the image tar archive", completion: .file(),
transform: { str in
let path = FilePath(str)
guard path.isRelative else { return path.lexicallyNormalized() }
return FilePath(FileManager.default.currentDirectoryPath)
.pushing(path)
.lexicallyNormalized()
})
transform: { str in FilePath.absolute(str) })
var input: FilePath?

@Flag(name: .shortAndLong, help: "Load images even if the archive contains invalid files")
Expand Down
8 changes: 1 addition & 7 deletions Sources/ContainerCommands/Image/ImageSave.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,7 @@ extension Application {

@Option(
name: .shortAndLong, help: "Pathname for the saved image", completion: .file(),
transform: { str in
let path = FilePath(str)
guard path.isRelative else { return path.lexicallyNormalized() }
return FilePath(FileManager.default.currentDirectoryPath)
.pushing(path)
.lexicallyNormalized()
})
transform: { str in FilePath.absolute(str) })
var output: FilePath?

@Option(
Expand Down
80 changes: 80 additions & 0 deletions Tests/ContainerCommandsTests/FilePathAbsoluteTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
//===----------------------------------------------------------------------===//
// 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 SystemPackage
import Testing

@testable import ContainerCommands

struct FilePathAbsoluteTests {
@Test
func absoluteInputReturnedAsIs() {
let result = FilePath.absolute("/usr/local/bin/tool")
#expect(result.string == "/usr/local/bin/tool")
}

@Test
func absoluteInputWithDotDotNormalized() {
let result = FilePath.absolute("/usr/local/../bin/tool")
#expect(result.string == "/usr/bin/tool")
}

@Test
func relativeInputResolvedAgainstCwd() {
let result = FilePath.absolute("images/foo.tar", relativeTo: FilePath("/tmp"))
#expect(result.string == "/tmp/images/foo.tar")
}

@Test
func relativeInputWithDotDotNormalized() {
let result = FilePath.absolute("../foo.tar", relativeTo: FilePath("/tmp/sub"))
#expect(result.string == "/tmp/foo.tar")
}

@Test
func singleFilenameResolvedAgainstCwd() {
let result = FilePath.absolute("archive.tar", relativeTo: FilePath("/home/user"))
#expect(result.string == "/home/user/archive.tar")
}

@Test
func dotDotPastRootClampsAtRoot() {
// POSIX lexicallyNormalized() clamps excess `..` components at `/`
let result = FilePath.absolute("../../../../../../etc/passwd", relativeTo: FilePath("/tmp"))
#expect(result.string == "/etc/passwd")
}

@Test
func emptyStringInputResolvesToCwd() {
// `--output ""` is silently treated as "use current directory"
let result = FilePath.absolute("", relativeTo: FilePath("/tmp"))
#expect(result.string == "/tmp")
}

@Test
func rootPathPreservedThroughNormalization() {
// An absolute `/` input should survive lexical normalization unchanged
let result = FilePath.absolute("/", relativeTo: FilePath("/tmp"))
#expect(result.string == "/")
}

@Test
func trailingSlashDroppedByNormalization() {
// FilePath drops trailing slashes during lexical normalization
let result = FilePath.absolute("/tmp/", relativeTo: FilePath("/somewhere"))
#expect(result.string == "/tmp")
}
}