Skip to content

Commit 43a199a

Browse files
correct semantic version parsing
The semantic versioning specification 2.0.0 [states](https://semver.org/#spec-item-9) that pre-release identifiers must be positioned after the version core, and build metadata identifiers after pre-release identifiers. In the old implementation, if a version core was appended with metadata identifiers that contain hyphens ("-"), the first hyphen would be mistaken as an indication of pre-release identifiers thereafter. Then, the position of the first hyphen would be treated as where the version core ends, resulting in a false negative after it was found that the "version core" contained non-numeric characters. For example: the semantic version `1.2.3+some-meta.data` is a well-formed, with `1.2.3` being the version core and `some-meta.data` the metadata identifiers. However, the old implementation of `Version.init?(_ versionString: String)` would falsely treat `1.2.3+some` as the version core and `meta.data` the pre-release identifiers. The new implementation fixes this problem by restricting the search area for "-" to the substring before the first "+". In addition, the logic for breaking up the version core into numeric identifiers has been rewritten to be more understandable.
1 parent f808059 commit 43a199a

File tree

2 files changed

+162
-31
lines changed

2 files changed

+162
-31
lines changed

Sources/PackageDescription/Version+StringLiteralConvertible.swift

Lines changed: 42 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2018 Apple Inc. and the Swift project authors
4+
Copyright (c) 2018 - 2021 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See http://swift.org/LICENSE.txt for license information
@@ -59,36 +59,48 @@ extension Version {
5959
}
6060

6161
/// Initializes a version struct with the provided version string.
62-
///
63-
/// - Parameters:
64-
/// - version: A version string to use for creating a new version struct.
62+
/// - Parameter version: A version string to use for creating a new version struct.
6563
public init?(_ versionString: String) {
66-
let prereleaseStartIndex = versionString.firstIndex(of: "-")
67-
let metadataStartIndex = versionString.firstIndex(of: "+")
68-
69-
let requiredEndIndex = prereleaseStartIndex ?? metadataStartIndex ?? versionString.endIndex
70-
let requiredCharacters = versionString.prefix(upTo: requiredEndIndex)
71-
let requiredComponents = requiredCharacters
72-
.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
73-
.map(String.init)
74-
.compactMap({ Int($0) })
75-
.filter({ $0 >= 0 })
76-
77-
guard requiredComponents.count == 3 else { return nil }
78-
79-
self.major = requiredComponents[0]
80-
self.minor = requiredComponents[1]
81-
self.patch = requiredComponents[2]
82-
83-
func identifiers(start: String.Index?, end: String.Index) -> [String] {
84-
guard let start = start else { return [] }
85-
let identifiers = versionString[versionString.index(after: start)..<end]
86-
return identifiers.split(separator: ".").map(String.init)
64+
// SemVer 2.0.0 allows only ASCII alphanumerical characters and "-" in the version string, except for "." and "+" as delimiters. ("-" is used as a delimiter between the version core and pre-release identifiers, but it's allowed within pre-release and metadata identifiers as well.)
65+
// Alphanumerics check will come later, after each identifier is split out (i.e. after the delimiters are removed).
66+
guard versionString.allSatisfy(\.isASCII) else { return nil }
67+
68+
let metadataDelimiterIndex = versionString.firstIndex(of: "+")
69+
// SemVer 2.0.0 requires that pre-release identifiers come before build metadata identifiers
70+
let prereleaseDelimiterIndex = versionString[..<(metadataDelimiterIndex ?? versionString.endIndex)].firstIndex(of: "-")
71+
72+
let versionCore = versionString[..<(prereleaseDelimiterIndex ?? metadataDelimiterIndex ?? versionString.endIndex)]
73+
let versionCoreIdentifiers = versionCore.split(separator: ".", omittingEmptySubsequences: false)
74+
75+
guard
76+
versionCoreIdentifiers.count == 3,
77+
// Major, minor, and patch versions must be ASCII numbers, according to the semantic versioning standard.
78+
// Converting each identifier from a substring to an integer doubles as checking if the identifiers have non-numeric characters.
79+
let major = Int(versionCoreIdentifiers[0]),
80+
let minor = Int(versionCoreIdentifiers[1]),
81+
let patch = Int(versionCoreIdentifiers[2])
82+
else { return nil }
83+
84+
self.major = major
85+
self.minor = minor
86+
self.patch = patch
87+
88+
if prereleaseDelimiterIndex == nil {
89+
self.prereleaseIdentifiers = []
90+
} else {
91+
let prereleaseStartIndex = prereleaseDelimiterIndex.map(versionString.index(after:)) ?? metadataDelimiterIndex ?? versionString.endIndex
92+
let prereleaseIdentifiers = versionString[prereleaseStartIndex..<(metadataDelimiterIndex ?? versionString.endIndex)].split(separator: ".", omittingEmptySubsequences: false)
93+
guard prereleaseIdentifiers.allSatisfy( { $0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } } ) else { return nil }
94+
self.prereleaseIdentifiers = prereleaseIdentifiers.map { String($0) }
95+
}
96+
97+
if metadataDelimiterIndex == nil {
98+
self.buildMetadataIdentifiers = []
99+
} else {
100+
let metadataStartIndex = metadataDelimiterIndex.map(versionString.index(after:)) ?? versionString.endIndex
101+
let buildMetadataIdentifiers = versionString[metadataStartIndex...].split(separator: ".", omittingEmptySubsequences: false)
102+
guard buildMetadataIdentifiers.allSatisfy( { $0.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } } ) else { return nil }
103+
self.buildMetadataIdentifiers = buildMetadataIdentifiers.map { String($0) }
87104
}
88-
89-
self.prereleaseIdentifiers = identifiers(
90-
start: prereleaseStartIndex,
91-
end: metadataStartIndex ?? versionString.endIndex)
92-
self.buildMetadataIdentifiers = identifiers(start: metadataStartIndex, end: versionString.endIndex)
93105
}
94106
}

