diff --git a/Sources/ContainerCommands/Container/ContainerCopy.swift b/Sources/ContainerCommands/Container/ContainerCopy.swift index 83023316e..7138f16f3 100644 --- a/Sources/ContainerCommands/Container/ContainerCopy.swift +++ b/Sources/ContainerCommands/Container/ContainerCopy.swift @@ -24,21 +24,27 @@ import SystemPackage extension Application { public struct ContainerCopy: AsyncLoggableCommand { - enum PathRef { + enum PathRef: Equatable { case local(String) case container(id: String, path: String) } static func parsePathRef(_ ref: String) throws -> PathRef { - let parts = ref.components(separatedBy: ":") - switch parts.count { - case 1: + // A container reference is ":". The id never + // contains a path separator, but the path may itself contain ':' + // (e.g. an ISO-8601 timestamp in a filename), so split on the FIRST + // ':' only rather than on every colon. If the text before the first + // ':' contains '/', the whole ref is a local path that happens to + // contain a colon (e.g. "/home/2026-01-01T00:00:00.log"). + guard let colon = ref.firstIndex(of: ":"), !ref[.. Application.ContainerCopy.PathRef { + try Application.ContainerCopy.parsePathRef(ref) + } + + @Test("a path without a colon is a local path") + func localNoColon() throws { + #expect(try parse("/tmp/file.txt") == .local("/tmp/file.txt")) + } + + @Test("an id followed by an absolute path is a container reference") + func containerRef() throws { + #expect(try parse("box:/tmp/file.txt") == .container(id: "box", path: "/tmp/file.txt")) + } + + @Test("a container path containing colons is preserved") + func containerPathWithColons() throws { + // Regression: an ISO-8601 timestamp filename was rejected as an + // "invalid path" because the reference was split on every colon + // instead of only the first. + #expect( + try parse("box:/var/log/app-2026-07-20T10:30:00.log") + == .container(id: "box", path: "/var/log/app-2026-07-20T10:30:00.log") + ) + } + + @Test("a local path containing colons is a local path") + func localPathWithColons() throws { + #expect( + try parse("/home/user/2026-07-20T10:30:00.log") + == .local("/home/user/2026-07-20T10:30:00.log") + ) + } + + @Test("an empty container id is rejected") + func emptyId() { + #expect(throws: (any Error).self) { try parse(":/tmp/x") } + } + + @Test("a non-absolute container path is rejected") + func relativeContainerPath() { + #expect(throws: (any Error).self) { try parse("box:relative/path") } + } +}