Skip to content

Commit 8c426b0

Browse files
[windows] fix trailing backslash string quoting (swiftlang#555)
1 parent 277ddeb commit 8c426b0

3 files changed

Lines changed: 56 additions & 24 deletions

File tree

.github/workflows/pull_request.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
windows_swift_versions: '["nightly-main"]'
2525
windows_build_command: 'Invoke-Program swift build'
2626
enable_android_sdk_build: true
27-
android_sdk_build_command: "swift build --build-tests"
27+
android_sdk_build_command: "swift build --build-tests --build-system native"
2828
android_ndk_versions: '["r27d", "r29"]'
2929
enable_ios_checks: true
3030
enable_macos_checks: true

Sources/TSCBasic/StringConversions.swift

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -50,36 +50,53 @@ extension String {
5050
public func spm_shellEscaped() -> String {
5151

5252
// If all the characters in the string are in the allow list then no need to escape.
53-
guard let pos = utf8.firstIndex(where: { !inShellAllowlist($0) }) else {
53+
guard utf8.contains(where: { !inShellAllowlist($0) }) else {
5454
return self
5555
}
5656

57-
#if os(Windows)
58-
let quoteCharacter: Character = "\""
59-
let escapedQuoteCharacter = "\"\""
60-
#else
61-
let quoteCharacter: Character = "'"
62-
let escapedQuoteCharacter = "'\\''"
63-
#endif
64-
// If there are no quote characters then we can just wrap the string within the quotes.
65-
guard let quotePos = utf8[pos...].firstIndex(of: quoteCharacter.asciiValue!) else {
66-
return String(quoteCharacter) + self + String(quoteCharacter)
67-
}
57+
#if os(Windows)
58+
var quoted = "\""
59+
var unquoted = self.unicodeScalars
60+
while !unquoted.isEmpty {
61+
guard let firstNonBackslash = unquoted.firstIndex(where: { $0 != "\\" }) else {
62+
let backslashCount = unquoted.count
63+
quoted.append(String(repeating: "\\", count: backslashCount * 2))
64+
break
65+
}
66+
let backslashCount = unquoted.distance(
67+
from: unquoted.startIndex, to: firstNonBackslash)
68+
if unquoted[firstNonBackslash] == "\"" {
69+
quoted.append(String(repeating: "\\", count: backslashCount * 2 + 1))
70+
} else {
71+
quoted.append(String(repeating: "\\", count: backslashCount))
72+
}
73+
quoted.append(String(unquoted[firstNonBackslash]))
74+
unquoted.removeFirst(backslashCount + 1)
75+
}
76+
quoted.append("\"")
77+
return quoted
78+
#else
79+
let quoteCharacter: Character = "'"
80+
let escapedQuoteCharacter = "'\\''"
81+
// If there are no quote characters then we can just wrap the string within the quotes.
82+
guard let quotePos = utf8.firstIndex(of: quoteCharacter.asciiValue!) else {
83+
return String(quoteCharacter) + self + String(quoteCharacter)
84+
}
6885

69-
// Otherwise iterate and escape all the single quotes.
70-
var newString = String(quoteCharacter) + String(self[..<quotePos])
86+
// Otherwise iterate and escape all the single quotes.
87+
var newString = String(quoteCharacter) + String(self[..<quotePos])
7188

72-
for char in self[quotePos...] {
73-
if char == quoteCharacter {
74-
newString += escapedQuoteCharacter
75-
} else {
76-
newString += String(char)
89+
for char in self[quotePos...] {
90+
if char == quoteCharacter {
91+
newString += escapedQuoteCharacter
92+
} else {
93+
newString += String(char)
94+
}
7795
}
78-
}
79-
80-
newString += String(quoteCharacter)
8196

82-
return newString
97+
newString += String(quoteCharacter)
98+
return newString
99+
#endif
83100
}
84101

85102
/// Shell escapes the current string. This method is mutating version of shellEscaped().

Tests/TSCBasicTests/StringConversionsTests.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@ class StringConversionTests: XCTestCase {
3636

3737
str = "hello\nA\"B C>D*[$;()^><"
3838
XCTAssertEqual("'hello\nA\"B C>D*[$;()^><'", str.spm_shellEscaped())
39+
40+
#if os(Windows)
41+
// Trailing backslash must be doubled so the closing quote is not escaped.
42+
str = "hello world\\"
43+
XCTAssertEqual("\"hello world\\\\\"", str.spm_shellEscaped())
44+
45+
// Embedded double-quote is escaped with a backslash.
46+
str = "hello\"world"
47+
XCTAssertEqual("\"hello\\\"world\"", str.spm_shellEscaped())
48+
49+
// Backslash immediately before an embedded double-quote: the backslash is doubled,
50+
// then the quote is escaped with another backslash.
51+
str = "hello\\\"world"
52+
XCTAssertEqual("\"hello\\\\\\\"world\"", str.spm_shellEscaped())
53+
#endif
3954
}
4055

4156
func testLocalizedJoin() {

0 commit comments

Comments
 (0)