fix(parsing): a zero followed by a sign is text, not an octal integer

Int(_, radix:) accepts a sign, so 0-3 (a CPU range, a port range) was read as the integer -3, and 0x-1, 0b-1 and 0o-1 as negative numbers. After a radix prefix only digits may follow. Octal, hex, binary and signed integers read as before.
This commit is contained in:
Mihaela Mihaljevic 2026-11-02 18:23:51 +01:00
parent ae85ec97c9
commit d2421aa0ce
3 changed files with 33 additions and 1 deletions

View File

@ -401,6 +401,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- A plain scalar that starts with `0` and continues with a sign is text, not an
octal integer. `Int(_, radix:)` accepts a leading sign, so `0-3` (a CPU set, a
port range) read as the integer -3, and `0x-1`, `0b-1` and `0o-1` as negative
numbers. After a radix prefix only digits may follow. Octal (`0755`),
hexadecimal, binary and signed integers read as before.
- A string the emitter wrote without quotes could be read back as something - A string the emitter wrote without quotes could be read back as something
else, so the string `yes` (and `no`, `on`, `off` in every case), `0x1F`, else, so the string `yes` (and `no`, `on`, `off` in every case), `0x1F`,
`0b1`, `0o17`, `1_000`, `.inf` and `.nan` did not survive a dump and parse. `0b1`, `0o17`, `1_000`, `.inf` and `.nan` did not survive a dump and parse.

View File

@ -69,7 +69,8 @@ extension PureYAML.Parsing {
for radixPrefix in radixPrefixes where unsigned.hasPrefix(radixPrefix.prefix) { for radixPrefix in radixPrefixes where unsigned.hasPrefix(radixPrefix.prefix) {
let digits = String(unsigned.dropFirst(radixPrefix.prefix.count)) let digits = String(unsigned.dropFirst(radixPrefix.prefix.count))
guard !digits.isEmpty, let magnitude = Int(digits, radix: radixPrefix.radix) else { // `Int(_, radix:)` accepts a sign, so `0-3` would read as minus three: after the prefix only digits may follow
guard let first = digits.first, first != "-", first != "+", let magnitude = Int(digits, radix: radixPrefix.radix) else {
return nil return nil
} }
return negative ? -magnitude : magnitude return negative ? -magnitude : magnitude

View File

@ -0,0 +1,25 @@
@testable import PureYAML
import Testing
/// A plain scalar that starts with `0` is an octal integer only when everything after the `0` is octal digits. A sign after the leading zero makes it text:
/// `0-3` is the range "0 to 3" (a CPU set, a port range), not minus three.
@Suite("Plain scalars that begin with a zero")
struct SignedOctalLookalikeTests {
@Test("A zero followed by a sign is text", arguments: ["0-3", "0-15", "0+1", "00-1", "0x-1", "0b-1", "0o-1"])
func test_signAfterTheLeadingZeroIsText(_ scalar: String) throws {
#expect(try PureYAML.parse("key: \(scalar)\n") == .mapping(.init([.init(key: "key", value: .string(scalar))])))
}
@Test("Octal, hexadecimal, binary and signed integers stay integers", arguments: [
("0755", 493), ("0", 0), ("-0", 0), ("0x1F", 31), ("0b101", 5), ("0o17", 15), ("-12", -12), ("+7", 7), ("-0755", -493),
])
func test_integersAreStillIntegers(_ scalar: String, _ expected: Int) throws {
#expect(try PureYAML.parse("key: \(scalar)\n") == .mapping(.init([.init(key: "key", value: .int(expected))])))
}
@Test("A range written with a leading zero survives a round trip", arguments: ["0-3", "0-15"])
func test_rangeRoundTrips(_ scalar: String) throws {
let value = PureYAML.Model.Value.mapping(.init([.init(key: "cpuset", value: .string(scalar))]))
#expect(try PureYAML.parse(PureYAML.dump(value)) == value)
}
}