diff --git a/Package.swift b/Package.swift index ad10fb3..ca4e522 100644 --- a/Package.swift +++ b/Package.swift @@ -104,6 +104,7 @@ let package = Package( .testTarget( name: "BinaryParsingMacrosTests", dependencies: [ + "BinaryParsing", "BinaryParsingMacros", .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), diff --git a/Sources/BinaryParsing/Macros/Macros.swift b/Sources/BinaryParsing/Macros/Macros.swift index eff14a6..114ae16 100644 --- a/Sources/BinaryParsing/Macros/Macros.swift +++ b/Sources/BinaryParsing/Macros/Macros.swift @@ -12,3 +12,66 @@ @freestanding(expression) public macro magicNumber(_ code: String, parsing input: inout ParserSpan) = #externalMacro(module: "BinaryParsingMacros", type: "MagicNumberStringMacro") + +/// Parses and validates a magic number or signature of arbitrary length from binary data. +/// +/// This macro extends the functionality of `#magicNumber` to support ASCII strings of any length, +/// not just the 2, 4, or 8 byte limitations of the original macro. It compiles to an optimized +/// `InlineArray` comparison with zero runtime overhead. +/// +/// The macro takes an ASCII string literal and generates compile-time code that: +/// 1. Parses exactly `string.count` bytes from the input span +/// 2. Compares them against the expected byte values +/// 3. Throws a `ParsingError` if bytes don't match or insufficient data is available +/// +/// ## Usage +/// +/// ```swift +/// // Parse 4-byte magic number +/// try #magic("test", parsing: &input) +/// +/// // Parse single byte +/// try #magic("A", parsing: &input) +/// +/// // Parse longer sequences (e.g., 11 bytes) +/// try #magic("hello world", parsing: &input) +/// +/// ``` +/// +/// ## Compile-time Optimization +/// +/// The macro generates code equivalent to: +/// ```swift +/// _loadAndCheckInlineArrayBytes( +/// parsing: &input, +/// expectedBytes: [116, 101, 115, 116] // ASCII values of "test" +/// ) +/// ``` +/// +/// This leverages Swift 6.2's `InlineArray` and Value Generics for compile-time resolution, +/// providing the same performance as hand-written byte comparisons. +/// +/// ## Error Conditions +/// +/// Throws `ParsingError` in these cases: +/// - Input span has fewer bytes than the magic string length +/// - Parsed bytes don't match the expected magic string +/// - Magic string contains non-ASCII characters +/// - Magic string is empty +/// +/// ## Availability +/// +/// Requires macOS 26+, iOS 26+, watchOS 26+, tvOS 26+, visionOS 26+ due to `InlineArray` usage. +/// +/// ## See Also +/// +/// - `#magicNumber(_:parsing:)` for 2/4/8 byte magic numbers using fixed-width integers +/// - `InlineArray` for the underlying compile-time array implementation +/// +/// - Parameters: +/// - code: An ASCII string literal representing the expected magic bytes +/// - input: An inout `ParserSpan` to parse from +/// - Throws: `ParsingError` if parsing fails or bytes don't match +@freestanding(expression) +public macro magic(_ code: String, parsing input: inout ParserSpan) = + #externalMacro(module: "BinaryParsingMacros", type: "MagicMacro") diff --git a/Sources/BinaryParsing/Macros/MagicNumber.swift b/Sources/BinaryParsing/Macros/MagicNumber.swift index c0f61d7..c3573b6 100644 --- a/Sources/BinaryParsing/Macros/MagicNumber.swift +++ b/Sources/BinaryParsing/Macros/MagicNumber.swift @@ -41,3 +41,48 @@ public func _loadAndCheckDirectBytesByteOrder< location: input.startPosition) } } + +/// Loads and validates bytes from a parser span against an expected InlineArray of bytes. +/// +/// This function is used internally by the `#magic` macro to perform arbitrary-length +/// magic number validation using InlineArray for compile-time optimization. +/// +/// The function: +/// 1. Parses exactly `N` bytes from the input span into an `InlineArray` +/// 2. Compares the parsed bytes against the expected bytes using InlineArray's Equatable conformance +/// 3. Throws a `ParsingError` with `.invalidValue` status if bytes don't match +/// 4. Consumes the bytes from the span regardless of whether comparison succeeds or fails +/// +/// ## Usage +/// +/// This function is primarily intended for use by macro-generated code: +/// ```swift +/// // Generated by #magic("test", parsing: &input) +/// try _loadAndCheckInlineArrayBytes( +/// parsing: &input, +/// expectedBytes: [116, 101, 115, 116] +/// ) +/// ``` +/// +/// ## Availability +/// +/// Requires macOS 26+, iOS 26+, watchOS 26+, tvOS 26+, visionOS 26+ due to `InlineArray` usage. +/// +/// - Parameters: +/// - input: An inout `ParserSpan` to parse bytes from +/// - expectedBytes: An `InlineArray` containing the expected byte values +/// - Throws: `ParsingError` with `.invalidValue` status if bytes don't match, or propagates +/// any parsing errors from `InlineArray.init(parsing:)` +@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) +@_lifetime(&input) +@inlinable +public func _loadAndCheckInlineArrayBytes( + parsing input: inout ParserSpan, + expectedBytes: InlineArray +) throws(ParsingError) { + let parsedBytes = try? InlineArray(parsing: &input) + guard parsedBytes == expectedBytes else { + throw ParsingError( + status: .invalidValue, location: input.startPosition) + } +} diff --git a/Sources/BinaryParsing/Parsers/InlineArray.swift b/Sources/BinaryParsing/Parsers/InlineArray.swift index 26262b8..2e2e62e 100644 --- a/Sources/BinaryParsing/Parsers/InlineArray.swift +++ b/Sources/BinaryParsing/Parsers/InlineArray.swift @@ -27,6 +27,21 @@ extension InlineArray where Element == UInt8 { } } +@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) +extension InlineArray: @retroactive Equatable where Element == UInt8 { + @inlinable + public static func == ( + lhs: InlineArray, rhs: InlineArray + ) -> Bool { + for i in 0.. ExprSyntax { + guard let argument = node.arguments.first?.expression, + let stringLiteral = argument.as(StringLiteralExprSyntax.self) + else { + context.diagnose( + .init( + node: node, + message: MacroExpansionErrorMessage( + "Magic bytes must be expressed as a string literal."))) + return "" + } + + // Handle both single-segment and multi-segment strings (for escape sequences) + var string = "" + for segment in stringLiteral.segments { + switch segment { + case .stringSegment(let literalSegment): + string += literalSegment.content.text + case .expressionSegment(_): + context.diagnose( + .init( + node: node, + message: MacroExpansionErrorMessage( + "String interpolation not supported in magic bytes."))) + return "" + @unknown default: + context.diagnose( + .init( + node: node, + message: MacroExpansionErrorMessage( + "Unsupported string segment type."))) + return "" + } + } + + guard string.allSatisfy(\.isASCII) else { + context.diagnose( + .init( + node: node, + message: MacroExpansionErrorMessage( + "Magic bytes must be ASCII only."))) + return "" + } + + guard !string.isEmpty else { + context.diagnose( + .init( + node: node, + message: MacroExpansionErrorMessage( + "Magic bytes string cannot be empty."))) + return "" + } + + var parsingExpr = "input" + if let parsingArg = node.arguments.first(where: { + $0.label?.text == "parsing" + }) { + parsingExpr = parsingArg.expression.description + } + + let bytes = Array(string.utf8) + let byteValues = bytes.map { String($0) }.joined(separator: ", ") + + return """ + _loadAndCheckInlineArrayBytes(\ + parsing: \(raw: parsingExpr), \ + expectedBytes: [\(raw: byteValues)]) + """ + } +} diff --git a/Sources/BinaryParsingMacros/Plugin.swift b/Sources/BinaryParsingMacros/Plugin.swift index 371a45e..66f8bd7 100644 --- a/Sources/BinaryParsingMacros/Plugin.swift +++ b/Sources/BinaryParsingMacros/Plugin.swift @@ -17,5 +17,6 @@ struct ParserMacroPlugin: CompilerPlugin { var providingMacros: [Macro.Type] = [ ParserMacro.self, MagicNumberStringMacro.self, + MagicMacro.self, ] } diff --git a/Tests/BinaryParsingMacrosTests/MagicMacroTests.swift b/Tests/BinaryParsingMacrosTests/MagicMacroTests.swift new file mode 100644 index 0000000..a9fed7a --- /dev/null +++ b/Tests/BinaryParsingMacrosTests/MagicMacroTests.swift @@ -0,0 +1,133 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift Binary Parsing open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import BinaryParsing +import BinaryParsingMacros +import MacroTesting +import Testing + +@Suite( + .macros(macros: ["magic": MagicMacro.self]) +) +struct MagicMacroTests { + // MARK: Macro expansion tests + @Test + func magicStringAsciiOnly() { + assertMacro { + #"try #magic("test", parsing: &data)"# + } expansion: { + "try _loadAndCheckInlineArrayBytes(parsing: &data, expectedBytes: [116, 101, 115, 116])" + } + } + + @Test + func magicStringLong() { + assertMacro { + #"try #magic("hello world", parsing: &data)"# + } expansion: { + "try _loadAndCheckInlineArrayBytes(parsing: &data, expectedBytes: [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100])" + } + } + + @Test + func magicStringSingleByte() { + assertMacro { + #"try #magic("A", parsing: &data)"# + } expansion: { + "try _loadAndCheckInlineArrayBytes(parsing: &data, expectedBytes: [65])" + } + } + + @Test + func magicStringWithLiteralBackslashN() { + // Note: \n is treated as literal backslash + n characters, not a newline + assertMacro { + #"try #magic("hello\nworld", parsing: &data)"# + } expansion: { + "try _loadAndCheckInlineArrayBytes(parsing: &data, expectedBytes: [104, 101, 108, 108, 111, 92, 110, 119, 111, 114, 108, 100])" + } + } + + @Test + func magicStringEmpty() { + assertMacro { + #"try #magic("", parsing: &data)"# + } diagnostics: { + """ + try #magic("", parsing: &data) + ┬───────────────────────── + ╰─ 🛑 Magic bytes string cannot be empty. + """ + } + } + + @Test + func magicCustomParsingArgument() { + assertMacro { + #"try #magic("test", parsing: &mySpan)"# + } expansion: { + "try _loadAndCheckInlineArrayBytes(parsing: &mySpan, expectedBytes: [116, 101, 115, 116])" + } + } + + // MARK: End-to-end runtime tests + @available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) + @Test + func magicEndToEndMatching() throws { + let testBytes: [UInt8] = [116, 101, 115, 116] // "test" + + try testBytes.withParserSpan { span in + // This should succeed - bytes match + try #magic("test", parsing: &span) + #expect(span.count == 0) + } + } + + @available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) + @Test + func magicEndToEndMismatched() throws { + let wrongBytes: [UInt8] = [74, 80, 69, 71] // "JPEG" + + wrongBytes.withParserSpan { span in + // This should fail - bytes don't match + #expect(throws: ParsingError.self) { + try #magic("test", parsing: &span) + } + // Span should still be consumed even though comparison failed + #expect(span.count == 0) + } + } + + @available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) + @Test + func magicEndToEndLongString() throws { + let longTestBytes: [UInt8] = Array("hello world".utf8) + + try longTestBytes.withParserSpan { span in + // Test arbitrary length support + try #magic("hello world", parsing: &span) + #expect(span.count == 0) + } + } + + @available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) + @Test + func magicEndToEndInsufficientBytes() throws { + let shortBytes: [UInt8] = [116, 101] // "te" (only 2 bytes) + + shortBytes.withParserSpan { span in + // This should fail - not enough bytes + #expect(throws: ParsingError.self) { + try #magic("test", parsing: &span) // needs 4 bytes + } + } + } +} diff --git a/Tests/BinaryParsingTests/InlineArrayParsingTests.swift b/Tests/BinaryParsingTests/InlineArrayParsingTests.swift index e0ad970..804376e 100644 --- a/Tests/BinaryParsingTests/InlineArrayParsingTests.swift +++ b/Tests/BinaryParsingTests/InlineArrayParsingTests.swift @@ -173,4 +173,35 @@ struct InlineArrayParsingTests { #expect(parsedArray == expectedArray) } } + + @available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) + @Test + func magicFunctionMatchingBytes() throws { + let correctBytes: [UInt8] = [116, 101, 115, 116] // "test" + + try correctBytes.withParserSpan { span in + let expectedArray: InlineArray<4, UInt8> = [116, 101, 115, 116] + // This should succeed - bytes match + try _loadAndCheckInlineArrayBytes( + parsing: &span, expectedBytes: expectedArray) + #expect(span.count == 0) + } + } + + @available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) + @Test + func magicFunctionMismatchedBytes() throws { + let wrongBytes: [UInt8] = [74, 80, 69, 71] // "JPEG" + + wrongBytes.withParserSpan { span in + let expectedArray: InlineArray<4, UInt8> = [116, 101, 115, 116] + // This should fail - bytes don't match + #expect(throws: ParsingError.self) { + try _loadAndCheckInlineArrayBytes( + parsing: &span, expectedBytes: expectedArray) + } + // Span should be consumed even though comparison failed + #expect(span.count == 0) + } + } }