From d2421aa0cebc0b0840505650ea679c349fc4717d Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 2 Nov 2026 18:23:51 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 6 ++++++ Sources/Parsing/Parsing.PlainScalar.swift | 3 ++- Tests/SignedOctalLookalikeTests.swift | 25 +++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 Tests/SignedOctalLookalikeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c4cf9a7..0f457a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -401,6 +401,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 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. diff --git a/Sources/Parsing/Parsing.PlainScalar.swift b/Sources/Parsing/Parsing.PlainScalar.swift index d44fc86..64b4050 100644 --- a/Sources/Parsing/Parsing.PlainScalar.swift +++ b/Sources/Parsing/Parsing.PlainScalar.swift @@ -69,7 +69,8 @@ extension PureYAML.Parsing { for radixPrefix in radixPrefixes where unsigned.hasPrefix(radixPrefix.prefix) { 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 negative ? -magnitude : magnitude diff --git a/Tests/SignedOctalLookalikeTests.swift b/Tests/SignedOctalLookalikeTests.swift new file mode 100644 index 0000000..fb00b31 --- /dev/null +++ b/Tests/SignedOctalLookalikeTests.swift @@ -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) + } +}