Tests/PackageDescriptionTests/VersionTests.swift

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors
4+
Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See http://swift.org/LICENSE.txt for license information
@@ -23,5 +23,124 @@ class VersionTests: XCTestCase {
2323

2424
XCTAssertEqual(Version("1.2.3-alpha.beta.2").description, "1.2.3-alpha.beta.2")
2525
}
26+
27+
28+
29+
30+
func testLosslessConversionFromStringToVersion() {
31+
32+
// We use type coercion `as String` in `Version(_:)` because there is a pair of overloaded initializers: `init(_ version: Version)` and `init?(_ versionString: String)`, and we want to test the latter in this function.
33+
34+
// MARK: Well-formed version core
35+
36+
XCTAssertNotNil(Version("0.0.0" as String))
37+
XCTAssertEqual(Version("0.0.0" as String), Version(0, 0, 0))
38+
39+
XCTAssertNotNil(Version("1.1.2" as String))
40+
XCTAssertEqual(Version("1.1.2" as String), Version(1, 1, 2))
41+
42+
// MARK: Malformed version core
43+
44+
XCTAssertNil(Version("3" as String))
45+
XCTAssertNil(Version("3 5" as String))
46+
XCTAssertNil(Version("5.8" as String))
47+
XCTAssertNil(Version("-5.8.13" as String))
48+
XCTAssertNil(Version("8.-13.21" as String))
49+
XCTAssertNil(Version("13.21.-34" as String))
50+
XCTAssertNil(Version("-0.0.0" as String))
51+
XCTAssertNil(Version("0.-0.0" as String))
52+
XCTAssertNil(Version("0.0.-0" as String))
53+
XCTAssertNil(Version("21.34.55.89" as String))
54+
XCTAssertNil(Version("6 x 9 = 42" as String))
55+
XCTAssertNil(Version("forty two" as String))
56+
57+
// MARK: Well-formed version core, well-formed pre-release identifiers
58+
59+
XCTAssertNotNil(Version("0.0.0-pre-alpha" as String))
60+
XCTAssertEqual(Version("0.0.0-pre-alpha" as String), Version(0, 0, 0, prereleaseIdentifiers: ["pre-alpha"]))
61+
62+
XCTAssertNotNil(Version("55.89.144-beta.1" as String))
63+
XCTAssertEqual(Version("55.89.144-beta.1" as String), Version(55, 89, 144, prereleaseIdentifiers: ["beta", "1"]))
64+
65+
XCTAssertNotNil(Version("89.144.233-a.whole..lot.of.pre-release.identifiers" as String))
66+
XCTAssertEqual(Version("89.144.233-a.whole..lot.of.pre-release.identifiers" as String), Version(89, 144, 233, prereleaseIdentifiers: ["a", "whole", "", "lot", "of", "pre-release", "identifiers"]))
67+
68+
XCTAssertNotNil(Version("144.233.377-" as String))
69+
XCTAssertEqual(Version("144.233.377-" as String), Version(144, 233, 377, prereleaseIdentifiers: [""]))
70+
71+
// MARK: Well-formed version core, malformed pre-release identifiers
72+
73+
XCTAssertNil(Version("233.377.610-hello world" as String))
74+
75+
// MARK: Malformed version core, well-formed pre-release identifiers
76+
77+
XCTAssertNil(Version("987-Hello.world--------" as String))
78+
XCTAssertNil(Version("987.1597-half-life.3" as String))
79+
XCTAssertNil(Version("1597.2584.4181.6765-a.whole.lot.of.pre-release.identifiers" as String))
80+
XCTAssertNil(Version("6 x 9 = 42-" as String))
81+
XCTAssertNil(Version("forty-two" as String))
82+
//
83+
// MARK: Well-formed version core, well-formed build metadata identifiers
84+
85+
XCTAssertNotNil(Version("0.0.0+some-metadata" as String))
86+
XCTAssertEqual(Version("0.0.0+some-metadata" as String), Version(0, 0, 0, buildMetadataIdentifiers: ["some-metadata"]))
87+
88+
XCTAssertNotNil(Version("4181.6765.10946+more.meta..more.data" as String))
89+
XCTAssertEqual(Version("4181.6765.10946+more.meta..more.data" as String), Version(4181, 6765, 10946, buildMetadataIdentifiers: ["more", "meta", "", "more", "data"]))
90+
91+
XCTAssertNotNil(Version("6765.10946.17711+-a-very--long---build-----metadata--------identifier-------------with---------------------many----------------------------------hyphens-------------------------------------------------------" as String))
92+
XCTAssertEqual(Version("6765.10946.17711+-a-very--long---build-----metadata--------identifier-------------with---------------------many----------------------------------hyphens-------------------------------------------------------" as String), Version(6765, 10946, 17711, buildMetadataIdentifiers: ["-a-very--long---build-----metadata--------identifier-------------with---------------------many----------------------------------hyphens-------------------------------------------------------"]))
93+
94+
XCTAssertNotNil(Version("10946.17711.28657+" as String))
95+
XCTAssertEqual(Version("10946.17711.28657+" as String), Version(10946, 17711, 28657, buildMetadataIdentifiers: [""]))
96+
97+
// MARK: Well-formed version core, malformed build metadata identifiers
98+
99+
XCTAssertNil(Version("17711.28657.46368+hello world" as String))
100+
XCTAssertNil(Version("28657.46368.75025+hello+world" as String))
101+
102+
// MARK: Malformed version core, well-formed build metadata identifiers
103+
104+
XCTAssertNil(Version("121393+Hello.world--------" as String))
105+
XCTAssertNil(Version("121393.196418+half-life.3" as String))
106+
XCTAssertNil(Version("196418.317811.514229.832040+a.whole.lot.of.build.metadata.identifiers" as String))
107+
XCTAssertNil(Version("196418.317811.514229.832040+a.whole.lot.of.build.metadata.identifiers" as String))
108+
XCTAssertNil(Version("6 x 9 = 42+" as String))
109+
XCTAssertNil(Version("forty two+a-very-long-build-metadata-identifier-with-many-hyphens" as String))
110+
111+
// MARK: Well-formed version core, well-formed pre-release identifiers, well-formed build metadata identifiers
112+
113+
XCTAssertNotNil(Version("0.0.0-beta.-42+42-42.42" as String))
114+
XCTAssertEqual(Version("0.0.0-beta.-42+42-42.42" as String), Version(0, 0, 0, prereleaseIdentifiers: ["beta", "-42"], buildMetadataIdentifiers: ["42-42", "42"]))
115+
116+
// MARK: Well-formed version core, well-formed pre-release identifiers, malformed build metadata identifiers
117+
118+
XCTAssertNil(Version("514229.832040.1346269-beta1+ " as String))
119+
120+
// MARK: Well-formed version core, malformed pre-release identifiers, well-formed build metadata identifiers
121+
122+
XCTAssertNil(Version("832040.1346269.2178309-beta 1+-" as String))
123+
124+
// MARK: Well-formed version core, malformed pre-release identifiers, malformed build metadata identifiers
125+
126+
XCTAssertNil(Version("1346269.2178309.3524578-beta 1++" as String))
127+
128+
// MARK: malformed version core, well-formed pre-release identifiers, well-formed build metadata identifiers
129+
130+
XCTAssertNil(Version(" 832040.1346269.3524578-beta1+abc" as String))
131+
132+
// MARK: malformed version core, well-formed pre-release identifiers, malformed build metadata identifiers
133+
134+
XCTAssertNil(Version("1346269.3524578.5702887-beta1+😀" as String))
135+
136+
// MARK: malformed version core, malformed pre-release identifiers, well-formed build metadata identifiers
137+
138+
XCTAssertNil(Version("3524578.5702887.9227465-beta!@#$%^&*1+asdfghjkl123456789" as String))
139+
140+
// MARK: malformed version core, malformed pre-release identifiers, malformed build metadata identifiers
141+
142+
XCTAssertNil(Version("5702887.9227465-bètá1+±" as String))
143+
144+
}
26145
}
27146

0 commit comments

Comments
 (0)