diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index 8e84cb09..f796bc23 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -196,10 +196,12 @@ public struct Service: Codable, Hashable { ports = try container.decodeIfPresent([String].self, forKey: .ports) // Decode 'command' which can be either a single string or an array of strings. + // Per Compose semantics the string form is shell-lexed into argv + // (e.g. `sh -c "npm install"` -> ["sh", "-c", "npm install"]), not passed as one argument. if let cmdArray = try? container.decodeIfPresent([String].self, forKey: .command) { command = cmdArray } else if let cmdString = try? container.decodeIfPresent(String.self, forKey: .command) { - command = [cmdString] + command = shellLex(cmdString) } else { command = nil } @@ -222,10 +224,11 @@ public struct Service: Codable, Hashable { hostname = try container.decodeIfPresent(String.self, forKey: .hostname) // Decode 'entrypoint' which can be either a single string or an array of strings. + // The string form is shell-lexed into argv, matching Compose semantics. if let entrypointArray = try? container.decodeIfPresent([String].self, forKey: .entrypoint) { entrypoint = entrypointArray } else if let entrypointString = try? container.decodeIfPresent(String.self, forKey: .entrypoint) { - entrypoint = [entrypointString] + entrypoint = shellLex(entrypointString) } else { entrypoint = nil } diff --git a/Sources/Container-Compose/Helper Functions.swift b/Sources/Container-Compose/Helper Functions.swift index 371d135b..4a5c8790 100644 --- a/Sources/Container-Compose/Helper Functions.swift +++ b/Sources/Container-Compose/Helper Functions.swift @@ -110,6 +110,57 @@ public func deriveProjectName(cwd: String) -> String { return projectName } +/// Splits a shell-style command string into arguments: whitespace separation, +/// single/double quotes, and backslash escapes. No expansion is performed — +/// this matches how Docker Compose parses string-form `command:` and +/// `entrypoint:` (shellwords), e.g. +/// `sh -c "npm install && npm run build"` -> ["sh", "-c", "npm install && npm run build"]. +func shellLex(_ input: String) -> [String] { + var args: [String] = [] + var current = "" + var hasToken = false + var inSingleQuotes = false + var inDoubleQuotes = false + var escaped = false + + for character in input { + if escaped { + current.append(character) + escaped = false + hasToken = true + continue + } + if character == "\\" && !inSingleQuotes { + escaped = true + continue + } + if character == "'" && !inDoubleQuotes { + inSingleQuotes.toggle() + hasToken = true + continue + } + if character == "\"" && !inSingleQuotes { + inDoubleQuotes.toggle() + hasToken = true + continue + } + if character.isWhitespace && !inSingleQuotes && !inDoubleQuotes { + if hasToken { + args.append(current) + current = "" + hasToken = false + } + continue + } + current.append(character) + hasToken = true + } + if hasToken { + args.append(current) + } + return args +} + /// Converts Docker Compose port specification into a container run -p format. /// Handles various formats: "PORT", "HOST:PORT", "IP:HOST:PORT", and optional protocol. /// - Parameter portSpec: The port specification string from docker-compose.yml. diff --git a/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift b/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift index 1418a214..e65bc527 100644 --- a/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift +++ b/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift @@ -242,7 +242,7 @@ struct DockerComposeParsingTests { #expect(compose.services["app"]??.command?.first == "sh") } - @Test("Parse compose with command as string") + @Test("Parse compose with command as string is shell-lexed") func parseComposeWithCommandString() throws { let yaml = """ version: '3.8' @@ -251,12 +251,31 @@ struct DockerComposeParsingTests { image: alpine:latest command: "echo hello" """ - + let decoder = YAMLDecoder() let compose = try decoder.decode(DockerCompose.self, from: yaml) - - #expect(compose.services["app"]??.command?.count == 1) - #expect(compose.services["app"]??.command?.first == "echo hello") + + #expect(compose.services["app"]??.command == ["echo", "hello"]) + } + + @Test("Parse compose with quoted string command preserves quoted argument") + func parseComposeWithQuotedStringCommand() throws { + // Regression: the whole string was passed as a single argument, + // producing 'Cannot find module .../sh -c "npm install && npm run build:watch"'. + let yaml = """ + version: '3.8' + services: + app: + image: node:18-alpine + command: sh -c "npm install && npm run build:watch" + entrypoint: /bin/sh -c + """ + + let decoder = YAMLDecoder() + let compose = try decoder.decode(DockerCompose.self, from: yaml) + + #expect(compose.services["app"]??.command == ["sh", "-c", "npm install && npm run build:watch"]) + #expect(compose.services["app"]??.entrypoint == ["/bin/sh", "-c"]) } @Test("Parse compose with restart policy") diff --git a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift index be2ec08f..35b1a548 100644 --- a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift +++ b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift @@ -98,6 +98,36 @@ struct HelperFunctionsTests { #expect(result == "0.0.0.0:3000:3000") } + @Test("Shell lex - plain words") + func testShellLexPlainWords() throws { + #expect(shellLex("echo hello world") == ["echo", "hello", "world"]) + } + + @Test("Shell lex - double-quoted argument kept as one token") + func testShellLexDoubleQuotes() throws { + #expect(shellLex("sh -c \"npm install && npm run build:watch\"") == ["sh", "-c", "npm install && npm run build:watch"]) + } + + @Test("Shell lex - single-quoted argument kept as one token") + func testShellLexSingleQuotes() throws { + #expect(shellLex("sh -c 'echo \"nested\" done'") == ["sh", "-c", "echo \"nested\" done"]) + } + + @Test("Shell lex - escaped space joins token") + func testShellLexEscapedSpace() throws { + #expect(shellLex("cat my\\ file.txt") == ["cat", "my file.txt"]) + } + + @Test("Shell lex - empty quoted string produces empty token") + func testShellLexEmptyQuotes() throws { + #expect(shellLex("env VAR= \"\"") == ["env", "VAR=", ""]) + } + + @Test("Shell lex - collapses repeated whitespace") + func testShellLexWhitespaceRuns() throws { + #expect(shellLex(" npm run dev ") == ["npm", "run", "dev"]) + } + } /// Trait that creates a unique temporary directory before a test runs and removes it after.