Skip to content

Commit fe910bb

Browse files
committed
Use FilePath for CLI image/container path-arg parsing.
- Closes CHAOS-1457. - Adds shared FilePath.absolute(_:relativeTo:) helper. - Sibling of apple#1480 (HostDNSResolver) and apple#1518 (PacketFilter), same pattern.
1 parent caff1e9 commit fe910bb

6 files changed

Lines changed: 117 additions & 18 deletions

File tree

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ let package = Package(
145145
.testTarget(
146146
name: "ContainerCommandsTests",
147147
dependencies: [
148+
.product(name: "SystemPackage", package: "swift-system"),
148149
"ContainerCommands",
149150
"ContainerResource",
150151
]

Sources/ContainerCommands/Container/ContainerExport.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import ArgumentParser
1818
import ContainerAPIClient
1919
import ContainerizationError
2020
import Foundation
21+
import SystemPackage
2122
import TerminalProgress
2223

2324
extension Application {
@@ -35,9 +36,7 @@ extension Application {
3536

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

4342
@Argument(help: "container ID")
@@ -67,7 +66,7 @@ extension Application {
6766
}
6867
try fileHandle.close()
6968
} else {
70-
try FileManager.default.moveItem(at: archive, to: URL(fileURLWithPath: output!))
69+
try FileManager.default.moveItem(atPath: archive.path(), toPath: output!)
7170
}
7271
}
7372
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import Foundation
18+
import SystemPackage
19+
20+
extension FilePath {
21+
/// Resolve `str` to an absolute path. If already absolute, returns it lexically normalized.
22+
/// Otherwise resolves against `cwd` (defaults to the current working directory).
23+
static func absolute(
24+
_ str: String,
25+
relativeTo cwd: FilePath = FilePath(FileManager.default.currentDirectoryPath)
26+
) -> FilePath {
27+
let p = FilePath(str)
28+
if p.isAbsolute { return p.lexicallyNormalized() }
29+
return cwd.appending(p.components).lexicallyNormalized()
30+
}
31+
}

Sources/ContainerCommands/Image/ImageLoad.swift

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,7 @@ extension Application {
3232

3333
@Option(
3434
name: .shortAndLong, help: "Path to the image tar archive", completion: .file(),
35-
transform: { str in
36-
let path = FilePath(str)
37-
guard path.isRelative else { return path.lexicallyNormalized() }
38-
return FilePath(FileManager.default.currentDirectoryPath)
39-
.pushing(path)
40-
.lexicallyNormalized()
41-
})
35+
transform: { str in FilePath.absolute(str) })
4236
var input: FilePath?
4337

4438
@Flag(name: .shortAndLong, help: "Load images even if the archive contains invalid files")

Sources/ContainerCommands/Image/ImageSave.swift

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,7 @@ extension Application {
4747

4848
@Option(
4949
name: .shortAndLong, help: "Pathname for the saved image", completion: .file(),
50-
transform: { str in
51-
let path = FilePath(str)
52-
guard path.isRelative else { return path.lexicallyNormalized() }
53-
return FilePath(FileManager.default.currentDirectoryPath)
54-
.pushing(path)
55-
.lexicallyNormalized()
56-
})
50+
transform: { str in FilePath.absolute(str) })
5751
var output: FilePath?
5852

5953
@Option(
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import SystemPackage
18+
import Testing
19+
20+
@testable import ContainerCommands
21+
22+
struct FilePathAbsoluteTests {
23+
@Test
24+
func absoluteInputReturnedAsIs() {
25+
let result = FilePath.absolute("/usr/local/bin/tool")
26+
#expect(result.string == "/usr/local/bin/tool")
27+
}
28+
29+
@Test
30+
func absoluteInputWithDotDotNormalized() {
31+
let result = FilePath.absolute("/usr/local/../bin/tool")
32+
#expect(result.string == "/usr/bin/tool")
33+
}
34+
35+
@Test
36+
func relativeInputResolvedAgainstCwd() {
37+
let result = FilePath.absolute("images/foo.tar", relativeTo: FilePath("/tmp"))
38+
#expect(result.string == "/tmp/images/foo.tar")
39+
}
40+
41+
@Test
42+
func relativeInputWithDotDotNormalized() {
43+
let result = FilePath.absolute("../foo.tar", relativeTo: FilePath("/tmp/sub"))
44+
#expect(result.string == "/tmp/foo.tar")
45+
}
46+
47+
@Test
48+
func singleFilenameResolvedAgainstCwd() {
49+
let result = FilePath.absolute("archive.tar", relativeTo: FilePath("/home/user"))
50+
#expect(result.string == "/home/user/archive.tar")
51+
}
52+
53+
@Test
54+
func dotDotPastRootClampsAtRoot() {
55+
// POSIX lexicallyNormalized() clamps excess `..` components at `/`
56+
let result = FilePath.absolute("../../../../../../etc/passwd", relativeTo: FilePath("/tmp"))
57+
#expect(result.string == "/etc/passwd")
58+
}
59+
60+
@Test
61+
func emptyStringInputResolvesToCwd() {
62+
// `--output ""` is silently treated as "use current directory"
63+
let result = FilePath.absolute("", relativeTo: FilePath("/tmp"))
64+
#expect(result.string == "/tmp")
65+
}
66+
67+
@Test
68+
func rootPathPreservedThroughNormalization() {
69+
// An absolute `/` input should survive lexical normalization unchanged
70+
let result = FilePath.absolute("/", relativeTo: FilePath("/tmp"))
71+
#expect(result.string == "/")
72+
}
73+
74+
@Test
75+
func trailingSlashDroppedByNormalization() {
76+
// FilePath drops trailing slashes during lexical normalization
77+
let result = FilePath.absolute("/tmp/", relativeTo: FilePath("/somewhere"))
78+
#expect(result.string == "/tmp")
79+
}
80+
}

0 commit comments

Comments
 (0)