diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0bab45..293f404 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,12 +9,23 @@ on: jobs: build-and-test: name: Build & Test + # Run for pushes (post-merge to main) and for pull requests whose head + # branch lives in this repo. Pull requests from forks (foreign branches) + # have a different head repo and are skipped, so they never reach the + # macOS runner or install dependencies. + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository runs-on: macos-15 steps: - uses: actions/checkout@v4 with: submodules: false + # The live integration tests publish a synthetic stream with ffmpeg to a + # mediamtx RTSP server and pull it back through RTSPClientSession, so both + # tools must be on PATH. See README "Testing". + - name: Install integration test dependencies + run: brew install ffmpeg mediamtx + - name: Build run: swift build -v @@ -23,6 +34,7 @@ jobs: lint: name: Lint + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository runs-on: macos-15 steps: - uses: actions/checkout@v4 diff --git a/API.md b/API.md index 5771f13..8bd84f0 100644 --- a/API.md +++ b/API.md @@ -35,6 +35,11 @@ await session.stop() ### RTSPClientSession The main entry point. Manages the full RTSP lifecycle (DESCRIBE, SETUP, PLAY, TEARDOWN). +While streaming it sends periodic keepalives (GET_PARAMETER when the server +advertises support, otherwise OPTIONS) at roughly half the negotiated session +timeout, so long-running sessions aren't dropped by the camera. A successful +keepalive is reported via `onDiagnostic` at `.info` severity; a failed one at +`.warning` — the stream keeps running and the next tick retries. ```swift final class RTSPClientSession: Sendable @@ -51,6 +56,10 @@ func frames() -> AsyncThrowingStream func stop() async ``` +The `url` host may be a hostname, an IPv4 literal, or a bracketed IPv6 literal +(`rtsp://[2001:db8::1]:554/stream`) — including link-local addresses with a zone +id. Both IPv4 and IPv6 cameras work over either transport. + ### RTSPDiagnostic Non-fatal anomalies observed during a session (e.g. cameras deviating from spec). The @@ -78,10 +87,12 @@ init(username: String, password: String) ```swift enum Transport: Sendable { case tcp // RTP interleaved over RTSP TCP connection - case udp // RTP/RTCP on separate UDP ports + case udp // RTP/RTCP on a separate even/odd UDP port pair } ``` +Both transports work over IPv4 and IPv6 peers. + ## Session Description Returned by `start()` with stream metadata parsed from SDP. @@ -405,6 +416,17 @@ enum StreamContext: Sendable { case dummy } +struct TcpStreamContext: Sendable { + let rtpChannelId: UInt8 // RTCP channel id is one higher +} + +struct UdpStreamContext: Sendable { + let localIP: String + let peerIP: String + let localRtpPort: UInt16 // RTCP port is one higher + let peerRtpPort: UInt16 +} + enum PacketContext: Sendable { case tcp(RtspMessageContext) case udp(receivedWall: WallTime) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86bdd59..393738e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,38 @@ ### New - ONVIF analytics metadata stream support (`vnd.onvif.metadata` per the ONVIF Streaming Specification). Surfaced as `PublicCodecItem.metadata(PublicMetadataFrame)` in the `session.frames()` stream, with discoverability via `SessionDescription.metadataEncoding`. Best-effort: malformed metadata SDP or a failed application SETUP degrades to a diagnostic without aborting video/audio. +- Real UDP transport for RTP/RTCP. Selecting `Transport.udp` now streams end-to-end over an Apple Network-framework socket pair (an even RTP port and the consecutive odd RTCP port, negotiated via `client_port`), replacing the earlier non-functional scaffolding. A best-effort NAT hole-punch is sent toward the peer after `PLAY`. +- IPv6 support on both transports. The RTSP control connection and the UDP RTP/RTCP pair work over IPv6 peers, and a bracketed IPv6 literal in the URL host (e.g. `rtsp://[2001:db8::1]:554/stream`) is parsed correctly. +- Automatic RTSP session keepalive while streaming: sends `GET_PARAMETER` when the server advertises support, otherwise `OPTIONS`, at roughly half the negotiated session timeout, so long-running sessions aren't dropped by the camera. Each round-trip is reported via `onDiagnostic` at `.info` severity. ### Improvements - Add visionOS 1.0 to supported platforms +- Make the `ntpUnixEpoch` constant private. It was accidentally exposed as a public top-level symbol but is only used internally; consumers should not have relied on it. +- Add a connect timeout to the RTSP TCP transport so an unreachable or refused camera fails fast instead of hanging. `NWConnection` parks refused/unreachable peers in `.waiting`, which previously burned the full timeout; these now surface the real cause after a short grace window. +- Surface transport-level failures that previously stalled or died silently: a terminal UDP receive error (e.g. an ICMP port-unreachable from a NAT'd camera) is now reported via `onDiagnostic` instead of letting the receive loop die quietly. +- Broaden `onDiagnostic` coverage of the drop/recover paths — interleaved data on an unnegotiated channel, FU-reassembly anomalies, audio/metadata SETUP or init failures, sprop parameter-set parse fallback, interleaved media a camera streams before its `PLAY` response completes, and SDP streams that fail to parse while others succeed. Each condition is rate-limited to fire once so a misbehaving camera can't flood the consumer. +- Live integration test suite: drives `RTSPClientSession` end-to-end against an `ffmpeg`-published stream relayed by `mediamtx`, exercising H.264/H.265/AAC over both RTSP-interleaved TCP and UDP, including IPv6. CI installs `ffmpeg` and `mediamtx`. ### Fixes - Audio depacketizer init failures now null out the audio stream state (index, encoding name, clock rate, channels), mirroring the metadata-init failure path. Previously the indices stayed set while the depacketizer was nil; packets on that channel were silently dropped by the dispatch loop but `SessionDescription` could still claim the stream existed. Required to keep the "at least one usable stream" guard honest in audio-only sessions. +- Stop the codec, SDP, and RTSP parsers from trapping (crashing the process) on malformed or hostile input — out-of-range reads, bad lengths, and truncated buffers now error or are tolerated instead of aborting. +- Don't tear down the stream on a single bad packet; the packet is dropped and streaming continues. +- Bound depacketizer memory and reset depacketizer state on failure, so a malformed stream can't grow memory without limit. +- Validate the RTCP sender-report SSRC before anchoring the RTP/NTP timeline, so a stray report can't corrupt timestamps. +- Tolerate sloppy SDP and lenient H.265 `fmtp` from real cameras (Postel's law) instead of rejecting the session, and make AAC depacketization tolerant of real-camera deviations. +- Harden the session lifecycle and SETUP tolerance. +- Strengthen RTSP Digest authentication: escape quoted-string parameters correctly and fix the 401 retry path. +- Harden the H.265 depacketizer and parameter-set parsing against malformed input. +- Fix and harden H.264 SPS parsing. +- Harden RTP packet and context handling, and saturate loss counters to avoid overflow traps. +- Harden the RTSP parsers against malformed server input, and harden the session start path. +- Fix the static L16 payload clock rate to 44100 Hz. +- Ignore the RTP MARK bit on H.264 SEI packets (some cameras set it on a trailing SEI, splitting the access unit early). +- Tolerate a complete (un-fragmented) NAL that arrives inside an FU wrapper instead of erroring. +- Accept a scheme-less `Content-Base` header by resolving it against the request URL. +- Normalize audio frame data to a standalone `Data`. ## 0.2.0 diff --git a/README.md b/README.md index 98fc370..3bc0b0a 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,13 @@ A pure-Swift RTSP client library for streaming live video and audio from IP came - **Audio** — AAC, PCMU, PCMA, G.722, G.726, L16, G.723.1 - **ONVIF analytics metadata** — raw XML documents from the camera's `application` RTSP stream - **Optional streams** — any combination of video / audio / metadata is supported; audio-only or metadata-only sessions (e.g. Axis `video=0`) work end-to-end -- **Zero dependencies** — only Foundation, Network, and CryptoKit +- **TCP & UDP transport** — RTP/RTCP over RTSP-interleaved TCP or a dedicated UDP socket pair, over IPv4 or IPv6 +- **Zero dependencies** — built only on Apple system frameworks (Foundation, Network, CryptoKit) - **Swift 6** — strict concurrency with async/await and AsyncThrowingStream ## Requirements -- macOS 14.0+ +- macOS 13.0+, iOS 16.0+, tvOS 16.0+, Mac Catalyst 16.0+, visionOS 1.0+ - Swift 6.0+ ## Installation @@ -20,7 +21,7 @@ Add IPCamKit as a dependency in your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/steelbrain/IPCamKit.git", from: "0.1.1"), + .package(url: "https://github.com/steelbrain/IPCamKit.git", from: "0.2.0"), ] ``` @@ -103,7 +104,9 @@ See [API.md](API.md) for the full API reference. - SDP parsing with codec parameter extraction - RTP packet parsing (RFC 3550) with sequence tracking and loss detection - RTSP authentication (Basic and Digest with MD5) -- Transport: TCP interleaved and UDP +- Automatic session keepalive while streaming (GET_PARAMETER when the server + advertises it, else OPTIONS) so long sessions aren't dropped at the timeout +- Transport: TCP interleaved and UDP, over IPv4 or IPv6 ### Compatibility - Tested with Reolink, Dahua, Hikvision, Longse, GW Security, VStarcam, Tenda, Foscam, and others @@ -132,18 +135,27 @@ Sources/IPCamKit/ ├── RTP/ RTP/RTCP packets, Timeline, ChannelMapping, InorderParser ├── Codec/ H.264/H.265 depacketizers, NAL/SPS/PPS parsing, audio + metadata depacketizers ├── Auth/ Basic and Digest authentication -├── Transport/ NWConnection TCP/UDP transport +├── Transport/ Network-framework RTSP/TCP control + UDP RTP/RTCP socket pair (IPv4/IPv6) └── Client/ RTSP session, DESCRIBE/SETUP/PLAY parsers, Presentation ``` ## Testing -100+ tests across 15+ suites covering RTSP parsing, SDP, RTP, H.264/H.265 depacketization, AAC, simple audio, ONVIF metadata depacketization, authentication, and integration: +165+ tests across 18 suites covering RTSP parsing, SDP, RTP, H.264/H.265 depacketization, AAC, simple audio, ONVIF metadata depacketization, authentication, and the full pipeline: ```bash swift test ``` +The **live integration suite** drives the real `RTSPClientSession` end to end: `ffmpeg` +publishes a synthetic H.264/H.265/AAC stream to a [mediamtx](https://github.com/bluenviron/mediamtx) +RTSP server, and the client pulls it back over both RTSP-interleaved TCP and UDP. Both tools +must be on `PATH`: + +```bash +brew install ffmpeg mediamtx +``` + ## License MIT — see [LICENSE](LICENSE) for details. diff --git a/RETINA_SYNC.md b/RETINA_SYNC.md new file mode 100644 index 0000000..337e362 --- /dev/null +++ b/RETINA_SYNC.md @@ -0,0 +1,79 @@ +# Upstream sync: scottlamb/retina + +IPCamKit began as a Swift port of the Rust crate +[`retina`](https://github.com/scottlamb/retina). Most files carry +`// Port of retina src/...` comments with upstream line references. + +This file records the last upstream commit we have reconciled against, so a +future sync only has to look at what landed in retina *after* this anchor. + +## Status + +- **Fork point:** retina ~late February 2026 (IPCamKit's initial commit is + 2026-02-28). +- **Upstream HEAD at last review:** `6972ac4261ce7bf5b585da9051606c7b5c0ab82c` + ("accept scheme-less `Content-Base` header", 2026-03-30). +- **Last reconciled commit (anchor):** `6972ac4261ce7bf5b585da9051606c7b5c0ab82c` + ("accept scheme-less `Content-Base` header", 2026-03-30). All relevant + behavioral changes through this commit have been ported; see the checklist + below for the porting commits and skip rationale. +- **Reviewed on:** 2026-05-30 + +> IPCamKit has *diverged on purpose*: it is hardened against malformed/hostile +> camera input (overflow-trap guards, bounded parser loops, best-effort +> audio/metadata) and fixes several port bugs. When pulling upstream changes, +> re-apply those hardening patterns rather than reverting to retina's +> debug-panic / `assert!` style. + +## Post-fork upstream commits to evaluate (fork → 6972ac4) + +Relevant (behavioral) — ported: + +- [x] `ff771fe` (2026-03-13) — **H.264: ignore the RTP MARK bit on SEI packets** + (some cameras set MARK on a trailing SEI, splitting the access unit early). + Ported in IPCamKit `42010e4`: `H264Depacketizer.canEndAU` now excludes + NAL type 6 (SEI) alongside SPS/PPS, with test vectors updated to match. +- [x] `8ff7a0f` (2026-03-28, with follow-up `e1c0bbf` "hoist end block") — + **tolerate a complete (un-fragmented) NAL that arrives inside an FU + wrapper** ("struggle on" instead of erroring). Ported in IPCamKit + `66ab85e`: both H.264 (FU-A) and H.265 (FU) depacketizers treat a + START+END fragment as one complete NAL. +- [x] `6972ac4` (2026-03-30) — **accept a scheme-less `Content-Base` header** + (resolve it against the request URL instead of rejecting). Ported in + IPCamKit `b6ff670`: `DescribeParser` prepends the request URL's scheme to + a scheme-less Content-Base/Content-Location. + +Evaluated, intentionally skipped: + +- `6339bd6` (2026-02-27) — *support stripping H.26x inline parameter sets.* This + is a **feature**, not a bug fix: it adds a configurable `FrameFormat` / + `ParameterSetInsertion` policy controlling whether in-band SPS/PPS/VPS are + inserted into or stripped from output frames. IPCamKit passes NALs through as + received and surfaces parameter sets separately (`VideoStream.sps/.pps/.vps` + and `PublicVideoFrame.sps/.pps/.vps`), so there is no output-framing policy to + configure. Not applicable. + +Confirmed no-op for us (API exposure / examples / Rust-only infra) — skipped: + +- `58f0042` FrameFormat (tied to the `6339bd6` feature above), `93f5917` + VideoParameters from SPS/PPS, `8ecbeab` expose audio channels, `0799d0e` coded + width/height, `6a4568b` receive timestamps — IPCamKit's public surface already + covers the inspectable data (`VideoParameters.pixelDimensions/rfc6381Codec`, + `AudioStream.channels`, per-frame presentation `timestamp`); these are + feature/API-exposure changes with no behavioral bug fix to port. +- `b6a18c4` own RTSP/1.0 parser, `f2bcec3` derive_more, `7073bb5`/`c92ff24` + fuzz crate, `09c6175` license headers, `80abcd6` build-without-h265, + `44042ba` flaky test, examples/docs/clippy — Rust-specific, not applicable. + +## How to sync next time + +```sh +git clone https://github.com/scottlamb/retina /tmp/retina +cd /tmp/retina +git log --oneline ..HEAD +git diff ..HEAD -- src/rtp.rs src/rtcp.rs src/client/ src/codec/ +``` + +For each relevant change, port it into the matching `Sources/IPCamKit/...` +file, keep the `// Port of retina ...` line references accurate, and update the +**Last reconciled commit** above to the new anchor. diff --git a/Sources/IPCamKit/Auth/RTSPAuth.swift b/Sources/IPCamKit/Auth/RTSPAuth.swift index f87a7ee..e1a0972 100644 --- a/Sources/IPCamKit/Auth/RTSPAuth.swift +++ b/Sources/IPCamKit/Auth/RTSPAuth.swift @@ -23,6 +23,9 @@ public struct Credentials: Sendable { struct RTSPAuthenticator: Sendable { private let credentials: Credentials private var digestState: DigestState? + /// Set once the server has offered Basic, so later requests can attach it + /// pre-emptively instead of paying a fresh 401 round-trip every time. + private var basicChallengeSeen = false init(credentials: Credentials) { self.credentials = credentials @@ -31,17 +34,28 @@ struct RTSPAuthenticator: Sendable { /// Parse a WWW-Authenticate header and update internal state. mutating func handleChallenge(_ wwwAuthenticate: String) { let trimmed = wwwAuthenticate.trimmingCharacters(in: .whitespaces) - if trimmed.lowercased().hasPrefix("digest") { - digestState = parseDigestChallenge(String(trimmed.dropFirst(6))) + let lower = trimmed.lowercased() + if lower.hasPrefix("digest") { + let state = parseDigestChallenge(String(trimmed.dropFirst(6))) + // A digest challenge without a nonce is unusable; ignore it rather than + // emit a bogus Authorization header — and never let a nonce-less re-offer + // (or a second challenge line) clobber a good earlier Digest challenge. + // A usable challenge replaces any prior one, resetting nc (nc starts 0). + if !state.nonce.isEmpty { + digestState = state + } + } else if lower.hasPrefix("basic") { + basicChallengeSeen = true } - // Basic auth doesn't need to parse the challenge } /// Generate an Authorization header value for the given request. - func authorize(method: String, uri: String) -> String? { - if let state = digestState { - return generateDigestAuth( - method: method, uri: uri, state: state) + /// + /// Mutates internal state: Digest+qop advances the nonce-count so each + /// authorized request carries a unique, monotonically increasing `nc`. + mutating func authorize(method: String, uri: String) -> String? { + if digestState != nil { + return generateDigestAuth(method: method, uri: uri) } // Fall back to Basic auth return generateBasicAuth() @@ -49,7 +63,7 @@ struct RTSPAuthenticator: Sendable { /// Whether we have received a challenge and can generate auth headers. var hasChallenge: Bool { - digestState != nil + digestState != nil || basicChallengeSeen } // MARK: - Basic Auth @@ -82,8 +96,13 @@ struct RTSPAuthenticator: Sendable { let kv = param.split(separator: "=", maxSplits: 1) guard kv.count == 2 else { continue } let key = kv[0].trimmingCharacters(in: .whitespaces).lowercased() - let value = kv[1].trimmingCharacters(in: .whitespaces) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + let value = unquote(String(kv[1])) + // Reject control characters (a bare CR/LF/tab/DEL) in a challenge value: + // they're illegal in an RFC 7230/7616 quoted-string, and echoing one into + // the Authorization header would inject control bytes into the request. + // Dropping the param fails the challenge safely (e.g. an empty nonce). + guard !value.unicodeScalars.contains(where: { $0.value < 0x20 || $0.value == 0x7F }) + else { continue } switch key { case "realm": realm = value @@ -100,38 +119,66 @@ struct RTSPAuthenticator: Sendable { opaque: opaque, algorithm: algorithm) } - private func generateDigestAuth(method: String, uri: String, state: DigestState) -> String { - let ha1 = md5Hex("\(credentials.username):\(state.realm):\(credentials.password)") - let ha2 = md5Hex("\(method):\(uri)") - - let response: String - if let qop = state.qop, qop.contains("auth") { - let nc = String(format: "%08x", state.nc + 1) - let cnonce = generateCNonce() - response = md5Hex("\(ha1):\(state.nonce):\(nc):\(cnonce):auth:\(ha2)") - var header = - "Digest username=\"\(credentials.username)\", realm=\"\(state.realm)\", " - + "nonce=\"\(state.nonce)\", uri=\"\(uri)\", " - + "response=\"\(response)\", qop=auth, nc=\(nc), cnonce=\"\(cnonce)\"" - if let opaque = state.opaque { - header += ", opaque=\"\(opaque)\"" - } - return header - } else { - response = md5Hex("\(ha1):\(state.nonce):\(ha2)") - var header = - "Digest username=\"\(credentials.username)\", realm=\"\(state.realm)\", " - + "nonce=\"\(state.nonce)\", uri=\"\(uri)\", response=\"\(response)\"" - if let opaque = state.opaque { - header += ", opaque=\"\(opaque)\"" - } - return header + /// Digest hash family parsed from the challenge `algorithm` token. + private enum DigestHash { case md5, sha256 } + + /// Map an `algorithm` token to a hash family and the `-sess` flag. Unknown + /// algorithms fall back to MD5 (RFC 7616's default). + private func parseAlgorithm(_ algorithm: String) -> (hash: DigestHash, sess: Bool) { + let lower = algorithm.lowercased() + let hash: DigestHash = lower.hasPrefix("sha-256") ? .sha256 : .md5 + return (hash, lower.hasSuffix("-sess")) + } + + /// True iff the server's `qop` list offers plain "auth" (not only "auth-int"). + private func qopOffersAuth(_ qop: String) -> Bool { + qop.split(separator: ",").contains { + $0.trimmingCharacters(in: .whitespaces) == "auth" + } + } + + private func hashHex(_ hash: DigestHash, _ input: String) -> String { + let bytes = Data(input.utf8) + switch hash { + case .md5: + return Insecure.MD5.hash(data: bytes).map { String(format: "%02x", $0) }.joined() + case .sha256: + return SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() } } - private func md5Hex(_ input: String) -> String { - let digest = Insecure.MD5.hash(data: Data(input.utf8)) - return digest.map { String(format: "%02x", $0) }.joined() + private mutating func generateDigestAuth(method: String, uri: String) -> String { + guard var state = digestState else { return generateBasicAuth() } + let (hash, sess) = parseAlgorithm(state.algorithm) + let cnonce = generateCNonce() + + var ha1 = hashHex(hash, "\(credentials.username):\(state.realm):\(credentials.password)") + if sess { + ha1 = hashHex(hash, "\(ha1):\(state.nonce):\(cnonce)") + } + let ha2 = hashHex(hash, "\(method):\(uri)") + + // Quoted-string fields are escaped (RFC 7616); the hash inputs above use the + // raw, unescaped values. `algorithm`, `qop=auth`, and `nc=` stay unquoted — + // they are RFC tokens, not quoted-strings. + var header = + "Digest username=\(quote(credentials.username)), realm=\(quote(state.realm)), " + + "nonce=\(quote(state.nonce)), uri=\(quote(uri)), algorithm=\(state.algorithm)" + + if let qop = state.qop, qopOffersAuth(qop) { + state.nc &+= 1 + let nc = String(format: "%08x", state.nc) + let response = hashHex(hash, "\(ha1):\(state.nonce):\(nc):\(cnonce):auth:\(ha2)") + header += ", response=\(quote(response)), qop=auth, nc=\(nc), cnonce=\(quote(cnonce))" + } else { + let response = hashHex(hash, "\(ha1):\(state.nonce):\(ha2)") + header += ", response=\(quote(response))" + } + if let opaque = state.opaque { + header += ", opaque=\(quote(opaque))" + } + digestState = state // persist the advanced nonce-count + return header } private func generateCNonce() -> String { @@ -139,13 +186,54 @@ struct RTSPAuthenticator: Sendable { return bytes.map { String(format: "%02x", $0) }.joined() } - /// Split auth parameters, handling quoted strings with commas inside. + /// Encode a string as an RFC 7616 quoted-string: escape `\` then `"`, wrap in + /// quotes. Used only for header construction — never for hash inputs. + private func quote(_ s: String) -> String { + let escaped = s.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + + /// Decode an auth parameter value: trim surrounding whitespace, and if it is a + /// quoted-string strip exactly one surrounding pair of quotes and unescape + /// `\\`/`\"` (RFC 7616). Bare token values pass through unchanged. A blanket + /// quote-trim would mishandle escaped quotes and over-strip. + private func unquote(_ raw: String) -> String { + let v = raw.trimmingCharacters(in: .whitespaces) + guard v.count >= 2, v.hasPrefix("\""), v.hasSuffix("\"") else { return v } + var out = "" + out.reserveCapacity(v.count) + var escaped = false + for ch in v.dropFirst().dropLast() { + if escaped { + out.append(ch) + escaped = false + } else if ch == "\\" { + escaped = true + } else { + out.append(ch) + } + } + if escaped { out.append("\\") } // dangling backslash: keep literal + return out + } + + /// Split auth parameters on commas, honoring quoted strings (commas inside a + /// quoted-string don't split) and backslash escaping inside quotes (so a `\"` + /// doesn't prematurely close the quote). private func splitAuthParams(_ params: String) -> [String] { var result: [String] = [] var current = "" var inQuotes = false + var escaped = false for ch in params { - if ch == "\"" { + if escaped { + current.append(ch) + escaped = false + } else if ch == "\\" && inQuotes { + current.append(ch) + escaped = true + } else if ch == "\"" { inQuotes.toggle() current.append(ch) } else if ch == "," && !inQuotes { diff --git a/Sources/IPCamKit/Client/DescribeParser.swift b/Sources/IPCamKit/Client/DescribeParser.swift index a6387e8..f00e7af 100644 --- a/Sources/IPCamKit/Client/DescribeParser.swift +++ b/Sources/IPCamKit/Client/DescribeParser.swift @@ -14,7 +14,8 @@ import Foundation /// - Returns: A Presentation with all parsed streams func parseDescribe( requestURL: String, - response: RTSPResponse + response: RTSPResponse, + onDiagnostic: (@Sendable (RTSPDiagnostic) -> Void)? = nil ) throws -> Presentation { // Validate content type (warn if missing, error if wrong) if let ct = response.contentType { @@ -25,11 +26,23 @@ func parseDescribe( } // Note: upstream logs a warning for missing Content-Type but still continues - // Determine base URL from Content-Base or Content-Location header - let baseURL = - response.header("Content-Base")?.trimmingCharacters(in: .whitespaces) + // Determine base URL from Content-Base or Content-Location header. Some + // cameras (e.g. Anjvision) send a scheme-less value such as + // "192.168.1.10:554/stream0/"; resolve those against the request URL's + // scheme instead of rejecting them. Port of retina 6972ac4. + let baseURL: String + if let raw = response.header("Content-Base")?.trimmingCharacters(in: .whitespaces) ?? response.header("Content-Location")?.trimmingCharacters(in: .whitespaces) - ?? requestURL + { + if hasURIScheme(raw) { + baseURL = raw + } else { + let scheme = requestURL.prefix(while: { $0 != ":" }) + baseURL = scheme.isEmpty ? raw : "\(scheme)://\(raw)" + } + } else { + baseURL = requestURL + } // Parse SDP body guard !response.body.isEmpty else { @@ -65,7 +78,15 @@ func parseDescribe( let stream = try parseMedia(baseURL: baseURL, mediaDescription: mediaDesc) streams.append(stream) } catch { + // A stream the camera offered that we can't parse (unsupported codec, bad + // rtpmap, ...) is dropped. If at least one other stream parses the session + // still starts, so surface the drop rather than losing it silently. errors.append(error.localizedDescription) + onDiagnostic?( + RTSPDiagnostic( + severity: .warning, + message: "Dropping unparseable \(mediaDesc.media) stream from SDP: " + + error.localizedDescription)) } } @@ -81,3 +102,14 @@ func parseDescribe( tool: tool ) } + +/// Whether `s` begins with a URI scheme (`scheme:` where the scheme starts with +/// a letter), per RFC 3986. Used to distinguish an absolute Content-Base from a +/// scheme-less one like "192.168.1.10:554/stream0/" (which has a `:` for the +/// port but no scheme). Mirrors retina's `Url::parse` succeed/fail check. +func hasURIScheme(_ s: String) -> Bool { + guard let colon = s.firstIndex(of: ":") else { return false } + let scheme = s[s.startIndex.. Void)? private let state: SessionState + /// Test-only override of the keepalive interval. When nil (production), the + /// interval is derived from the server's advertised session timeout. + private let keepaliveInterval: Duration? - public init( + public convenience init( url: String, credentials: Credentials? = nil, transport: Transport = .tcp, userAgent: String = "IPCamKit", onDiagnostic: (@Sendable (RTSPDiagnostic) -> Void)? = nil + ) { + self.init( + url: url, credentials: credentials, transport: transport, + userAgent: userAgent, onDiagnostic: onDiagnostic, keepaliveInterval: nil) + } + + /// Designated initializer. `keepaliveInterval` is internal and intended for + /// tests that need a short, deterministic keepalive cadence. + init( + url: String, + credentials: Credentials?, + transport: Transport, + userAgent: String, + onDiagnostic: (@Sendable (RTSPDiagnostic) -> Void)?, + keepaliveInterval: Duration? ) { self.url = url self.credentials = credentials self.transport = transport self.userAgent = userAgent self.onDiagnostic = onDiagnostic + self.keepaliveInterval = keepaliveInterval self.state = SessionState() } @@ -134,7 +159,8 @@ public final class RTSPClientSession: Sendable { credentials: credentials, transport: transport, userAgent: userAgent, - onDiagnostic: onDiagnostic + onDiagnostic: onDiagnostic, + keepaliveInterval: keepaliveInterval ) } @@ -189,7 +215,10 @@ public struct PublicAudioFrame: Sendable { /// Channel count, if known. public let channels: UInt16? - /// Number of RTP packets lost before this frame. + /// RTP packets lost before this frame: the forward gap in the RTP sequence + /// number. May be large (or spurious) if a camera's packetizer emits a wildly + /// forward-jumped sequence number, so treat it as a discontinuity indicator + /// rather than an exact loss count. public let loss: UInt16 } @@ -206,7 +235,10 @@ public struct PublicMetadataFrame: Sendable { /// SDP encoding name (e.g. `vnd.onvif.metadata`). public let encodingName: String - /// Number of RTP packets lost before this frame. + /// RTP packets lost before this frame: the forward gap in the RTP sequence + /// number. May be large (or spurious) if a camera's packetizer emits a wildly + /// forward-jumped sequence number, so treat it as a discontinuity indicator + /// rather than an exact loss count. public let loss: UInt16 } @@ -221,7 +253,10 @@ public struct PublicVideoFrame: Sendable { /// Whether this is a keyframe (IDR). public let isKeyframe: Bool - /// Number of RTP packets lost before this frame. + /// RTP packets lost before this frame: the forward gap in the RTP sequence + /// number. May be large (or spurious) if a camera's packetizer emits a wildly + /// forward-jumped sequence number, so treat it as a discontinuity indicator + /// rather than an exact loss count. public let loss: UInt16 /// SPS data if parameters changed with this frame. @@ -273,6 +308,16 @@ enum VideoDepacketizer: Sendable { // MARK: - Internal Session State +/// One demultiplexed data-plane datagram in a UDP session: an RTP or RTCP packet +/// tagged with its stream index. The per-socket readers fan these into a single +/// ordered consumer on the session actor. Control-plane traffic (keepalive / +/// TEARDOWN responses, connection close) is handled directly by the control +/// reader, off this lossy buffer, so it is never dropped under RTP backpressure. +private enum UDPTransportEvent: Sendable { + case rtp(streamIndex: Int, data: Data) + case rtcp(streamIndex: Int, data: Data) +} + /// Actor-based internal state for thread-safe session management. actor SessionState { private var connection: RTSPTransportConnection? @@ -292,26 +337,72 @@ actor SessionState { private var audioChannels: UInt16? private var applicationEncodingName: String? private var channelMappings = ChannelMappings() + /// UDP socket pairs keyed by stream index (UDP transport only); empty for TCP. + private var udpPairs: [Int: UDPPair] = [:] private var inorderParsers: [Int: InorderParser] = [:] private var userAgent: String? + /// Selected RTP transport; drives SETUP, the receive loop, and parser config. + private var transport: Transport = .tcp + /// RTSP server host from the URL; the UDP RTP/RTCP peer fallback when SETUP + /// omits an explicit `source`. + private var host: String? private var isPlaying = false + /// True while a `frames()` reader loop owns the connection. A second + /// concurrent reader would race the same socket and corrupt the RTP pipeline, + /// so it is rejected. + private var isStreaming = false private var onDiagnostic: (@Sendable (RTSPDiagnostic) -> Void)? + /// Session timeout advertised by the server in the SETUP `Session` header, + /// in seconds. Drives the keepalive interval while streaming. + private var sessionTimeoutSec: UInt32 = 60 + /// Whether the server advertised `GET_PARAMETER` in its OPTIONS `Public` + /// header. When true it is preferred over `OPTIONS` as the keepalive method. + private var getParameterSupported = false + /// Control responses awaited by `sendControlRequest`, keyed by CSeq. The + /// `streamFrames` reader resumes them as matching responses arrive. + private var pendingResponses: [UInt32: CheckedContinuation] = [:] + /// Per-CSeq timeout tasks, cancelled as soon as the waiter resolves so a fast + /// response doesn't leave a sleeping task lingering for the full timeout. + private var pendingTimeouts: [UInt32: Task] = [:] + /// The periodic keepalive task, active only while streaming. + private var keepaliveTask: Task? + /// Why the UDP control connection closed, recorded by the control reader so + /// the consumer loop can distinguish a clean stop() from a peer drop after the + /// event stream finishes. nil while the connection is healthy. + private var udpControlError: RTSPError? + /// Test-only override of the keepalive interval; nil derives it from the + /// server's advertised session timeout. + private var keepaliveIntervalOverride: Duration? + /// Bound on the UDP multiplexing buffer. RTP, RTCP, and control events queue + /// here between the socket readers and the actor-isolated consumer; under + /// normal operation the consumer keeps up and it never fills. If a stalled + /// consumer ever overruns it, the oldest datagrams are dropped (surfacing as + /// RTP loss) rather than the buffer growing without bound. + private static let udpEventBufferCount = 4096 func start( url: String, credentials: Credentials?, transport: Transport, userAgent: String, - onDiagnostic: (@Sendable (RTSPDiagnostic) -> Void)? + onDiagnostic: (@Sendable (RTSPDiagnostic) -> Void)?, + keepaliveInterval: Duration? = nil ) async throws -> SessionDescription { self.onDiagnostic = onDiagnostic + self.keepaliveIntervalOverride = keepaliveInterval + self.transport = transport + // Parse URL guard let urlComponents = URLComponents(string: url) else { throw RTSPError.connectionFailed("Invalid URL: \(url)") } - let host = urlComponents.host ?? "localhost" - let port = UInt16(urlComponents.port ?? 554) + let host = unbracketedHost(urlComponents.host ?? "localhost") + let rawPort = urlComponents.port ?? 554 + guard let port = UInt16(exactly: rawPort) else { + throw RTSPError.connectionFailed("Invalid port \(rawPort) in URL: \(url)") + } + self.host = host self.userAgent = userAgent // Set up authenticator @@ -324,13 +415,21 @@ actor SessionState { try await conn.connect(host: host, port: port) connection = conn + // OPTIONS (best-effort): discover whether the server supports + // GET_PARAMETER, which is the preferred keepalive method. A server that + // rejects or omits OPTIONS just leaves us defaulting to OPTIONS keepalive. + if let optionsResp = try? await sendRequest(method: .options, url: url) { + getParameterSupported = parseOptions(response: optionsResp).getParameterSupported + } + // DESCRIBE let describeResp = try await sendRequest( method: .describe, url: url, extraHeaders: [("Accept", "application/sdp")] ) - var presMut = try parseDescribe(requestURL: url, response: describeResp) + var presMut = try parseDescribe( + requestURL: url, response: describeResp, onDiagnostic: onDiagnostic) presentation = presMut self.url = url @@ -346,24 +445,8 @@ actor SessionState { if let videoIdx = videoIdx { let stream = presMut.streams[videoIdx] - let setupURL = stream.control ?? url - var setupHeaders: [(String, String)] = [] - if transport == .tcp { - let channelId = channelMappings.nextUnassigned() ?? 0 - setupHeaders.append( - ( - "Transport", - "RTP/AVP/TCP;unicast;interleaved=\(channelId)-\(channelId + 1)" - )) - try channelMappings.assign(channelId: channelId, streamIndex: videoIdx) - } else { - setupHeaders.append(("Transport", "RTP/AVP;unicast")) - } - - let setupResp = try await sendRequest( - method: .setup, url: setupURL, extraHeaders: setupHeaders) - let setup = try parseSetup(response: setupResp) - sessionId = setup.session.id + let setup = try await setupStream( + controlURL: stream.control ?? url, streamIndex: videoIdx) videoSetupSSRC = setup.ssrc presMut.streams[videoIdx].state = .setup( StreamStateInit(ssrc: setup.ssrc, initialSeq: nil, initialRtptime: nil, ctx: .dummy)) @@ -378,36 +461,8 @@ actor SessionState { if let audioIdx = audioIdx { let audioStream = presMut.streams[audioIdx] - let audioSetupURL = audioStream.control ?? url - var audioSetupHeaders: [(String, String)] = [] - if transport == .tcp { - let audioChannelId = channelMappings.nextUnassigned() ?? 2 - audioSetupHeaders.append( - ( - "Transport", - "RTP/AVP/TCP;unicast;interleaved=\(audioChannelId)-\(audioChannelId + 1)" - )) - try channelMappings.assign( - channelId: audioChannelId, streamIndex: audioIdx) - } else { - audioSetupHeaders.append(("Transport", "RTP/AVP;unicast")) - } - if let sid = sessionId { - audioSetupHeaders.append(("Session", sid)) - } - let audioSetupResp = try await sendRequest( - method: .setup, url: audioSetupURL, - extraHeaders: audioSetupHeaders) - let audioSetup = try parseSetup(response: audioSetupResp) - if let prev = sessionId, prev != audioSetup.session.id { - onDiagnostic?( - RTSPDiagnostic( - severity: .warning, - message: - "Camera issued a new Session ID at audio SETUP " - + "(\(prev) -> \(audioSetup.session.id)); rolling forward.")) - } - sessionId = audioSetup.session.id + let audioSetup = try await setupStream( + controlURL: audioStream.control ?? url, streamIndex: audioIdx) audioSetupSSRC = audioSetup.ssrc presMut.streams[audioIdx].state = .setup( StreamStateInit(ssrc: audioSetup.ssrc, initialSeq: nil, initialRtptime: nil, ctx: .dummy)) @@ -429,38 +484,9 @@ actor SessionState { if let idx = applicationIdx { let applicationStream = presMut.streams[idx] - let applicationSetupURL = applicationStream.control ?? url - var applicationSetupHeaders: [(String, String)] = [] - if transport == .tcp { - let applicationChannelId = channelMappings.nextUnassigned() ?? 4 - applicationSetupHeaders.append( - ( - "Transport", - "RTP/AVP/TCP;unicast;interleaved=\(applicationChannelId)-\(applicationChannelId + 1)" - )) - try channelMappings.assign( - channelId: applicationChannelId, streamIndex: idx) - } else { - applicationSetupHeaders.append(("Transport", "RTP/AVP;unicast")) - } - if let sid = sessionId { - applicationSetupHeaders.append(("Session", sid)) - } - do { - let applicationSetupResp = try await sendRequest( - method: .setup, url: applicationSetupURL, - extraHeaders: applicationSetupHeaders) - let applicationSetup = try parseSetup(response: applicationSetupResp) - if let prev = sessionId, prev != applicationSetup.session.id { - onDiagnostic?( - RTSPDiagnostic( - severity: .warning, - message: - "Camera issued a new Session ID at application SETUP " - + "(\(prev) -> \(applicationSetup.session.id)); rolling forward.")) - } - sessionId = applicationSetup.session.id + let applicationSetup = try await setupStream( + controlURL: applicationStream.control ?? url, streamIndex: idx) applicationSetupSSRC = applicationSetup.ssrc presMut.streams[idx].state = .setup( StreamStateInit( @@ -507,6 +533,26 @@ actor SessionState { try parsePlay(response: playResp, presentation: &presMut) presentation = presMut + // Surface (once) any interleaved media the camera streamed on the control + // channel before its PLAY response completed — those frames were dropped. + let droppedPrePlay = await conn.droppedInterleavedBeforeResponse + if droppedPrePlay > 0 { + onDiagnostic?( + RTSPDiagnostic( + severity: .warning, + message: "Dropped \(droppedPrePlay) interleaved frame(s) on the control channel " + + "before the PLAY response completed; camera streamed before PLAY.")) + } + + // UDP NAT/firewall hole-punch: now that the server is sending (post-PLAY), + // poke each local RTP/RTCP port toward its peer so inbound datagrams can + // traverse a NAT. Best-effort and a no-op on loopback/LAN. + if transport == .udp { + for pair in udpPairs.values { + pair.holePunch() + } + } + // Initialize video depacketizer + timeline + inorder parser. Conditional // on a successful video SETUP — when no video stream was set up, // `videoStreamIndex` is nil and we skip the entire video pipeline. @@ -524,6 +570,19 @@ actor SessionState { clockRate: stream.clockRateHz, formatSpecificParams: stream.formatSpecificParams)) } + // If the SDP advertised parameter sets (sprop-*) but the depacketizer + // couldn't parse them, it silently falls back to in-band parameters; + // surface that, since the first decodable frame may be delayed. + if let fmtp = stream.formatSpecificParams, + fmtp.lowercased().contains("sprop"), + depacketizer?.videoParameters == nil + { + onDiagnostic?( + RTSPDiagnostic( + severity: .warning, + message: "Video fmtp advertised sprop parameter sets but they could not be " + + "parsed; falling back to in-band parameters.")) + } videoClockRate = stream.clockRateHz var videoStart: UInt32? @@ -532,7 +591,10 @@ actor SessionState { if case .setup(let init_) = presMut.streams[videoIdx].state { videoStart = init_.initialRtptime - if let seq = init_.initialSeq, seq != 0, seq != 1 { + // Adopt the RTP-Info initial sequence so loss before the first received + // packet is detected. Only ignore the `seq=0;rtptime=0` placeholder some + // cameras emit instead of a real seed (finding #50). + if let seq = init_.initialSeq, !(seq == 0 && (init_.initialRtptime ?? 0) == 0) { videoSeq = seq } if let s = init_.ssrc { videoSsrc = s } @@ -565,22 +627,39 @@ actor SessionState { if case .setup(let init_) = presMut.streams[audioIdx].state { audioStart = init_.initialRtptime - if let seq = init_.initialSeq, seq != 0, seq != 1 { + // See the video seed above (finding #50): only the placeholder is ignored. + if let seq = init_.initialSeq, !(seq == 0 && (init_.initialRtptime ?? 0) == 0) { audioSeq = seq } if let s = init_.ssrc { resolvedAudioSsrc = s } } - let audioTimeline = try Timeline( - start: audioStart, clockRate: audioStream.clockRateHz) - inorderParsers[audioIdx] = InorderParser( - ssrc: resolvedAudioSsrc, nextSeq: audioSeq, - isTcp: transport == .tcp, timeline: audioTimeline, - onDiagnostic: onDiagnostic) - resolvedAudioCodec = publicAudioCodec( - from: audioStream.encodingName) - resolvedAudioRate = audioStream.clockRateHz - resolvedAudioChannels = audioStream.channels + do { + let audioTimeline = try Timeline( + start: audioStart, clockRate: audioStream.clockRateHz) + inorderParsers[audioIdx] = InorderParser( + ssrc: resolvedAudioSsrc, nextSeq: audioSeq, + isTcp: transport == .tcp, timeline: audioTimeline, + onDiagnostic: onDiagnostic) + resolvedAudioCodec = publicAudioCodec( + from: audioStream.encodingName) + resolvedAudioRate = audioStream.clockRateHz + resolvedAudioChannels = audioStream.channels + } catch { + // Audio is best-effort: a broken clock rate must not abort the + // whole session. Disable audio and carry on (mirrors metadata). + onDiagnostic?( + RTSPDiagnostic( + severity: .warning, + message: + "Failed to initialize audio timeline: \(error); " + + "audio will not be delivered.")) + audioDepacketizer = nil + audioStreamIndex = nil + audioEncodingName = nil + audioClockRate = nil + audioChannels = nil + } } else { // Audio SETUP succeeded but the depacketizer rejected the format // (e.g. malformed AAC fmtp). Null the audio state so packets on @@ -612,7 +691,8 @@ actor SessionState { if case .setup(let init_) = presMut.streams[applicationIdx].state { applicationStart = init_.initialRtptime - if let seq = init_.initialSeq, seq != 0, seq != 1 { + // See the video seed above (finding #50): only the placeholder is ignored. + if let seq = init_.initialSeq, !(seq == 0 && (init_.initialRtptime ?? 0) == 0) { applicationSeq = seq } if let s = init_.ssrc { resolvedApplicationSsrc = s } @@ -714,127 +794,470 @@ actor SessionState { return } - while isPlaying { - let msg = try await conn.receiveMessage() + // frames() may be consumed by only one reader at a time; a second concurrent + // reader would race the same connection. The check-and-set is atomic (no + // await between them on this actor). + guard !isStreaming else { + continuation.finish( + throwing: RTSPError.invalidState("frames() is already being consumed")) + return + } + isStreaming = true + defer { isStreaming = false } + + startKeepalive() + defer { + keepaliveTask?.cancel() + keepaliveTask = nil + } + + switch transport { + case .tcp: + try await streamFramesTCP(conn: conn, continuation: continuation) + case .udp: + try await streamFramesUDP(conn: conn, continuation: continuation) + } + + continuation.finish() + } + + /// TCP-interleaved reader loop. Owns the single TCP connection: it + /// demultiplexes interleaved RTP/RTCP and routes RTSP responses (keepalive / + /// TEARDOWN) back to their waiters. Runs until the socket closes — by stop() + /// (clean) or by the peer (propagates the error). + private func streamFramesTCP( + conn: RTSPTransportConnection, + continuation: AsyncThrowingStream.Continuation + ) async throws { + while true { + let msg: RTSPMessage + do { + msg = try await conn.receiveMessage() + } catch { + // After stop() set isPlaying = false, the socket close is expected — + // end cleanly. Otherwise the peer dropped us; propagate the error. + if !isPlaying { break } + throw error + } + + // Route control responses (keepalive / TEARDOWN) to their awaiting sender. + if case .response(let resp) = msg { + if let cseq = resp.cseq { resumePending(cseq, .success(resp)) } + continue + } guard case .data(let interleaved) = msg else { continue } - guard let mapping = channelMappings.lookup(interleaved.channelId) else { continue } + guard let mapping = channelMappings.lookup(interleaved.channelId) else { + // The camera sent interleaved data on a channel we never negotiated. + // Drop it (can't route it) but surface it once per channel. + warnStreamOnce( + "channel.\(interleaved.channelId)", .warning, + "Interleaved data on unassigned channel \(interleaved.channelId); dropped.") + continue + } if mapping.channelType == .rtp { - guard var parser = inorderParsers[mapping.streamIndex] else { continue } - - if let videoIdx = videoStreamIndex, mapping.streamIndex == videoIdx { - guard var depkt = depacketizer else { continue } - if let pkt = try parser.rtp( - data: interleaved.data, ctx: .dummy, - streamId: mapping.streamIndex, streamCtx: .dummy) - { - if pkt.payload.isEmpty { - onDiagnostic?( - RTSPDiagnostic( - severity: .warning, - message: "Empty video RTP payload from camera; packet skipped.")) - } - do { - try depkt.push(pkt) - } catch { - throw RTSPError.depacketizationError("Video push failed: \(error)") - } - while let result = depkt.pull() { - switch result { - case .success(.videoFrame(let frame)): - let publicFrame = convertFrame(frame, depacketizer: depkt) - continuation.yield(.video(publicFrame)) - case .failure(let err): - throw RTSPError.depacketizationError("Video depacketization failed: \(err)") - default: - break - } - } - } - depacketizer = depkt - } else if let audioIdx = audioStreamIndex, mapping.streamIndex == audioIdx { - guard var depkt = audioDepacketizer else { continue } - if let pkt = try parser.rtp( - data: interleaved.data, ctx: .dummy, - streamId: mapping.streamIndex, streamCtx: .dummy) - { - try depkt.push(pkt) - while let result = depkt.pull() { - switch result { - case .success(.audioFrame(let frame)): - let publicFrame = PublicAudioFrame( - data: frame.data, - timestamp: frame.timestamp.elapsedSeconds, - codec: publicAudioCodec( - from: audioEncodingName ?? ""), - sampleRate: audioClockRate ?? 0, - channels: audioChannels, - loss: frame.loss - ) - continuation.yield(.audio(publicFrame)) - case .failure(let err): - throw RTSPError.depacketizationError("Audio depacketization failed: \(err)") - default: - break - } - } - } - audioDepacketizer = depkt - } else if let applicationIdx = applicationStreamIndex, - mapping.streamIndex == applicationIdx - { - guard var depkt = applicationDepacketizer else { continue } - if let pkt = try parser.rtp( - data: interleaved.data, ctx: .dummy, - streamId: mapping.streamIndex, streamCtx: .dummy) - { - try depkt.push(pkt) - while let result = depkt.pull() { - switch result { - case .success(.metadataFrame(let frame)): - let publicFrame = PublicMetadataFrame( - data: frame.data, - timestamp: frame.timestamp.elapsedSeconds, - encodingName: applicationEncodingName ?? "", - loss: frame.loss - ) - continuation.yield(.metadata(publicFrame)) - case .failure(let err): - throw RTSPError.depacketizationError("Metadata depacketization failed: \(err)") - default: - break - } - } - } - applicationDepacketizer = depkt + try routeRTP( + streamIndex: mapping.streamIndex, data: interleaved.data, continuation: continuation) + } else if mapping.channelType == .rtcp { + try routeRTCP( + streamIndex: mapping.streamIndex, data: interleaved.data, continuation: continuation) + } + } + } + + /// UDP reader loop. RTP and RTCP arrive on per-stream UDP sockets while RTSP + /// control (keepalive / TEARDOWN responses) stays on the TCP connection, so + /// the three sources are multiplexed through one ordered event stream and fed + /// to the same routing pipeline as TCP. Runs until the TCP control connection + /// closes — by stop() (clean) or by the peer (propagates the error). + private func streamFramesUDP( + conn: RTSPTransportConnection, + continuation: AsyncThrowingStream.Continuation + ) async throws { + udpControlError = nil + let (events, eventCont) = AsyncStream.makeStream( + of: UDPTransportEvent.self, + bufferingPolicy: .bufferingNewest(Self.udpEventBufferCount)) + + // Per-stream UDP receivers feed RTP/RTCP datagrams into the event stream. + // Each socket's DispatchSource is serial, so per-stream packet order holds. + let diag = onDiagnostic + for (idx, pair) in udpPairs { + pair.startReceiving( + onRTP: { eventCont.yield(.rtp(streamIndex: idx, data: $0)) }, + onRTCP: { eventCont.yield(.rtcp(streamIndex: idx, data: $0)) }, + onError: { error in + // Surface the terminal receive error so a NAT'd camera whose UDP flow + // dies isn't a silent stall. (Control stays on TCP; keepalive/TEARDOWN + // still work.) + diag?( + RTSPDiagnostic( + severity: .warning, + message: "UDP receive on stream \(idx) stopped: \(error)")) + }) + } + + // The TCP connection now carries only control responses (no interleaved + // data). The control reader handles keepalive/TEARDOWN responses directly + // (off the lossy data buffer) and, on close, records why and ends the event + // stream so the consumer unblocks even if no more datagrams arrive. + let controlReader = Task { [weak self] in + while true { + let msg: RTSPMessage + do { + msg = try await conn.receiveMessage() + } catch { + let rtspError = (error as? RTSPError) ?? .connectionFailed("\(error)") + await self?.recordUDPControlClose(rtspError) + eventCont.finish() + return + } + if case .response(let resp) = msg, let cseq = resp.cseq { + await self?.resumePending(cseq, .success(resp)) } + // Interleaved data isn't expected on a UDP session; ignore it. + } + } - inorderParsers[mapping.streamIndex] = parser - } else if mapping.channelType == .rtcp { - guard var parser = inorderParsers[mapping.streamIndex] else { continue } - if let rtcpPkt = try parser.rtcp( - ctx: .dummy, streamId: mapping.streamIndex, data: interleaved.data) - { + // The consumer owns teardown: stop the control reader and release the UDP + // sockets (stop() also closes the pairs — close() is idempotent). The reader + // task may linger blocked on receiveMessage until stop() closes the TCP + // connection; that is the documented contract for releasing the session. + defer { + controlReader.cancel() + eventCont.finish() + for pair in udpPairs.values { pair.close() } + } + + for await event in events { + switch event { + case .rtp(let idx, let data): + try routeRTP(streamIndex: idx, data: data, continuation: continuation) + case .rtcp(let idx, let data): + try routeRTCP(streamIndex: idx, data: data, continuation: continuation) + } + } + + // The stream finished, which only happens when the control connection + // closed. A clean stop() (isPlaying == false) ends here; a peer drop while + // still playing propagates the recorded transport error. + if isPlaying, let error = udpControlError { + throw error + } + } + + /// Record the error that closed the UDP session's TCP control connection. + /// Called by the control reader so the consumer can classify the close. + private func recordUDPControlClose(_ error: RTSPError) { + udpControlError = error + } + + /// One-shot latch keys for per-stream conditions that can recur per packet or + /// per frame, so a sloppy/hostile camera can't flood `onDiagnostic`. + private var warnedStreamConditions: Set = [] + + /// Emit `message` only the first time `key` is seen this session. + private func warnStreamOnce( + _ key: String, _ severity: RTSPDiagnostic.Severity, _ message: @autoclosure () -> String + ) { + guard warnedStreamConditions.insert(key).inserted else { return } + onDiagnostic?(RTSPDiagnostic(severity: severity, message: message())) + } + + /// Surface (once each) the FU-reassembly anomalies the video depacketizer + /// tracks — an inconsistent FU NAL header or an RFC-forbidden single-fragment + /// FU — both tolerated but worth telling the consumer about. + private func surfaceVideoAnomalies(_ depkt: VideoDepacketizer) { + let inconsistentFu: Bool + let singleFragmentFu: Bool + switch depkt { + case .h264(let d): + inconsistentFu = d.seenInconsistentFuANalHdr + singleFragmentFu = d.seenSingleFragmentFuA + case .h265(let d): + inconsistentFu = d.seenInconsistentFuNalHdr + singleFragmentFu = d.seenSingleFragmentFu + } + if inconsistentFu { + warnStreamOnce( + "video.fuHeaderInconsistent", .warning, + "Camera sent FU fragments with an inconsistent NAL header; tolerated.") + } + if singleFragmentFu { + warnStreamOnce( + "video.singleFragmentFu", .info, + "Camera sent a single-fragment FU (RFC-forbidden); treated as a complete NAL.") + } + } + + /// Route one RTP packet to its stream's depacketizer, yielding any completed + /// frames. Shared by the TCP and UDP reader loops; a missing parser or + /// depacketizer (a disabled stream) drops the packet. A malformed wire packet + /// or a per-frame depacketization failure drops that packet/frame and emits a + /// rate-limited diagnostic rather than tearing down the whole stream — only + /// the opt-in `.abortSession` SSRC policy still propagates fatally. + private func routeRTP( + streamIndex: Int, data: Data, + continuation: AsyncThrowingStream.Continuation + ) throws { + guard var parser = inorderParsers[streamIndex] else { return } + defer { inorderParsers[streamIndex] = parser } + + if let videoIdx = videoStreamIndex, streamIndex == videoIdx { + guard var depkt = depacketizer else { return } + defer { depacketizer = depkt } + guard + let pkt = try parser.rtp( + data: data, ctx: .dummy, streamId: streamIndex, streamCtx: .dummy) + else { return } + if pkt.payload.isEmpty { + warnStreamOnce( + "video.empty", .warning, "Empty video RTP payload from camera; packets skipped.") + } + do { + try depkt.push(pkt) + } catch { + warnStreamOnce( + "video.push", .error, "Video depacketizer push failed; packet dropped: \(error)") + return + } + surfaceVideoAnomalies(depkt) + while let result = depkt.pull() { + switch result { + case .success(.videoFrame(let frame)): + continuation.yield(.video(convertFrame(frame, depacketizer: depkt))) + case .failure(let err): + warnStreamOnce( + "video.pull", .error, "Video depacketization failed; frame dropped: \(err)") + default: + break + } + } + } else if let audioIdx = audioStreamIndex, streamIndex == audioIdx { + guard var depkt = audioDepacketizer else { return } + defer { audioDepacketizer = depkt } + guard + let pkt = try parser.rtp( + data: data, ctx: .dummy, streamId: streamIndex, streamCtx: .dummy) + else { return } + do { + try depkt.push(pkt) + } catch { + warnStreamOnce( + "audio.push", .error, "Audio depacketizer push failed; packet dropped: \(error)") + return + } + while let result = depkt.pull() { + switch result { + case .success(.audioFrame(let frame)): continuation.yield( - .rtcp( - PublicRTCPPacket(timestamp: rtcpPkt.rtpTimestamp?.elapsedSeconds, data: rtcpPkt.raw))) + .audio( + PublicAudioFrame( + data: frame.data, + timestamp: frame.timestamp.elapsedSeconds, + codec: publicAudioCodec(from: audioEncodingName ?? ""), + sampleRate: audioClockRate ?? 0, + channels: audioChannels, + loss: frame.loss))) + case .failure(let err): + warnStreamOnce( + "audio.pull", .error, "Audio depacketization failed; frame dropped: \(err)") + default: + break + } + } + } else if let applicationIdx = applicationStreamIndex, streamIndex == applicationIdx { + guard var depkt = applicationDepacketizer else { return } + defer { applicationDepacketizer = depkt } + guard + let pkt = try parser.rtp( + data: data, ctx: .dummy, streamId: streamIndex, streamCtx: .dummy) + else { return } + do { + try depkt.push(pkt) + } catch { + warnStreamOnce( + "metadata.push", .error, "Metadata depacketizer push failed; packet dropped: \(error)") + return + } + while let result = depkt.pull() { + switch result { + case .success(.metadataFrame(let frame)): + continuation.yield( + .metadata( + PublicMetadataFrame( + data: frame.data, + timestamp: frame.timestamp.elapsedSeconds, + encodingName: applicationEncodingName ?? "", + loss: frame.loss))) + case .failure(let err): + warnStreamOnce( + "metadata.pull", .error, "Metadata depacketization failed; frame dropped: \(err)") + default: + break } - inorderParsers[mapping.streamIndex] = parser } } + } - continuation.finish() + /// Route one RTCP packet to its stream's parser, yielding a public RTCP item + /// when a sender report is parsed. Shared by both reader loops. + private func routeRTCP( + streamIndex: Int, data: Data, + continuation: AsyncThrowingStream.Continuation + ) throws { + guard var parser = inorderParsers[streamIndex] else { return } + defer { inorderParsers[streamIndex] = parser } + if let rtcpPkt = try parser.rtcp(ctx: .dummy, streamId: streamIndex, data: data) { + continuation.yield( + .rtcp( + PublicRTCPPacket( + timestamp: rtcpPkt.rtpTimestamp?.elapsedSeconds, data: rtcpPkt.raw))) + } } func stop() async { + let wasPlaying = isPlaying isPlaying = false - if let _ = connection, let sid = sessionId, let url = self.url { - // Send TEARDOWN (best-effort) using sendRequest to include Auth and other headers - _ = try? await sendRequest(method: .teardown, url: url, extraHeaders: [("Session", sid)]) + keepaliveTask?.cancel() + keepaliveTask = nil + + if connection != nil, sessionId != nil, let url = self.url { + // TEARDOWN (best-effort). While playing, the reader loop owns the socket, + // so route the response through it — a competing read here would let the + // reader consume the TEARDOWN response and hang this call until the + // server's session timeout (~60s). Before PLAY there is no reader, so the + // normal request path (which reads its own response) is safe. + if wasPlaying { + _ = try? await sendControlRequest(method: .teardown, timeoutSeconds: 5) + } else { + _ = try? await sendRequest(method: .teardown, url: url) + } } + + // Closing the socket unblocks the reader's pending receive (it ends cleanly + // because isPlaying is now false) and lets us fail any outstanding waiter. await connection?.close() + failAllPending(RTSPError.unexpectedDisconnection) connection = nil + + // Release any UDP RTP/RTCP sockets (no-op for TCP). close() is idempotent, + // so the UDP reader loop's defer closing them too is harmless. + for pair in udpPairs.values { + pair.close() + } + udpPairs.removeAll() + } + + // MARK: - Control channel (keepalive / TEARDOWN while streaming) + + /// Send a control request on the streaming connection and await its response, + /// which the `streamFrames` reader loop routes back by CSeq. Used while RTP is + /// interleaved on the same TCP socket: the reader owns the socket, so we must + /// not issue a competing read here. Bounded by `timeoutSeconds`. + private func sendControlRequest( + method: RTSPMethod, + extraHeaders: [(String, String)] = [], + timeoutSeconds: Double = 10 + ) async throws -> RTSPResponse { + guard let conn = connection, let url = self.url else { + throw RTSPError.connectionFailed("Not connected") + } + let cseq = nextCSeq() + var request = RTSPRequest(method: method, url: url) + request.setHeader("CSeq", value: "\(cseq)") + if let userAgent = userAgent, !userAgent.isEmpty { + request.setHeader("User-Agent", value: userAgent) + } + if let sid = sessionId { + request.setHeader("Session", value: sid) + } + if authenticator?.hasChallenge == true, + let authHeader = authenticator!.authorize(method: method.rawValue, uri: url) + { + request.setHeader("Authorization", value: authHeader) + } + for (name, value) in extraHeaders { + request.setHeader(name, value: value) + } + let data = RTSPSerializer().serialize(request) + + return try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + // Register the waiter BEFORE the bytes go out so the reader can never see + // the response before we are ready to receive it. + pendingResponses[cseq] = continuation + Task { + do { + try await conn.send(data) + } catch { + self.resumePending(cseq, .failure(.connectionFailed("control send failed: \(error)"))) + } + } + pendingTimeouts[cseq] = Task { + try? await Task.sleep(for: .seconds(timeoutSeconds)) + self.resumePending(cseq, .failure(.timeout)) + } + } + } + + /// Resume (exactly once) the waiter for `cseq`, if still pending. The + /// remove-then-resume is actor-isolated, so the reader, a send failure, and + /// the timeout can race safely — the first to run wins, the rest no-op. + private func resumePending(_ cseq: UInt32, _ result: Result) { + // Cancel the (now-moot) timeout task; its only action after the sleep is a + // no-op resumePending, so cancelling is always safe. + pendingTimeouts.removeValue(forKey: cseq)?.cancel() + guard let continuation = pendingResponses.removeValue(forKey: cseq) else { return } + switch result { + case .success(let response): continuation.resume(returning: response) + case .failure(let error): continuation.resume(throwing: error) + } + } + + /// Fail every outstanding control waiter (e.g. when the connection is torn + /// down), so no `sendControlRequest` is left suspended. + private func failAllPending(_ error: RTSPError) { + for task in pendingTimeouts.values { task.cancel() } + pendingTimeouts.removeAll() + let waiters = pendingResponses + pendingResponses.removeAll() + for (_, continuation) in waiters { + continuation.resume(throwing: error) + } + } + + /// Start the periodic keepalive at roughly half the server's advertised + /// session timeout, preferring GET_PARAMETER when the server advertised it, + /// else OPTIONS. Runs until the reader loop ends or `stop()` cancels it. + private func startKeepalive() { + keepaliveTask?.cancel() + let period = keepaliveIntervalOverride ?? .seconds(max(1.0, Double(sessionTimeoutSec) / 2.0)) + let method: RTSPMethod = getParameterSupported ? .getParameter : .options + keepaliveTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: period) + guard !Task.isCancelled, let self else { return } + await self.sendKeepalive(method: method) + } + } + } + + /// Send one keepalive request, surfacing the outcome as a diagnostic. Failures + /// are non-fatal — the stream keeps running and the next tick retries. + private func sendKeepalive(method: RTSPMethod) async { + guard isPlaying else { return } + do { + _ = try await sendControlRequest(method: method, timeoutSeconds: 10) + onDiagnostic?( + RTSPDiagnostic( + severity: .info, message: "RTSP keepalive (\(method.rawValue)) acknowledged")) + } catch { + onDiagnostic?( + RTSPDiagnostic( + severity: .warning, message: "RTSP keepalive (\(method.rawValue)) failed: \(error)")) + } } // MARK: - Private Helpers @@ -859,8 +1282,8 @@ actor SessionState { request.setHeader("Session", value: sid) } - if let auth = authenticator, auth.hasChallenge { - if let authHeader = auth.authorize(method: method.rawValue, uri: url) { + if authenticator?.hasChallenge == true { + if let authHeader = authenticator!.authorize(method: method.rawValue, uri: url) { request.setHeader("Authorization", value: authHeader) } } @@ -871,27 +1294,46 @@ actor SessionState { let resp = try await conn.sendRequest(request) - // Handle 401 Unauthorized — retry with auth + // Handle 401 Unauthorized — retry with auth. if resp.statusCode == 401 { - if var auth = authenticator, - let wwwAuth = resp.header("WWW-Authenticate") - { - auth.handleChallenge(wwwAuth) - authenticator = auth - - var retryRequest = RTSPRequest(method: method, url: url) - retryRequest.setHeader("CSeq", value: "\(nextCSeq())") - if let authHeader = auth.authorize(method: method.rawValue, uri: url) { - retryRequest.setHeader("Authorization", value: authHeader) + if var auth = authenticator { + // Feed every challenge (a server may offer Basic and Digest on separate + // WWW-Authenticate lines) so Digest wins regardless of ordering. + for challenge in resp.headers(named: "WWW-Authenticate") { + auth.handleChallenge(challenge) } - for (name, value) in extraHeaders { - retryRequest.setHeader(name, value: value) - } - let retryResp = try await conn.sendRequest(retryRequest) - if retryResp.statusCode == 401 { - throw RTSPError.authenticationFailed + authenticator = auth + if auth.hasChallenge { + // Rebuild the retry the same way the first attempt did: a 401 on + // SETUP/PLAY requires the Session (and User-Agent) headers preserved, + // or the retry is rejected. Authorization is set last so a freshly + // computed digest isn't clobbered by a caller-supplied extra header. + var retryRequest = RTSPRequest(method: method, url: url) + retryRequest.setHeader("CSeq", value: "\(nextCSeq())") + if let userAgent = userAgent, !userAgent.isEmpty { + retryRequest.setHeader("User-Agent", value: userAgent) + } + if let sid = sessionId { + retryRequest.setHeader("Session", value: sid) + } + for (name, value) in extraHeaders { + retryRequest.setHeader(name, value: value) + } + if let authHeader = auth.authorize(method: method.rawValue, uri: url) { + retryRequest.setHeader("Authorization", value: authHeader) + } + // Persist after authorize so the advanced nonce-count survives. + authenticator = auth + let retryResp = try await conn.sendRequest(retryRequest) + if retryResp.statusCode == 401 { + throw RTSPError.authenticationFailed + } + guard retryResp.statusCode >= 200 && retryResp.statusCode < 300 else { + throw RTSPError.sessionSetupFailed( + statusCode: Int(retryResp.statusCode), reason: retryResp.reasonPhrase) + } + return retryResp } - return retryResp } throw RTSPError.authenticationFailed } @@ -911,6 +1353,131 @@ actor SessionState { return cseq } + /// SETUP one stream using the selected transport. Returns the parsed SETUP + /// response; both paths roll the session id forward and, as a side effect, + /// record where the stream's data will arrive (a TCP interleaved channel or a + /// bound UDP socket pair in `udpPairs`). + private func setupStream( + controlURL: String, streamIndex: Int + ) async throws -> SetupResponse { + switch transport { + case .tcp: + return try await setupStreamTCP(controlURL: controlURL, streamIndex: streamIndex) + case .udp: + return try await setupStreamUDP(controlURL: controlURL, streamIndex: streamIndex) + } + } + + /// Adopt the session id and timeout from a SETUP response, warning when the + /// camera renumbers the session mid-handshake. + private func adoptSession(_ setup: SetupResponse) { + if let prev = sessionId, prev != setup.session.id { + onDiagnostic?( + RTSPDiagnostic( + severity: .warning, + message: + "Camera issued a new Session ID at SETUP " + + "(\(prev) -> \(setup.session.id)); rolling forward.")) + } + sessionId = setup.session.id + sessionTimeoutSec = setup.session.timeoutSec + } + + /// SETUP one stream over TCP-interleaved transport, assigning its channel + /// pair. We request the next free even channel, but adopt the server's + /// interleaved channel when it renumbers to a different free even channel so + /// routing matches where the data actually arrives (the `Session` header is + /// added by `sendRequest`). + private func setupStreamTCP( + controlURL: String, streamIndex: Int + ) async throws -> SetupResponse { + let requested = channelMappings.nextUnassigned() ?? 0 + let headers = [ + ( + "Transport", + "RTP/AVP/TCP;unicast;interleaved=\(requested)-\(Int(requested) + 1)" + ) + ] + let resp = try await sendRequest( + method: .setup, url: controlURL, extraHeaders: headers) + let setup = try parseSetup(response: resp) + adoptSession(setup) + + let channel: UInt8 + if let server = setup.channelId, server % 2 == 0, + channelMappings.lookup(server) == nil + { + channel = server + } else { + channel = requested + } + try channelMappings.assign(channelId: channel, streamIndex: streamIndex) + return setup + } + + /// SETUP one stream over a UDP socket pair. Binds a local even/odd RTP/RTCP + /// pair, advertises it via `client_port`, then connects the sockets to the + /// server's `server_port` (RTCP = RTP + 1, enforced by `parseSetup`). The + /// connected pair is recorded in `udpPairs`; on any failure its sockets are + /// released before rethrowing. + /// + /// The local socket family (IPv4 / IPv6) is taken from the resolved control + /// connection so it matches the server, and the RTP/RTCP peer is chosen from a + /// candidate of the same family — IPv4 and IPv6 peers (including `[::1]`) both + /// work. + private func setupStreamUDP( + controlURL: String, streamIndex: Int + ) async throws -> SetupResponse { + // Pick the local socket family from the resolved control-connection peer + // (the same host the server's RTP/RTCP will come from). Fall back to the URL + // host's family, then IPv4. Resolved before SETUP since `bind()` needs it. + let resolved = await connection?.resolvedRemote() + let peerIsIPv6: Bool + if let resolved { + peerIsIPv6 = resolved.isIPv6 + } else if let host, UDPPair.isNumericIPv6(host) { + peerIsIPv6 = true + } else { + peerIsIPv6 = false + } + let family: UDPAddressFamily = peerIsIPv6 ? .ipv6 : .ipv4 + + let pair = try await UDPPair.bind(family: family) + do { + let headers = [ + ("Transport", "RTP/AVP;unicast;client_port=\(pair.rtpPort)-\(pair.rtcpPort)") + ] + let resp = try await sendRequest( + method: .setup, url: controlURL, extraHeaders: headers) + let setup = try parseSetup(response: resp) + adoptSession(setup) + + guard let serverRTPPort = setup.serverPort else { + throw RTSPError.sessionSetupFailed( + statusCode: Int(resp.statusCode), + reason: "UDP SETUP response omitted server_port") + } + // Choose the RTP/RTCP peer. Prefer the server-advertised `source`, then + // the resolved control-connection peer, then the URL host — but only a + // numeric literal matching the bound socket family, since this path does + // not resolve hostnames. The control connection's resolved peer is always + // a numeric literal of the right family, so it backstops a hostname URL + // (or a `source` given as a name / wrong family). + let matchesFamily = peerIsIPv6 ? UDPPair.isNumericIPv6 : UDPPair.isNumericIPv4 + let peerHost = + [setup.source, resolved?.host, host] + .compactMap { $0 } + .first(where: matchesFamily) ?? resolved?.host ?? family.loopback + try await pair.connect(peerHost: peerHost, peerRTPPort: serverRTPPort) + + udpPairs[streamIndex] = pair + return setup + } catch { + pair.close() + throw error + } + } + private func convertFrame( _ frame: VideoFrame, depacketizer: VideoDepacketizer ) -> PublicVideoFrame { @@ -969,6 +1536,21 @@ actor SessionState { } } +// MARK: - URL helpers (free functions, testable) + +/// Strip the surrounding brackets from an IPv6 literal URL host. +/// +/// Foundation's URL parser surfaces a bracketed IPv6 host verbatim +/// (`rtsp://[::1]:554/…` → `[::1]`), but `NWEndpoint.Host` expects the bare +/// address (e.g. `::1`, or `fe80::1%en0` with a zone id) and rejects the +/// bracketed form. A non-bracketed host (IPv4 / hostname) is returned unchanged. +func unbracketedHost(_ host: String) -> String { + guard host.hasPrefix("["), host.hasSuffix("]"), host.count >= 2 else { + return host + } + return String(host.dropFirst().dropLast()) +} + // MARK: - Encoding-support predicates (free functions, testable) /// True iff `RTSPClientSession` can depacketize a video stream advertising diff --git a/Sources/IPCamKit/Client/SetupParser.swift b/Sources/IPCamKit/Client/SetupParser.swift index 6b8de1a..40bc0b5 100644 --- a/Sources/IPCamKit/Client/SetupParser.swift +++ b/Sources/IPCamKit/Client/SetupParser.swift @@ -29,12 +29,10 @@ func parseSetup(response: RTSPResponse) throws -> SetupResponse { let key = kv[0].trimmingCharacters(in: .whitespaces).lowercased() let value = kv[1].trimmingCharacters(in: .whitespaces) if key == "timeout" { - if let t = UInt32(value) { - guard t > 0 else { - throw RTSPError.sessionSetupFailed( - statusCode: Int(response.statusCode), - reason: "Session timeout=0 is invalid") - } + // Some cameras/proxies send timeout=0 to mean "no expiry / no + // keepalive needed". Treat 0 (and an unparseable value) as "use the + // default cadence" instead of aborting SETUP. + if let t = UInt32(value), t > 0 { timeoutSec = t } } @@ -68,17 +66,25 @@ func parseSetup(response: RTSPResponse) throws -> SetupResponse { // SSRC is hex, may have leading whitespace ssrc = UInt32(value.trimmingCharacters(in: .whitespaces), radix: 16) case "interleaved": - // Format: "0-1" (RTP channel - RTCP channel, must be consecutive) + // Format: "0-1" (RTP channel - RTCP channel). The RTCP channel is + // optional; when present it must be consecutive (matching retina). Routing + // is parity-based, so only the RTP channel is kept. let channels = value.split(separator: "-") - guard channels.count == 2, - let first = UInt8(channels[0].trimmingCharacters(in: .whitespaces)), - let second = UInt8(channels[1].trimmingCharacters(in: .whitespaces)), - second == first + 1 + guard let first = UInt8(channels.first?.trimmingCharacters(in: .whitespaces) ?? "") else { throw RTSPError.sessionSetupFailed( statusCode: Int(response.statusCode), reason: "Invalid interleaved channels: \(value)") } + if channels.count >= 2 { + guard let second = UInt8(channels[1].trimmingCharacters(in: .whitespaces)), + Int(second) == Int(first) + 1 + else { + throw RTSPError.sessionSetupFailed( + statusCode: Int(response.statusCode), + reason: "Invalid interleaved channels: \(value)") + } + } channelId = first case "source": source = value @@ -88,7 +94,7 @@ func parseSetup(response: RTSPResponse) throws -> SetupResponse { guard ports.count == 2, let first = UInt16(ports[0].trimmingCharacters(in: .whitespaces)), let second = UInt16(ports[1].trimmingCharacters(in: .whitespaces)), - second == first + 1 + Int(second) == Int(first) + 1 else { throw RTSPError.sessionSetupFailed( statusCode: Int(response.statusCode), diff --git a/Sources/IPCamKit/Codec/AACDepacketizer.swift b/Sources/IPCamKit/Codec/AACDepacketizer.swift index b69f8f1..fd53569 100644 --- a/Sources/IPCamKit/Codec/AACDepacketizer.swift +++ b/Sources/IPCamKit/Codec/AACDepacketizer.swift @@ -26,11 +26,10 @@ struct AACDepacketizer: Sendable { } let config = try parseAACFormatSpecificParams( clockRate: clockRate, formatSpecificParams: fsp) - if let ch = channels, ch != config.channels.channels { - throw DepacketizeError( - "Expected RTP channels \(ch) and AAC channels \(config.channels.channels) to match" - ) - } + // RFC 3640: the AudioSpecificConfig in `config=` is authoritative for AAC. + // The rtpmap channel count (`channels`) is informational and frequently + // wrong or omitted on real cameras, so do not reject on mismatch — trust + // the ASC. `channels` is retained in the signature for API compatibility. self.config = config self.state = .idle(prevLoss: 0, lossSinceMark: false) } @@ -65,7 +64,7 @@ struct AACDepacketizer: Sendable { /// The received prefix of an AU split across multiple packets. private struct Fragment: Sendable { - var rtpTimestamp: UInt16 + var rtpTimestamp: UInt32 /// Packets lost before this fragment started. var loss: UInt16 /// True iff packets have been lost since the last mark. @@ -92,8 +91,10 @@ struct AACDepacketizer: Sendable { UInt16(payload[payload.startIndex]) << 8 | UInt16(payload[payload.startIndex + 1]) - // AAC-hbr requires 16-bit AU headers: 13-bit size + 3-bit index - guard (auHeadersLengthBits & 0x7) == 0 else { + // AAC-hbr headers are 16 bits (13-bit size + 3-bit index), so the declared + // AU-headers-length must be a multiple of 16. Enforce that rather than the + // looser multiple-of-8, which would silently truncate an off-spec length. + guard (auHeadersLengthBits & 0xF) == 0 else { throw DepacketizeError("bad au-headers-length \(auHeadersLengthBits)") } let auHeadersCount = auHeadersLengthBits >> 4 @@ -108,11 +109,13 @@ struct AACDepacketizer: Sendable { throw DepacketizeError( "Got \(auHeadersCount)-AU packet while fragment in progress") } - guard UInt16(truncatingIfNeeded: pkt.timestamp.timestamp) == frag.rtpTimestamp else { + // Compare the full 32-bit wire timestamp: a UInt16 narrowing could let two + // genuinely different timestamps whose low 16 bits collide be mis-stitched. + let pktTs = UInt32(truncatingIfNeeded: pkt.timestamp.timestamp) + guard pktTs == frag.rtpTimestamp else { throw DepacketizeError( "Timestamp changed from 0x\(String(frag.rtpTimestamp, radix: 16)) " - + "to 0x\(String(UInt16(truncatingIfNeeded: pkt.timestamp.timestamp), radix: 16)) mid-fragment" - ) + + "to 0x\(String(pktTs, radix: 16)) mid-fragment") } let auHeader = UInt16(payload[payload.startIndex + 2]) << 8 @@ -166,7 +169,7 @@ struct AACDepacketizer: Sendable { state = .aggregated( Aggregate( pkt: pkt, - loss: prevLoss + loss, + loss: UInt16(clamping: UInt32(prevLoss) + UInt32(loss)), lossSinceMark: lossSinceMark || loss > 0, frameI: 0, frameCount: auHeadersCount, @@ -237,7 +240,7 @@ struct AACDepacketizer: Sendable { contentsOf: payload[(payload.startIndex + agg.dataOff)...]) state = .fragmented( Fragment( - rtpTimestamp: UInt16( + rtpTimestamp: UInt32( truncatingIfNeeded: agg.pkt.timestamp.timestamp), loss: agg.loss, lossSinceMark: agg.lossSinceMark, diff --git a/Sources/IPCamKit/Codec/AACParameters.swift b/Sources/IPCamKit/Codec/AACParameters.swift index 01d5278..95c23db 100644 --- a/Sources/IPCamKit/Codec/AACParameters.swift +++ b/Sources/IPCamKit/Codec/AACParameters.swift @@ -89,22 +89,11 @@ extension AudioSpecificConfig { throw DepacketizeError( "reserved channelConfiguration 0x\(String(channelsConfigId, radix: 16))") } + // channelConfiguration 0 ("defined in AOT-related config") maps to nil here, + // so this unwrap already rejects it — no separate `> 0` guard needed. guard let channels = channelConfigs[Int(channelsConfigId)] else { throw DepacketizeError("program_config_element parsing unimplemented") } - guard channelsConfigId > 0 else { - throw DepacketizeError("program_config_element parsing unimplemented") - } - - // SBR/PS extensions - if audioObjectType == 5 || audioObjectType == 29 { - if let extFreqIdx = r.readBits(4), extFreqIdx == 0xf { - r.skip(24) - } - if let secondAot = r.readBits(5), secondAot == 22 { - r.skip(4) - } - } // Validate supported audio object types (ones using GASpecificConfig) switch audioObjectType { @@ -202,10 +191,16 @@ func parseAACFormatSpecificParams( let parsed = try AudioSpecificConfig.parse(configData) - guard clockRate == parsed.parameters.clockRate else { - throw DepacketizeError( - "Expected RTP clock rate \(clockRate) and AAC sampling frequency \(parsed.parameters.clockRate) to match" - ) + // HE-AAC / AAC+SBR commonly signals an RTP clock rate that is a small integer + // multiple of the core ASC sampling frequency (e.g. RTP 44100 with a 22050 + // core). Accept an exact match or a small multiple; reject an unrelated one. + let ascRate = parsed.parameters.clockRate + if clockRate != ascRate { + guard ascRate > 0, clockRate % ascRate == 0, clockRate / ascRate <= 4 else { + throw DepacketizeError( + "RTP clock rate \(clockRate) is not a small integer multiple of " + + "AAC sampling frequency \(ascRate)") + } } return parsed diff --git a/Sources/IPCamKit/Codec/ApplicationDepacketizer.swift b/Sources/IPCamKit/Codec/ApplicationDepacketizer.swift index 02c43c0..24bbfe3 100644 --- a/Sources/IPCamKit/Codec/ApplicationDepacketizer.swift +++ b/Sources/IPCamKit/Codec/ApplicationDepacketizer.swift @@ -13,7 +13,11 @@ import Foundation /// Recovery semantics: /// - Loss mid-document discards the buffered prefix and drops the rest of /// that document (until the next marker). The loss surfaces on the next -/// clean frame. +/// clean frame. Edge case: if the very next packet after the loss is itself a +/// complete single-packet document with the marker set, it is consumed as the +/// abandoned document's terminator and dropped too — RTP gives no way to tell +/// it apart from the loss-damaged document's tail. At most one extra document +/// is lost this way; the loss count is still carried forward. /// - Buffer overflow (oversized document) discards the prefix, fires a /// `warning` diagnostic, and drops the rest of that document until the /// next marker — same as the loss case. The next document emits normally. @@ -48,7 +52,7 @@ struct ApplicationDepacketizer: Sendable { mutating func push(_ pkt: ReceivedRTPPacket) throws { precondition(ready == nil, "push() called before pull() drained the previous frame") - pendingLoss = pendingLoss + UInt32(pkt.loss) + pendingLoss = UInt32(clamping: UInt64(pendingLoss) + UInt64(pkt.loss)) var skipAppend = false diff --git a/Sources/IPCamKit/Codec/G723Depacketizer.swift b/Sources/IPCamKit/Codec/G723Depacketizer.swift index ed65080..bf72804 100644 --- a/Sources/IPCamKit/Codec/G723Depacketizer.swift +++ b/Sources/IPCamKit/Codec/G723Depacketizer.swift @@ -56,7 +56,8 @@ struct G723Depacketizer: Sendable { timestamp: pkt.timestamp, frameLength: Self.fixedFrameLength, loss: pkt.loss, - data: payload + // Normalize to a standalone Data (startIndex 0) for the public frame. + data: Data(payload) ) } diff --git a/Sources/IPCamKit/Codec/H264Depacketizer.swift b/Sources/IPCamKit/Codec/H264Depacketizer.swift index 9650d0e..1ce926a 100644 --- a/Sources/IPCamKit/Codec/H264Depacketizer.swift +++ b/Sources/IPCamKit/Codec/H264Depacketizer.swift @@ -20,6 +20,18 @@ struct H264Depacketizer: Sendable { private var pieces: [Data] private var nals: [NALEntry] var seenInconsistentFuANalHdr: Bool + /// True once a FU-A with both the START and END bits set has been seen (a + /// single-fragment FU-A, forbidden by RFC 6184 section 5.8 but emitted by + /// some cameras). Tracked so callers can surface it as a diagnostic. + var seenSingleFragmentFuA: Bool + /// Running total of buffered NAL-body bytes for the in-progress access unit, + /// used to bound memory against a hostile camera (see `maxAccessUnitBytes`). + private var pendingBytes: Int = 0 + + /// Cap on a single accumulated access unit. A hostile camera could otherwise + /// stream FU-A continuations (or same-timestamp NALs) forever and grow memory + /// without bound. 16 MiB sits well above any real 4K/8K keyframe. + private static let maxAccessUnitBytes = 16 << 20 init(clockRate: UInt32, formatSpecificParams: String?) throws { guard clockRate == 90_000 else { @@ -31,6 +43,7 @@ struct H264Depacketizer: Sendable { self.pieces = [] self.nals = [] self.seenInconsistentFuANalHdr = false + self.seenSingleFragmentFuA = false if let fmtp = formatSpecificParams { self.parameters = try? H264Parameters.parseFormatSpecificParams(fmtp) @@ -71,7 +84,7 @@ struct H264Depacketizer: Sendable { AccessUnit( startCtx: pkt.ctx, endCtx: pkt.ctx, timestamp: pkt.timestamp, streamId: pkt.streamId, - fuA: nil, loss: pkt.loss + additionalLoss, + fuA: nil, loss: UInt16(clamping: UInt32(pkt.loss) + UInt32(additionalLoss)), sameTsAsPrev: sameTsAsPrev) } } @@ -91,17 +104,47 @@ struct H264Depacketizer: Sendable { mutating func push(_ pkt: ReceivedRTPPacket) throws { let result = pushInner(pkt) - // Clear nals and pieces if not in preMark state + if case .failure(let err) = result { + // A failure may have left nals/pieces partially mutated while inputState + // is still .preMark; reset to a clean state so a later push() cannot build + // on a corrupted buffer (matters once depacketizer errors are recoverable). + resetAccumulation() + inputState = .new + throw err + } + // Success: keep the in-progress NAL buffers only while mid-AU (.preMark). if case .preMark = inputState { } else { - nals.removeAll(keepingCapacity: true) - pieces.removeAll(keepingCapacity: true) + resetAccumulation() } - if case .failure(let err) = result { - throw err + } + + /// Clear the in-progress access-unit buffers and the byte accumulator together, + /// so the running total can never drift from the actual buffered bytes. + private mutating func resetAccumulation() { + nals.removeAll(keepingCapacity: true) + pieces.removeAll(keepingCapacity: true) + pendingBytes = 0 + } + + /// Append a NAL-body piece, enforcing the per-access-unit size cap. Throws when + /// the cap would be exceeded so a never-terminating FU-A or a flood of NALs at + /// one timestamp can't exhaust memory. + private mutating func appendPiece(_ piece: Data) throws { + guard pendingBytes + piece.count <= Self.maxAccessUnitBytes else { + throw DepacketizeError( + "H.264 access unit exceeded \(Self.maxAccessUnitBytes) bytes") } + pieces.append(piece) + pendingBytes += piece.count } + /// Returns the next completed item, or nil when none are buffered. + /// + /// Contract: callers MUST drain `pull()` to nil after each `push()` (as + /// RTSPSession does). The internal `pending` buffer is unbounded; not draining + /// lets it grow without limit. This type is module-internal with a single + /// driver that upholds the invariant. mutating func pull() -> Result? { guard !pending.isEmpty else { return nil } let item = pending.removeFirst() @@ -133,8 +176,7 @@ struct H264Depacketizer: Sendable { au.endCtx = pkt.ctx let loss = pkt.loss if loss > 0 { - nals.removeAll(keepingCapacity: true) - pieces.removeAll(keepingCapacity: true) + resetAccumulation() if sameTimestamp(pkt.timestamp, au.timestamp) { if pkt.mark { inputState = .postMark(timestamp: au.timestamp, loss: loss) @@ -152,15 +194,13 @@ struct H264Depacketizer: Sendable { let desc = "timestamp changed from \(au.timestamp) to \(pkt.timestamp) in the middle of a fragmented NAL" pending.append(.failure(DepacketizeError(desc))) - nals.removeAll(keepingCapacity: true) - pieces.removeAll(keepingCapacity: true) + resetAccumulation() accessUnit = AccessUnit.start(pkt, additionalLoss: 0, sameTsAsPrev: false) } else if !nals.isEmpty && canEndAU(nals.last!) { // Normal AU boundary let frame = finalizeAccessUnit(&au) pending.append(frame) - nals.removeAll(keepingCapacity: true) - pieces.removeAll(keepingCapacity: true) + resetAccumulation() accessUnit = AccessUnit.start(pkt, additionalLoss: 0, sameTsAsPrev: false) } else if nals.isEmpty { au.timestamp = pkt.timestamp @@ -183,7 +223,7 @@ struct H264Depacketizer: Sendable { // Stay in Loss regardless of mark bit — once loss is detected for a // timestamp, ALL remaining packets for that timestamp are ignored. // This matches upstream behavior (h264.rs lines 349-361). - lossPkts += pkt.loss + lossPkts = UInt16(clamping: UInt32(lossPkts) + UInt32(pkt.loss)) inputState = .loss(timestamp: lossTs, pkts: lossPkts) return .success(()) } @@ -261,20 +301,25 @@ struct H264Depacketizer: Sendable { let reconstructedByte = (nalHeaderByte & 0b1110_0000) | (fuHeader & 0b0001_1111) let reconstructedHeader = NALHeader(reconstructedByte) - if isStart && isEnd { - return .failure(DepacketizeError("Invalid FU-A: both START and END set")) - } + // A FU-A with both START and END set is a single-fragment FU-A: + // forbidden by RFC 6184 section 5.8, but some cameras wrap small NALs + // this way. Tolerate it (treat as a complete NAL) instead of erroring. + // Port of retina 8ff7a0f / e1c0bbf. if !isEnd && pkt.mark { return .failure(DepacketizeError("FU-A with MARK but no END bit")) } let fuPayload = Data(payload[(payload.startIndex + 2)...]) + var fuState: FuAState if isStart { if accessUnit.fuA != nil { return .failure(DepacketizeError("FU-A START while fragment already in progress")) } - var fuState = FuAState( + if isEnd && !seenSingleFragmentFuA { + seenSingleFragmentFuA = true + } + fuState = FuAState( initialNalHeader: reconstructedHeader, curNal: CurFuANal(hdr: reconstructedHeader, trailingZeros: 0, piecesBytes: 0)) do { @@ -282,9 +327,9 @@ struct H264Depacketizer: Sendable { } catch { return .failure(DepacketizeError("\(error)")) } - accessUnit.fuA = fuState - } else if var fuState = accessUnit.fuA { + } else if let inProgress = accessUnit.fuA { // Continuation or end + fuState = inProgress if reconstructedHeader != fuState.initialNalHeader && !seenInconsistentFuANalHdr { seenInconsistentFuANalHdr = true } @@ -293,31 +338,31 @@ struct H264Depacketizer: Sendable { } catch { return .failure(DepacketizeError("\(error)")) } - if isEnd { - // Finalize the FU-A NAL - if let c = fuState.curNal { - let totalLen = 1 + c.piecesBytes // header + body - nals.append( - NALEntry( - hdr: c.hdr, - nextPieceIdx: pieces.count, - len: totalLen)) - } - fuState.curNal = nil - accessUnit.fuA = nil - } else { - accessUnit.fuA = fuState - } } else { if pkt.loss > 0 { inputState = .loss(timestamp: accessUnit.timestamp, pkts: pkt.loss) - nals.removeAll(keepingCapacity: true) - pieces.removeAll(keepingCapacity: true) + resetAccumulation() return .success(()) } return .failure(DepacketizeError("FU-A continuation without START")) } + if isEnd { + // Finalize the FU-A NAL. With a single-fragment FU-A this happens on + // the START fragment itself. + if let c = fuState.curNal { + let totalLen = 1 + c.piecesBytes // header + body + nals.append( + NALEntry( + hdr: c.hdr, + nextPieceIdx: pieces.count, + len: totalLen)) + } + accessUnit.fuA = nil + } else { + accessUnit.fuA = fuState + } + case 0, 30, 31: return .failure(DepacketizeError("Bad NAL header type \(nalType)")) @@ -358,7 +403,7 @@ struct H264Depacketizer: Sendable { } let body = data.count > 1 ? Data(data[(data.startIndex + 1)...]) : Data() if !body.isEmpty { - pieces.append(body) + do { try appendPiece(body) } catch { return .failure(DepacketizeError("\(error)")) } } nals.append( NALEntry( @@ -415,8 +460,8 @@ struct H264Depacketizer: Sendable { let pieceEnd = dataEnd - c.trailingZeros - 1 if pieceEnd > dataOffset { let piece = Data(bytes[dataOffset.. dataOffset { let piece = Data(bytes[dataOffset.. String { - var errs = "" - var seenVCL = false - for (i, nal) in nals.enumerated() { - switch nal.hdr.nalUnitTypeId { - case 1, 2, 3, 4, 5: - seenVCL = true - case 6: // SEI - if seenVCL { errs += "\n* SEI after VCL" } - case 9: // Access Unit Delimiter - if i != 0 { errs += "\n* access unit delimiter must be first in AU" } - case 10: // End of Sequence - if !seenVCL { errs += "\n* end of sequence without VCL" } - case 11: // End of Stream - if i != nals.count - 1 { errs += "\n* end of stream NAL isn't last" } - default: - break - } - } - if !seenVCL { errs += "\n* missing VCL" } - return errs - } - // MARK: - Access Unit Finalization private func canEndAU(_ nal: NALEntry) -> Bool { let nalType = nal.hdr.nalUnitTypeId - // SPS and PPS cannot end an access unit (handles Reolink quirk) - return nalType != 7 && nalType != 8 + // SPS (7), PPS (8), and SEI (6) cannot end an access unit. Per H.264 + // section 7.4.1.2.3 these non-VCL NAL units precede the primary coded + // picture; some cameras incorrectly set the RTP MARK bit and/or change + // the timestamp after them (Reolink after SPS/PPS, "H264DVR 1.0" firmware + // after a trailing SEI). An AU containing only these with no VCL NAL is + // not a valid picture. Port of retina ff771fe. + return nalType != 6 && nalType != 7 && nalType != 8 } private mutating func finalizeAccessUnit( diff --git a/Sources/IPCamKit/Codec/H264Parameters.swift b/Sources/IPCamKit/Codec/H264Parameters.swift index 2cbb244..a73aa06 100644 --- a/Sources/IPCamKit/Codec/H264Parameters.swift +++ b/Sources/IPCamKit/Codec/H264Parameters.swift @@ -83,22 +83,18 @@ struct H264Parameters: Sendable, Equatable { /// /// Builds AVCDecoderConfiguration record (ISO/IEC 14496-15 section 5.2.4.1). static func parseSPSAndPPS(sps spsNAL: Data, pps ppsNAL: Data) throws -> H264Parameters { - // Decode SPS RBSP (skip NAL header byte for rbsp decoding) - let spsRBSP = decodeRBSP(spsNAL) - guard spsRBSP.count >= 4 else { - throw RTSPError.depacketizationError("SPS RBSP too short") - } + // Parse the SPS once: parseSPS already decodes the RBSP and returns the + // profile/constraint/level bytes along with dimensions and VUI, so there is + // no need to decode and slice the RBSP a second time here. + let parsedSPS = try parseSPS(spsNAL) + let profileIdc = parsedSPS.profileIdc + let constraintFlags = parsedSPS.constraintFlags + let levelIdc = parsedSPS.levelIdc - // Build RFC 6381 codec string from first 3 bytes of SPS RBSP (after NAL header) - let profileIdc = spsRBSP[spsRBSP.startIndex + 1] - let constraintFlags = spsRBSP[spsRBSP.startIndex + 2] - let levelIdc = spsRBSP[spsRBSP.startIndex + 3] + // Build RFC 6381 codec string let rfc6381Codec = String( format: "avc1.%02X%02X%02X", profileIdc, constraintFlags, levelIdc) - // Parse SPS for dimensions and VUI - let parsedSPS = try parseSPS(spsNAL) - // Build AVCDecoderConfiguration // configurationVersion = 1 // profile_idc, constraint_flags, level_idc diff --git a/Sources/IPCamKit/Codec/H265Depacketizer.swift b/Sources/IPCamKit/Codec/H265Depacketizer.swift index beaf855..c006398 100644 --- a/Sources/IPCamKit/Codec/H265Depacketizer.swift +++ b/Sources/IPCamKit/Codec/H265Depacketizer.swift @@ -16,6 +16,18 @@ struct H265Depacketizer: Sendable { private var pieces: [Data] private var nals: [NALEntry] var seenInconsistentFuNalHdr: Bool + /// True once a FU with both the START and END bits set has been seen (a + /// single-fragment FU, forbidden by RFC 7798 section 4.4.3 but emitted by + /// some cameras). Tracked so callers can surface it as a diagnostic. + var seenSingleFragmentFu: Bool + /// Running total of buffered NAL-body bytes for the in-progress access unit, + /// used to bound memory against a hostile camera (see `maxAccessUnitBytes`). + private var pendingBytes: Int = 0 + + /// Cap on a single accumulated access unit. A hostile camera could otherwise + /// stream FU continuations (or same-timestamp NALs) forever and grow memory + /// without bound. 16 MiB sits well above any real 4K/8K keyframe. + private static let maxAccessUnitBytes = 16 << 20 init(clockRate: UInt32, formatSpecificParams: String?) throws { guard clockRate == 90_000 else { @@ -27,6 +39,7 @@ struct H265Depacketizer: Sendable { self.pieces = [] self.nals = [] self.seenInconsistentFuNalHdr = false + self.seenSingleFragmentFu = false if let fmtp = formatSpecificParams { self.parameters = try? H265Parameters.parseFormatSpecificParams(fmtp) @@ -68,7 +81,7 @@ struct H265Depacketizer: Sendable { AccessUnit( startCtx: pkt.ctx, endCtx: pkt.ctx, timestamp: pkt.timestamp, streamId: pkt.streamId, - inFU: false, loss: pkt.loss + additionalLoss, + inFU: false, loss: UInt16(clamping: UInt32(pkt.loss) + UInt32(additionalLoss)), sameTsAsPrev: sameTsAsPrev) } } @@ -77,16 +90,47 @@ struct H265Depacketizer: Sendable { mutating func push(_ pkt: ReceivedRTPPacket) throws { let result = pushInner(pkt) + if case .failure(let err) = result { + // Reset to a clean state so a later push() cannot build on a buffer left + // partially mutated by the failed push (matters once errors are + // recoverable). + resetAccumulation() + inputState = .new + throw err + } if case .preMark = inputState { } else { - nals.removeAll(keepingCapacity: true) - pieces.removeAll(keepingCapacity: true) + resetAccumulation() } - if case .failure(let err) = result { - throw err + } + + /// Clear the in-progress access-unit buffers and the byte accumulator together, + /// so the running total can never drift from the actual buffered bytes. + private mutating func resetAccumulation() { + nals.removeAll(keepingCapacity: true) + pieces.removeAll(keepingCapacity: true) + pendingBytes = 0 + } + + /// Append a NAL-body piece, enforcing the per-access-unit size cap so a + /// never-terminating FU or a flood of NALs at one timestamp can't exhaust + /// memory. + private mutating func appendPiece(_ piece: Data) -> Result { + guard pendingBytes + piece.count <= Self.maxAccessUnitBytes else { + return .failure( + DepacketizeError("H.265 access unit exceeded \(Self.maxAccessUnitBytes) bytes")) } + pieces.append(piece) + pendingBytes += piece.count + return .success(()) } + /// Returns the next completed item, or nil when none are buffered. + /// + /// Contract: callers MUST drain `pull()` to nil after each `push()` (as + /// RTSPSession does). The internal `pending` buffer is unbounded; not draining + /// lets it grow without limit. This type is module-internal with a single + /// driver that upholds the invariant. mutating func pull() -> Result? { guard !pending.isEmpty else { return nil } let item = pending.removeFirst() @@ -117,8 +161,7 @@ struct H265Depacketizer: Sendable { au.endCtx = pkt.ctx let loss = pkt.loss if loss > 0 { - nals.removeAll(keepingCapacity: true) - pieces.removeAll(keepingCapacity: true) + resetAccumulation() if sameTimestamp(pkt.timestamp, au.timestamp) { if pkt.mark { inputState = .postMark(timestamp: au.timestamp, loss: loss) @@ -133,16 +176,14 @@ struct H265Depacketizer: Sendable { let desc = "timestamp changed from \(au.timestamp) to \(pkt.timestamp) in the middle of a fragmented NAL" pending.append(.failure(DepacketizeError(desc))) - nals.removeAll(keepingCapacity: true) - pieces.removeAll(keepingCapacity: true) + resetAccumulation() accessUnit = AccessUnit.start(pkt, additionalLoss: 0, sameTsAsPrev: false) } else if nals.isEmpty { return .failure(DepacketizeError("nals should not be empty")) } else if canEndAU(nals.last!.hdr.unitType) { let frame = finalizeAccessUnit(&au) pending.append(frame) - nals.removeAll(keepingCapacity: true) - pieces.removeAll(keepingCapacity: true) + resetAccumulation() accessUnit = AccessUnit.start(pkt, additionalLoss: 0, sameTsAsPrev: false) } else { au.timestamp = pkt.timestamp @@ -159,7 +200,7 @@ struct H265Depacketizer: Sendable { case .loss(let lossTs, var lossPkts): if sameTimestamp(pkt.timestamp, lossTs) { - lossPkts += pkt.loss + lossPkts = UInt16(clamping: UInt32(lossPkts) + UInt32(pkt.loss)) inputState = .loss(timestamp: lossTs, pkts: lossPkts) return .success(()) } @@ -192,12 +233,18 @@ struct H265Depacketizer: Sendable { let body = Data(payload[(payload.startIndex + 2)...]) let len = body.count + 2 // includes 2-byte header if !body.isEmpty { - pieces.append(body) + if case .failure(let e) = appendPiece(body) { return .failure(e) } } nals.append(NALEntry(hdr: hdr, nextPieceIdx: pieces.count, len: len)) case 48: // Aggregation Packet (RFC 7798 section 4.4.2) + if accessUnit.inFU { + // An AP arriving mid-fragment would leave the in-progress FU NALEntry + // with its Int.max sentinel nextPieceIdx, which finalizeAccessUnit + // would then slice on and trap. Reject like the single-NAL branch. + return .failure(DepacketizeError("AP while fragment in progress")) + } var offset = payload.startIndex + 2 // skip outer NAL header guard offset < payload.endIndex else { return .failure( @@ -235,7 +282,7 @@ struct H265Depacketizer: Sendable { ? Data(nalData[(nalData.startIndex + 2)...]) : Data() if !innerBody.isEmpty { - pieces.append(innerBody) + if case .failure(let e) = appendPiece(innerBody) { return .failure(e) } } nals.append( NALEntry( @@ -259,31 +306,37 @@ struct H265Depacketizer: Sendable { } let reconstructedHdr = hdr.withUnitType(fuType) - if isStart && isEnd { - return .failure( - DepacketizeError( - "Invalid FU header \(String(format: "%02x", fuHeader))")) - } + // A FU with both START and END set is a single-fragment FU: forbidden by + // RFC 7798 section 4.4.3, but some cameras wrap small NALs this way. + // Tolerate it (treat as a complete NAL) instead of erroring. Port of + // retina 8ff7a0f / e1c0bbf. if !isEnd && pkt.mark { return .failure(DepacketizeError("FU pkt with MARK && !END")) } let fuPayload = Data(payload[(payload.startIndex + 3)...]) + // `pieces` count once this fragment's payload has been appended; used to + // close out the NAL's piece range on the END fragment (possibly now). + let piecesLen: Int switch (isStart, accessUnit.inFU) { case (true, true): return .failure( DepacketizeError("FU with start bit while frag in progress")) case (true, false): - pieces.append(fuPayload) + if isEnd && !seenSingleFragmentFu { + seenSingleFragmentFu = true + } + if case .failure(let e) = appendPiece(fuPayload) { return .failure(e) } nals.append( NALEntry( hdr: reconstructedHdr, - nextPieceIdx: Int.max, // overwritten on end + nextPieceIdx: Int.max, // overwritten on end (which may be now) len: 2 + fuPayload.count)) - accessUnit.inFU = true + piecesLen = pieces.count case (false, true): - pieces.append(fuPayload) + if case .failure(let e) = appendPiece(fuPayload) { return .failure(e) } + piecesLen = pieces.count guard var nal = nals.last else { return .failure(DepacketizeError("nals non-empty while in fu")) } @@ -291,15 +344,10 @@ struct H265Depacketizer: Sendable { seenInconsistentFuNalHdr = true } nal.len += fuPayload.count - if isEnd { - nal.nextPieceIdx = pieces.count - accessUnit.inFU = false - } nals[nals.count - 1] = nal case (false, false): if pkt.loss > 0 { - nals.removeAll(keepingCapacity: true) - pieces.removeAll(keepingCapacity: true) + resetAccumulation() inputState = .loss(timestamp: accessUnit.timestamp, pkts: pkt.loss) return .success(()) } @@ -307,6 +355,14 @@ struct H265Depacketizer: Sendable { DepacketizeError("FU has start bit unset while no frag in progress")) } + if isEnd { + guard !nals.isEmpty else { + return .failure(DepacketizeError("FU end with no NAL in progress")) + } + nals[nals.count - 1].nextPieceIdx = piecesLen + } + accessUnit.inFU = !isEnd + default: return .failure(DepacketizeError("unexpected/bad nal header type \(nalTypeRaw)")) } @@ -409,8 +465,7 @@ struct H265Depacketizer: Sendable { pieceIdx = nextPieceIdx } - nals.removeAll(keepingCapacity: true) - pieces.removeAll(keepingCapacity: true) + resetAccumulation() // Update parameters if changed let allNew = newVPS != nil && newSPS != nil && newPPS != nil diff --git a/Sources/IPCamKit/Codec/H265PPSParser.swift b/Sources/IPCamKit/Codec/H265PPSParser.swift index b3c656c..caefe7b 100644 --- a/Sources/IPCamKit/Codec/H265PPSParser.swift +++ b/Sources/IPCamKit/Codec/H265PPSParser.swift @@ -54,11 +54,13 @@ func parseH265PPS(_ rbsp: Data) throws -> H265Pps { throw RTSPError.depacketizationError("PPS: can't read uniform_spacing_flag") } if !uniformSpacing { + // numTileColumns/Rows are attacker-controlled exp-Golomb values; stop as + // soon as the bitstream runs out so a bogus count can't spin. for _ in 0..0 prepends a DONL to every FU/AP, which we'd misread as + // NAL data — fail loud rather than silently emit corrupt frames. + if let n = Int(value), n > 0 { + throw RTSPError.depacketizationError( + "unsupported sprop-max-don-diff=\(n); DONL/DOND decoding not implemented") + } case "sprop-vps": try storeSpropNAL(key: "sprop-vps", value: value, out: &vpsNAL) case "sprop-sps": @@ -61,17 +73,44 @@ struct H265Parameters: Sendable, Equatable { private static func storeSpropNAL( key: String, value: String, out: inout Data? ) throws { - guard let decoded = Data(base64Encoded: value) else { - throw RTSPError.depacketizationError( - "bad parameter \(key): NAL has invalid base64 encoding") - } - guard !decoded.isEmpty else { - throw RTSPError.depacketizationError("bad parameter \(key): empty NAL") - } guard out == nil else { throw RTSPError.depacketizationError("multiple \(key) parameters") } - out = decoded + // RFC 7798 7.1: the value is a comma-separated list of base64 NAL units. + // Take the first valid NAL — the struct holds a single VPS/SPS/PPS, and the + // RFC requires later entries be consistent with the first. + for element in value.split(separator: ",") { + let b64 = element.trimmingCharacters(in: .whitespaces) + guard !b64.isEmpty else { continue } + guard let decoded = Data(base64Encoded: b64) else { + throw RTSPError.depacketizationError( + "bad parameter \(key): NAL has invalid base64 encoding") + } + let nal = stripAnnexBStartCode(decoded) + guard !nal.isEmpty else { + throw RTSPError.depacketizationError("bad parameter \(key): empty NAL") + } + out = nal + break + } + guard out != nil else { + throw RTSPError.depacketizationError("bad parameter \(key): no NAL units") + } + } + + /// Strip a single leading Annex B start code (`00 00 01` or `00 00 00 01`) + /// that some cameras prepend to a base64 sprop NAL, mirroring the H264 path. + /// Only a leading prefix is removed, so trailing bytes (and emulation- + /// prevention sequences) of a bare NAL are left untouched. + private static func stripAnnexBStartCode(_ d: Data) -> Data { + let s = d.startIndex + if d.count >= 4, d[s] == 0, d[s + 1] == 0, d[s + 2] == 0, d[s + 3] == 1 { + return d.subdata(in: (s + 4)..= 3, d[s] == 0, d[s + 1] == 0, d[s + 2] == 1 { + return d.subdata(in: (s + 3)..64 KiB parameter set.) + guard vpsNAL.count <= 0xFFFF, spsNAL.count <= 0xFFFF, ppsNAL.count <= 0xFFFF else { + throw RTSPError.depacketizationError( + "VPS/SPS/PPS NAL exceeds 65535 bytes; cannot fit the HEVC record u16 length") + } + let rfc6381Codec = sps.rfc6381Codec() let dims = try sps.pixelDimensions() guard let width = UInt16(exactly: dims.0), let height = UInt16(exactly: dims.1) else { diff --git a/Sources/IPCamKit/Codec/H265SPSParser.swift b/Sources/IPCamKit/Codec/H265SPSParser.swift index 499cfff..1bee07d 100644 --- a/Sources/IPCamKit/Codec/H265SPSParser.swift +++ b/Sources/IPCamKit/Codec/H265SPSParser.swift @@ -458,6 +458,11 @@ private func skipH265HrdParameters( guard let n = reader.readExpGolomb() else { throw RTSPError.depacketizationError("SPS: can't read nb_cpb") } + // cpb_cnt_minus1 is in [0, 31] per H.265; reject larger values so the + // CPB loops below can't spin and `n + 1` can't overflow UInt32. + guard n <= 31 else { + throw RTSPError.depacketizationError("cpb_cnt_minus1 must be in [0, 31]") + } nbCpb = n + 1 } if nalParamsPresent { @@ -592,17 +597,18 @@ struct H265Sps: Sendable { var width = picWidthInLumaSamples var height = picHeightInLumaSamples if let c = conformanceWindow { - let widthShift: UInt32 = (chromaFormatIdc == 1 || chromaFormatIdc == 2) ? 1 : 0 - let heightShift: UInt32 = chromaFormatIdc == 1 ? 1 : 0 - let lr = c.leftOffset &+ c.rightOffset - let tb = c.topOffset &+ c.bottomOffset - let subW = lr << widthShift - let subH = tb << heightShift - guard width >= subW, height >= subH else { + // Compute the crop in 64-bit so malformed offsets can't wrap a 32-bit add + // to a small/zero value and silently yield wrong dimensions. The crop must + // leave a positive picture (H.265 §7.4.3.2.1), so reject when it doesn't. + let widthShift: UInt64 = (chromaFormatIdc == 1 || chromaFormatIdc == 2) ? 1 : 0 + let heightShift: UInt64 = chromaFormatIdc == 1 ? 1 : 0 + let subW = (UInt64(c.leftOffset) + UInt64(c.rightOffset)) << widthShift + let subH = (UInt64(c.topOffset) + UInt64(c.bottomOffset)) << heightShift + guard subW < UInt64(width), subH < UInt64(height) else { throw RTSPError.depacketizationError("bad conformance window") } - width -= subW - height -= subH + width -= UInt32(subW) + height -= UInt32(subH) } return (width, height) } @@ -694,14 +700,16 @@ func parseH265SPS(_ rbsp: Data) throws -> H265Sps { guard let bitDepthLuma = reader.readExpGolomb() else { throw RTSPError.depacketizationError("SPS: can't read bit_depth_luma_minus8") } - guard bitDepthLuma <= 8 else { - throw RTSPError.depacketizationError("bit_depth_luma_minus8 must be in [0, 8]") + guard bitDepthLuma <= 6 else { + // H.265 caps bit depth at 14-bit (minus8 in [0,6]); a value of 7/8 would + // also collide with the reserved bit in the 3-bit HEVC-record field. + throw RTSPError.depacketizationError("bit_depth_luma_minus8 must be in [0, 6]") } guard let bitDepthChroma = reader.readExpGolomb() else { throw RTSPError.depacketizationError("SPS: can't read bit_depth_chroma_minus8") } - guard bitDepthChroma <= 8 else { - throw RTSPError.depacketizationError("bit_depth_chroma_minus8 must be in [0, 8]") + guard bitDepthChroma <= 6 else { + throw RTSPError.depacketizationError("bit_depth_chroma_minus8 must be in [0, 6]") } guard let log2MaxPicOrderCntLsbMinus4 = reader.readExpGolomb() else { @@ -765,6 +773,11 @@ func parseH265SPS(_ rbsp: Data) throws -> H265Sps { guard let numLongTermRefPics = reader.readExpGolomb() else { throw RTSPError.depacketizationError("SPS: can't read num_long_term_ref_pics_sps") } + // num_long_term_ref_pics_sps is in [0, 32] per H.265; reject larger values + // so a bogus count can't spin the loop (reader.skip is unchecked). + guard numLongTermRefPics <= 32 else { + throw RTSPError.depacketizationError("num_long_term_ref_pics_sps must be in [0, 32]") + } for _ in 0.. ParsedSPS { } if scalingMatrixPresent == 1 { let count = chromaFormatIdc != 3 ? 8 : 12 - for _ in 0.. ParsedSPS { _ = reader.readSignedExpGolomb() // offset_for_non_ref_pic _ = reader.readSignedExpGolomb() // offset_for_top_to_bottom_field if let numRefFrames = reader.readExpGolomb() { + // numRefFrames is attacker-controlled; stop as soon as the bitstream + // runs out so a bogus count can't spin billions of iterations. for _ in 0.. ParsedSPS { } } - let mbWidth = picWidthMinus1 + 1 - let mbHeight = picHeightMinus1 + 1 - let heightMultiplier: UInt32 = frameMbsOnlyFlag == 1 ? 1 : 2 + // Compute dimensions in Int (64-bit) so malformed SPS values can't overflow + // or underflow and trap; then require the result to fit the UInt16 type. // CropUnitX = SubWidthC, CropUnitY = SubHeightC * (2 - frame_mbs_only_flag) - let rawWidth = mbWidth * 16 - (cropLeft + cropRight) * subWidthC + let mbWidth = Int(picWidthMinus1) + 1 + let mbHeight = Int(picHeightMinus1) + 1 + let heightMultiplier = frameMbsOnlyFlag == 1 ? 1 : 2 + let rawWidth = mbWidth * 16 - (Int(cropLeft) + Int(cropRight)) * Int(subWidthC) let rawHeight = - mbHeight * 16 * heightMultiplier - (cropTop + cropBottom) * subHeightC - * heightMultiplier + mbHeight * 16 * heightMultiplier + - (Int(cropTop) + Int(cropBottom)) * Int(subHeightC) * heightMultiplier - guard let width = UInt16(exactly: rawWidth), let height = UInt16(exactly: rawHeight) else { + guard rawWidth > 0, rawHeight > 0, + let width = UInt16(exactly: rawWidth), let height = UInt16(exactly: rawHeight) + else { throw RTSPError.depacketizationError( - "SPS dimensions too large: \(rawWidth)x\(rawHeight)") + "SPS dimensions invalid: \(rawWidth)x\(rawHeight)") } // VUI parameters (optional) @@ -303,10 +310,11 @@ func parseSPS(_ spsNAL: Data) throws -> ParsedSPS { if let numUnitsInTick = reader.readBits(32), let timeScale = reader.readBits(32) { - if numUnitsInTick > 0 && timeScale > 0 { - // frame_rate = time_scale / (2 * num_units_in_tick) - // We store as (num_units_in_tick * 2, time_scale) to represent the denominator/numerator - frameRate = (num: numUnitsInTick * 2, den: timeScale) + // frame_rate = time_scale / (2 * num_units_in_tick); stored as + // (num_units_in_tick * 2, time_scale). Skip if the doubling overflows. + let (doubledUnits, overflow) = numUnitsInTick.multipliedReportingOverflow(by: 2) + if numUnitsInTick > 0 && timeScale > 0 && !overflow { + frameRate = (num: doubledUnits, den: timeScale) } } } @@ -325,12 +333,14 @@ func parseSPS(_ spsNAL: Data) throws -> ParsedSPS { /// Skip a scaling list in the SPS. private func skipScalingList(_ reader: inout BitReader, size: Int) { - var lastScale: Int32 = 8 - var nextScale: Int32 = 8 + // Run the scale math in Int so a large signed exp-Golomb delta from + // malformed input can't overflow Int32 and trap. + var lastScale = 8 + var nextScale = 8 for _ in 0..= UInt16.max). A malformed/oversized length degrades to a + /// thrown DepacketizeError via push() rather than trapping the process. func frameLength(payloadLen: Int) -> UInt32? { - precondition(payloadLen < Int(UInt16.max)) + guard payloadLen < Int(UInt16.max) else { return nil } let bits = UInt32(payloadLen) * 8 guard bits % bitsPerSample == 0 else { return nil } let result = bits / bitsPerSample @@ -52,8 +53,11 @@ struct SimpleAudioDepacketizer: Sendable { streamId: pkt.streamId, timestamp: pkt.timestamp, frameLength: fl, + // Normalize to a standalone Data (startIndex 0); the payload is a slice + // of the receive buffer, and handing a nonzero-startIndex slice to the + // public frame is a subscripting footgun for consumers. loss: pkt.loss, - data: payload + data: Data(payload) ) } diff --git a/Sources/IPCamKit/Contexts.swift b/Sources/IPCamKit/Contexts.swift index 6b16bb1..6281213 100644 --- a/Sources/IPCamKit/Contexts.swift +++ b/Sources/IPCamKit/Contexts.swift @@ -82,7 +82,7 @@ public enum StreamContext: Sendable, CustomStringConvertible, CustomDebugStringC public var description: String { switch self { case .tcp(let tcp): - return "TCP, interleaved channel ids \(tcp.rtpChannelId)-\(tcp.rtpChannelId + 1)" + return "TCP, interleaved channel ids \(tcp.rtpChannelId)-\(Int(tcp.rtpChannelId) + 1)" case .udp(let udp): return udp.description case .dummy: @@ -121,7 +121,7 @@ public struct UdpStreamContext: Sendable, CustomStringConvertible { } public var description: String { - "\(localIP):\(localRtpPort)-\(localRtpPort + 1)(me) -> \(peerIP):\(peerRtpPort)-\(peerRtpPort + 1)" + "\(localIP):\(localRtpPort)-\(Int(localRtpPort) + 1)(me) -> \(peerIP):\(peerRtpPort)-\(Int(peerRtpPort) + 1)" } } diff --git a/Sources/IPCamKit/Errors.swift b/Sources/IPCamKit/Errors.swift index 218e14d..646a742 100644 --- a/Sources/IPCamKit/Errors.swift +++ b/Sources/IPCamKit/Errors.swift @@ -14,6 +14,8 @@ public enum RTSPError: Error, Sendable, CustomStringConvertible { case timeout case invalidSDP(String) case depacketizationError(String) + /// A library API was used incorrectly (e.g. `frames()` consumed more than once). + case invalidState(String) public var description: String { switch self { @@ -33,102 +35,8 @@ public enum RTSPError: Error, Sendable, CustomStringConvertible { return "Invalid SDP: \(msg)" case .depacketizationError(let msg): return "Depacketization error: \(msg)" - } - } -} - -/// Internal error type mirroring upstream `ErrorInt` variants. -/// Provides detailed context for debugging (connection info, packet position, etc.). -enum InternalError: Error, Sendable, CustomStringConvertible { - case invalidArgument(String) - - case rtspFramingError( - connCtx: ConnectionContext, - msgCtx: RtspMessageContext, - description: String - ) - - case rtspResponseError( - connCtx: ConnectionContext, - msgCtx: RtspMessageContext, - method: String, - cseq: UInt32, - statusCode: UInt16, - description: String - ) - - case rtspUnassignedChannelError( - connCtx: ConnectionContext, - msgCtx: RtspMessageContext, - channelId: UInt8, - data: Data - ) - - case packetError( - connCtx: ConnectionContext, - streamCtx: StreamContext, - pktCtx: PacketContext, - streamId: Int, - description: String - ) - - case rtpPacketError( - connCtx: ConnectionContext, - streamCtx: StreamContext, - pktCtx: PacketContext, - streamId: Int, - ssrc: UInt32, - sequenceNumber: UInt16, - description: String - ) - - case connectError(any Error & Sendable) - case writeError(connCtx: ConnectionContext, source: any Error & Sendable) - case rtspReadError( - connCtx: ConnectionContext, - msgCtx: RtspMessageContext, - source: any Error & Sendable - ) - case udpRecvError( - connCtx: ConnectionContext, - streamCtx: StreamContext, - when: WallTime, - source: any Error & Sendable - ) - - case failedPrecondition(String) - case `internal`(String) - case unsupported(String) - - var description: String { - switch self { - case .invalidArgument(let msg): - return "Invalid argument: \(msg)" - case .rtspFramingError(let conn, let msg, let desc): - return "RTSP framing error: \(desc)\n\nconn: \(conn)\nmsg: \(msg)" - case .rtspResponseError(let conn, let msg, let method, let cseq, let status, let desc): - return "\(status) response to \(method) CSeq=\(cseq): \(desc)\n\nconn: \(conn)\nmsg: \(msg)" - case .rtspUnassignedChannelError(_, _, let ch, _): - return "Received interleaved data on unassigned channel \(ch)" - case .packetError(let conn, let stream, let pkt, _, let desc): - return "\(desc)\n\nconn: \(conn)\nstream: \(stream)\npkt: \(pkt)" - case .rtpPacketError(let conn, let stream, let pkt, _, let ssrc, let seq, let desc): - return - "\(desc)\n\nconn: \(conn)\nstream: \(stream)\nssrc: \(String(format: "%08x", ssrc))\nseq: \(seq)\npkt: \(pkt)" - case .connectError(let err): - return "Unable to connect to RTSP server: \(err)" - case .writeError(let conn, let err): - return "Error writing to RTSP peer: \(err)\n\nconn: \(conn)" - case .rtspReadError(let conn, let msg, let err): - return "Error reading from RTSP peer: \(err)\n\nconn: \(conn)\nmsg: \(msg)" - case .udpRecvError(let conn, let stream, let when, let err): - return "Error receiving UDP packet: \(err)\n\nconn: \(conn)\nstream: \(stream)\nat: \(when)" - case .failedPrecondition(let msg): - return "Failed precondition: \(msg)" - case .internal(let msg): - return "Internal error: \(msg)" - case .unsupported(let msg): - return "Unsupported: \(msg)" + case .invalidState(let msg): + return "Invalid state: \(msg)" } } } diff --git a/Sources/IPCamKit/IPCamKit.swift b/Sources/IPCamKit/IPCamKit.swift index af90710..7388df3 100644 --- a/Sources/IPCamKit/IPCamKit.swift +++ b/Sources/IPCamKit/IPCamKit.swift @@ -4,7 +4,7 @@ // Public API: // - RTSPClientSession: main entry point for connecting to RTSP cameras // - Credentials: username/password for RTSP authentication -// - Transport: .tcp (interleaved) or .udp +// - Transport: .tcp (RTP interleaved over the RTSP connection) or .udp (separate RTP/RTCP socket pair, IPv4/IPv6) // - SessionDescription: codec info returned from start() // - PublicVideoFrame: depacketized video frame with AVCC NAL units // - PublicAudioFrame: depacketized audio frame diff --git a/Sources/IPCamKit/RTP/ChannelMapping.swift b/Sources/IPCamKit/RTP/ChannelMapping.swift index b017a18..50775e9 100644 --- a/Sources/IPCamKit/RTP/ChannelMapping.swift +++ b/Sources/IPCamKit/RTP/ChannelMapping.swift @@ -49,11 +49,9 @@ struct ChannelMappings: Sendable { throw RTSPError.depacketizationError( "Channel ID \(channelId) must be even") } - guard streamIndex < 255 else { - throw RTSPError.depacketizationError( - "Stream index \(streamIndex) too large") - } - + // No bound on streamIndex: it is an opaque SDP media index, not a channel + // slot. The 128-pair capacity is already enforced by channelId (UInt8, even) + // giving slotIndex = channelId/2 <= 127. let slotIndex = Int(channelId / 2) // Extend slots array if needed diff --git a/Sources/IPCamKit/RTP/InorderParser.swift b/Sources/IPCamKit/RTP/InorderParser.swift index 7bf1a1e..be56460 100644 --- a/Sources/IPCamKit/RTP/InorderParser.swift +++ b/Sources/IPCamKit/RTP/InorderParser.swift @@ -29,9 +29,17 @@ struct InorderParser: Sendable { private var isTcp: Bool var timeline: Timeline private var unknownRtcpSsrcPolicy: UnknownRtcpSsrcPolicy - private var seenUnknownRtcpSession: Bool = false private let onDiagnostic: (@Sendable (RTSPDiagnostic) -> Void)? + /// Kinds of recurring per-packet anomaly we warn about at most once per stream, + /// so a hostile/sloppy camera can't flood the diagnostic sink with one + /// diagnostic per packet. + private enum DiagKind: Sendable { + case malformedRtp, ssrcMismatch, rtpTimeline, outOfOrderTcp + case unknownRtcpSsrc, malformedRtcp, rtcpTimeline + } + private var warned: Set = [] + /// Number of RTP packets seen. private(set) var seenRtpPackets: UInt64 = 0 @@ -51,6 +59,17 @@ struct InorderParser: Sendable { self.onDiagnostic = onDiagnostic } + /// Emit `message` via `onDiagnostic` only the first time `kind` occurs on this + /// stream; later occurrences are silent (rate-limited per the diagnostic + /// contract). + private mutating func warnOnce( + _ kind: DiagKind, _ severity: RTSPDiagnostic.Severity, + _ message: @autoclosure () -> String + ) { + guard warned.insert(kind).inserted else { return } + onDiagnostic?(RTSPDiagnostic(severity: severity, message: message())) + } + /// Process an incoming RTP packet. /// /// Returns a ReceivedRTPPacket if the packet should be processed, @@ -66,7 +85,13 @@ struct InorderParser: Sendable { case .success(let pkt): raw = pkt case .failure(let reason): - throw RTSPError.depacketizationError("Invalid RTP packet: \(reason)") + // A malformed datagram (truncated, wrong version, bad padding/extension) + // is dropped, never fatal: one bad packet must not tear down the stream. + warnOnce( + .malformedRtp, .warning, + "Invalid RTP packet from camera; dropping: \(reason). " + + "Further malformed RTP on this stream is dropped silently.") + return nil } // Geovision quirk: skip PT=50 packets @@ -74,14 +99,26 @@ struct InorderParser: Sendable { return nil } - // SSRC validation - if let expectedSSRC = ssrc { - guard raw.ssrc == expectedSSRC else { + // SSRC validation. A mismatch (e.g. a camera that rotated its SSRC after an + // internal restart, or a stray/spoofed datagram) drops by default rather + // than killing the stream; `.abortSession` is the opt-in fatal policy. + if let expectedSSRC = ssrc, raw.ssrc != expectedSSRC { + switch unknownRtcpSsrcPolicy { + case .abortSession: throw RTSPError.depacketizationError( "SSRC mismatch: expected \(String(format: "%08x", expectedSSRC)), " + "got \(String(format: "%08x", raw.ssrc))") + case .dropPackets: + warnOnce( + .ssrcMismatch, .warning, + "RTP SSRC mismatch (expected \(String(format: "%08x", expectedSSRC)), " + + "got \(String(format: "%08x", raw.ssrc))); dropping. " + + "Further SSRC-mismatched RTP on this stream is dropped silently.") + return nil + case .processPackets: + break } - } else { + } else if ssrc == nil { ssrc = raw.ssrc } @@ -89,17 +126,20 @@ struct InorderParser: Sendable { var loss: UInt16 = 0 if let expected = nextSeq { let delta = raw.sequenceNumber &- expected - if delta > 0x8000 { + // Top bit set (delta in 0x8000...0xFFFF) means the packet sits in the + // backward half of the sequence space: out of order or a duplicate. + // 0x8000 itself is maximally ambiguous; treat it as out of order rather + // than reporting a spurious 32768-packet loss. + if delta >= 0x8000 { // Out of order. UDP reordering is normal and stays silent; TCP-interleaved // reordering means the camera's packetizer wrote sequence-numbers out of - // order before muxing, which is camera misbehavior worth surfacing. + // order before muxing, which is camera misbehavior worth surfacing (once). if isTcp { - onDiagnostic?( - RTSPDiagnostic( - severity: .warning, - message: - "Out-of-order RTP packet on TCP-interleaved transport: " - + "seq=\(raw.sequenceNumber), expected=\(expected); packet dropped.")) + warnOnce( + .outOfOrderTcp, .warning, + "Out-of-order RTP packet on TCP-interleaved transport: " + + "seq=\(raw.sequenceNumber), expected=\(expected); packet dropped. " + + "Further out-of-order RTP on this stream is dropped silently.") } return nil } @@ -108,8 +148,18 @@ struct InorderParser: Sendable { nextSeq = raw.sequenceNumber &+ 1 seenRtpPackets += 1 - // Advance timeline - let timestamp = try timeline.advanceTo(raw.rtpTimestamp) + // Advance timeline. A rejected/overflowing timestamp drops this packet + // rather than aborting the session. + let timestamp: Timestamp + do { + timestamp = try timeline.advanceTo(raw.rtpTimestamp) + } catch { + warnOnce( + .rtpTimeline, .warning, + "RTP timestamp rejected (\(error)); dropping. " + + "Further timeline errors on this stream are dropped silently.") + return nil + } return ReceivedRTPPacket( ctx: ctx, @@ -130,12 +180,23 @@ struct InorderParser: Sendable { streamId: Int, data: Data ) throws -> ReceivedCompoundPacket? { - let firstPkt = try ReceivedCompoundPacket.validate(data) - var rtpTimestamp: Timestamp? + // A malformed RTCP compound packet is dropped, not fatal. + let firstPkt: RTCPPacketRef + let sr: SenderReportRef? + do { + firstPkt = try ReceivedCompoundPacket.validate(data) + sr = try firstPkt.asSenderReport() + } catch { + warnOnce( + .malformedRtcp, .warning, + "Invalid RTCP packet from camera; dropping: \(error). " + + "Further malformed RTCP on this stream is dropped silently.") + return nil + } - if let sr = try firstPkt.asSenderReport() { - rtpTimestamp = try timeline.place(sr.rtpTimestamp) + var rtpTimestamp: Timestamp? + if let sr = sr { let srSSRC = sr.ssrc if let knownSSRC = ssrc, knownSSRC != srSSRC { switch unknownRtcpSsrcPolicy { @@ -144,9 +205,16 @@ struct InorderParser: Sendable { "Expected ssrc=\(String(format: "%08x", knownSSRC)), " + "got RTCP SR ssrc=\(String(format: "%08x", srSSRC))") case .dropPackets: - if !seenUnknownRtcpSession { - seenUnknownRtcpSession = true - } + // Drop BEFORE touching the timeline: place() permanently anchors the + // timeline `start` on the first SR, so a foreign/early SR must not be + // allowed to corrupt the origin used by every subsequent RTP packet's + // NPT. Warn once per stream; further unknown-SSRC SRs drop silently. + warnOnce( + .unknownRtcpSsrc, .warning, + "RTCP Sender Report with unexpected ssrc=" + + "\(String(format: "%08x", srSSRC)) (expected " + + "\(String(format: "%08x", knownSSRC))); dropping. Further " + + "unknown-SSRC RTCP on this stream is dropped silently.") return nil case .processPackets: break @@ -154,6 +222,17 @@ struct InorderParser: Sendable { } else if ssrc == nil && unknownRtcpSsrcPolicy != .processPackets { ssrc = srSSRC } + // Only anchor/advance the timeline for an SR we are actually keeping. A + // rejected/overflowing SR timestamp drops the packet rather than aborting. + do { + rtpTimestamp = try timeline.place(sr.rtpTimestamp) + } catch { + warnOnce( + .rtcpTimeline, .warning, + "RTCP SR timestamp rejected (\(error)); dropping. " + + "Further RTCP timeline errors on this stream are dropped silently.") + return nil + } } seenRtcpPackets += 1 diff --git a/Sources/IPCamKit/RTP/RTCPPacket.swift b/Sources/IPCamKit/RTP/RTCPPacket.swift index 7327f42..4eb6c78 100644 --- a/Sources/IPCamKit/RTP/RTCPPacket.swift +++ b/Sources/IPCamKit/RTP/RTCPPacket.swift @@ -9,8 +9,6 @@ private let rtcpCommonHeaderLen = 4 /// RTCP Sender Report payload type. let rtcpPayloadTypeSR: UInt8 = 200 -/// RTCP Receiver Report payload type. -let rtcpPayloadTypeRR: UInt8 = 201 /// A parsed RTCP packet reference. /// @@ -90,18 +88,6 @@ struct RTCPPacketRef: Sendable { buf } - /// Parse as a typed packet (SR or RR) if payload type is recognized. - func asTyped() throws -> TypedPacketRef? { - switch payloadType { - case rtcpPayloadTypeSR: - return .senderReport(try SenderReportRef.validate(self)) - case rtcpPayloadTypeRR: - return .receiverReport(try ReceiverReportRef.validate(self)) - default: - return nil - } - } - /// Parse as a sender report if the type matches. func asSenderReport() throws -> SenderReportRef? { guard payloadType == rtcpPayloadTypeSR else { return nil } @@ -109,12 +95,6 @@ struct RTCPPacketRef: Sendable { } } -/// A payload type-specific RTCP packet accessor. -enum TypedPacketRef: Sendable { - case senderReport(SenderReportRef) - case receiverReport(ReceiverReportRef) -} - /// A Sender Report (PT=200), RFC 3550 section 6.4.1. struct SenderReportRef: Sendable { private static let headerLen = 8 @@ -163,34 +143,6 @@ struct SenderReportRef: Sendable { } } -/// A Receiver Report (PT=201), RFC 3550 section 6.4.2. -struct ReceiverReportRef: Sendable { - private static let headerLen = 8 - private static let reportBlockLen = 24 - - let pkt: RTCPPacketRef - - /// Validate that the packet length is consistent with the report block count. - static func validate(_ pkt: RTCPPacketRef) throws -> ReceiverReportRef { - let count = Int(pkt.count) - let expectedLen = headerLen + (count * reportBlockLen) - guard pkt.payloadEnd >= expectedLen else { - throw RTSPError.depacketizationError( - "RTCP RR has invalid count=\(count) with unpadded_byte_len=\(pkt.payloadEnd)") - } - return ReceiverReportRef(pkt: pkt) - } - - /// SSRC of sender. - var ssrc: UInt32 { - let base = pkt.buf.startIndex + 4 - return UInt32(pkt.buf[base]) << 24 - | UInt32(pkt.buf[base + 1]) << 16 - | UInt32(pkt.buf[base + 2]) << 8 - | UInt32(pkt.buf[base + 3]) - } -} - /// A validated RTCP compound packet. /// /// Validated per RFC 3550 Appendix A.2: @@ -217,16 +169,4 @@ struct ReceivedCompoundPacket: Sendable { } return firstPkt } - - /// Iterate over all packets in this compound packet. - func packets() -> [RTCPPacketRef] { - var result: [RTCPPacketRef] = [] - var remaining = raw - while !remaining.isEmpty { - guard let (pkt, rest) = try? RTCPPacketRef.parse(remaining) else { break } - result.append(pkt) - remaining = rest - } - return result - } } diff --git a/Sources/IPCamKit/RTP/RTPPacket.swift b/Sources/IPCamKit/RTP/RTPPacket.swift index 1d5817f..ac3ef5f 100644 --- a/Sources/IPCamKit/RTP/RTPPacket.swift +++ b/Sources/IPCamKit/RTP/RTPPacket.swift @@ -85,7 +85,10 @@ struct RawRTPPacket: Sendable, Equatable { payloadEnd = len } - let range = Int(payloadStart)..= 0 else { + throw RTSPError.depacketizationError("Invalid Content-Length: \(cl.1)") + } + // Reject an absurd declared body up front so we never try to buffer it. + guard len <= Self.maxBodyBytes else { + throw RTSPError.depacketizationError( + "Content-Length too large: \(len) (max \(Self.maxBodyBytes))") + } bodyLength = len } - let totalLength = headerEnd - buffer.startIndex + bodyLength - guard buffer.count >= totalLength else { + let headerBytes = headerEnd - buffer.startIndex + // If the body hasn't fully arrived, wait for more data. Comparing against + // the bytes remaining (rather than computing `headerBytes + bodyLength` + // first) keeps an absurd Content-Length such as Int.max from overflowing. + guard bodyLength <= buffer.count - headerBytes else { return nil // Need more data for body } + let totalLength = headerBytes + bodyLength var body = Data() if bodyLength > 0 { @@ -137,14 +167,21 @@ public struct RTSPParser: Sendable { /// Parse "RTSP/1.0 200 OK" into components. func parseStatusLine(_ line: String) throws -> (String, UInt16, String) { - // Split into at most 3 parts: version, status code, reason phrase - let parts = line.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: false) + // Split into at most 3 parts: version, status code, reason phrase. + // Separate on runs of spaces/tabs so a doubled space ("RTSP/1.0 200 OK") + // or the odd tab-delimited camera still reads the code from the right field. + let parts = line.split( + maxSplits: 2, omittingEmptySubsequences: true, + whereSeparator: { $0 == " " || $0 == "\t" }) guard parts.count >= 2 else { throw RTSPError.depacketizationError("Invalid RTSP status line: \(line)") } let version = String(parts[0]) - guard let statusCode = UInt16(parts[1]) else { + // Read the leading ASCII digits of the code field so trailing junk on the + // status code doesn't abort an otherwise usable connection. + let digits = parts[1].prefix(while: { $0.isASCII && $0.isNumber }) + guard let statusCode = UInt16(digits) else { throw RTSPError.depacketizationError("Invalid status code in: \(line)") } let reasonPhrase = parts.count >= 3 ? String(parts[2]) : "" @@ -152,18 +189,30 @@ public struct RTSPParser: Sendable { return (version, statusCode, reasonPhrase) } - /// Find the position of "\r\n\r\n" in the data, returning the range of the double CRLF. - /// The lowerBound is the start of the first \r\n, the upperBound is after the second \r\n. + /// Find the header/body boundary (the blank line) and return its byte range: + /// `lowerBound` is the start of the terminating line break, `upperBound` the + /// first body byte. Recognizes `\r\n\r\n` (per RFC) and, for sloppy cameras, + /// a bare `\n\n`. Scans the `Data` in place via `withUnsafeBytes` — no + /// per-call full-buffer copy — so repeated calls as a body streams in stay + /// O(n) overall rather than O(n^2). func findDoubleCRLF(in data: Data) -> Range? { - let bytes = [UInt8](data) - guard bytes.count >= 4 else { return nil } - for i in 0...(bytes.count - 4) { - if bytes[i] == 0x0D && bytes[i + 1] == 0x0A - && bytes[i + 2] == 0x0D && bytes[i + 3] == 0x0A - { - return (data.startIndex + i)..<(data.startIndex + i + 4) + let start = data.startIndex + return data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Range? in + let n = raw.count + guard n >= 2 else { return nil } + var i = 0 + while i < n - 1 { + if raw[i] == 0x0A && raw[i + 1] == 0x0A { + return (start + i)..<(start + i + 2) // bare LF + LF + } + if i + 3 < n && raw[i] == 0x0D && raw[i + 1] == 0x0A + && raw[i + 2] == 0x0D && raw[i + 3] == 0x0A + { + return (start + i)..<(start + i + 4) // CR LF CR LF + } + i += 1 } + return nil } - return nil } } diff --git a/Sources/IPCamKit/RTSP/RTSPSerializer.swift b/Sources/IPCamKit/RTSP/RTSPSerializer.swift index 43fa58e..21b1781 100644 --- a/Sources/IPCamKit/RTSP/RTSPSerializer.swift +++ b/Sources/IPCamKit/RTSP/RTSPSerializer.swift @@ -49,6 +49,10 @@ public struct RTSPSerializer: Sendable { /// /// Format: '$' + channel_id (1 byte) + length (2 bytes BE) + data public func serializeInterleaved(_ interleavedData: RTSPInterleavedData) -> Data { + precondition( + interleavedData.data.count <= 0xFFFF, + "interleaved frame is \(interleavedData.data.count) bytes; " + + "the 16-bit length field caps it at 65535") var result = Data(capacity: 4 + interleavedData.data.count) result.append(0x24) // '$' result.append(interleavedData.channelId) diff --git a/Sources/IPCamKit/SDP/SDPParser.swift b/Sources/IPCamKit/SDP/SDPParser.swift index 68b28da..ec3c47b 100644 --- a/Sources/IPCamKit/SDP/SDPParser.swift +++ b/Sources/IPCamKit/SDP/SDPParser.swift @@ -57,6 +57,8 @@ public struct SDPParser: Sendable { case "b": if currentMedia != nil { currentMedia!.bandwidth = value + } else { + session.bandwidth = value } case "t": session.timing = value @@ -88,19 +90,29 @@ public struct SDPParser: Sendable { /// Parse an `a=` attribute line value. /// Format: `name:value` or just `name` (property attribute). + /// + /// The attribute *name* is lowercased so matching is case-insensitive, + /// consistent with the rest of the stack (RTSP headers, rtpmap encoding, + /// proto): real camera firmware sometimes emits `a=Control:`/`a=RTPMAP:`. + /// The *value* keeps its original case (base64 sprop, control URLs, etc.). func parseAttribute(_ value: String) -> SDPAttribute { if let colonIdx = value.firstIndex(of: ":") { - let name = String(value[value.startIndex.. [...]` func parseMediaLine(_ value: String) throws -> SDPMediaDescription { - let parts = value.split(separator: " ", maxSplits: 3) + // Fields are space-delimited per RFC 8866, but tolerate tabs / repeated + // spaces from sloppy cameras. maxSplits 3 keeps the whole fmt list in the + // last field. + let parts = value.split( + maxSplits: 3, omittingEmptySubsequences: true, + whereSeparator: { $0 == " " || $0 == "\t" }) guard parts.count >= 4 else { throw RTSPError.invalidSDP("Invalid media line: m=\(value)") } diff --git a/Sources/IPCamKit/SDP/SDPSession.swift b/Sources/IPCamKit/SDP/SDPSession.swift index 01afe88..e59517b 100644 --- a/Sources/IPCamKit/SDP/SDPSession.swift +++ b/Sources/IPCamKit/SDP/SDPSession.swift @@ -21,20 +21,26 @@ public struct SDPSession: Sendable { /// t= (timing) public var timing: String? + /// b= (bandwidth, session-level) + public var bandwidth: String? + /// Session-level attributes public var attributes: [SDPAttribute] = [] /// Media descriptions public var mediaDescriptions: [SDPMediaDescription] = [] - /// Look up a session-level attribute by name. Returns the first match. + /// Look up a session-level attribute by name (case-insensitive). Returns the + /// first match. public func attribute(_ name: String) -> SDPAttribute? { - attributes.first { $0.name == name } + let key = name.lowercased() + return attributes.first { $0.name == key } } - /// Look up all session-level attributes with the given name. + /// Look up all session-level attributes with the given name (case-insensitive). public func attributes(named name: String) -> [SDPAttribute] { - attributes.filter { $0.name == name } + let key = name.lowercased() + return attributes.filter { $0.name == key } } } @@ -61,14 +67,17 @@ public struct SDPMediaDescription: Sendable { /// Media-level attributes public var attributes: [SDPAttribute] = [] - /// Look up a media-level attribute by name. Returns the first match. + /// Look up a media-level attribute by name (case-insensitive). Returns the + /// first match. public func attribute(_ name: String) -> SDPAttribute? { - attributes.first { $0.name == name } + let key = name.lowercased() + return attributes.first { $0.name == key } } - /// Look up all media-level attributes with the given name. + /// Look up all media-level attributes with the given name (case-insensitive). public func attributes(named name: String) -> [SDPAttribute] { - attributes.filter { $0.name == name } + let key = name.lowercased() + return attributes.filter { $0.name == key } } } diff --git a/Sources/IPCamKit/Timestamp.swift b/Sources/IPCamKit/Timestamp.swift index f16a06e..ff5180c 100644 --- a/Sources/IPCamKit/Timestamp.swift +++ b/Sources/IPCamKit/Timestamp.swift @@ -64,7 +64,7 @@ public struct Timestamp: Sendable, Equatable, CustomStringConvertible, CustomDeb /// The Unix epoch as an NTP timestamp. /// NTP epoch is 1900-01-01, Unix epoch is 1970-01-01, difference is 2,208,988,800 seconds. -public let ntpUnixEpoch = NtpTimestamp(rawValue: 2_208_988_800 << 32) +private let ntpUnixEpoch = NtpTimestamp(rawValue: 2_208_988_800 << 32) /// A wallclock time in Network Time Protocol format. /// @@ -79,9 +79,14 @@ public struct NtpTimestamp: Sendable, Equatable, Comparable, CustomStringConvert self.rawValue = rawValue } - /// Convert to a Foundation Date (assumes time is within 68 years of 1970). + /// Convert to a Foundation Date. NTP times before the Unix epoch — e.g. an + /// unsynced camera that sends 0 — are clamped to 1970-01-01 rather than + /// wrapping to an absurd far-future date. public var date: Date { - let sinceEpoch = rawValue &- ntpUnixEpoch.rawValue + guard rawValue >= ntpUnixEpoch.rawValue else { + return Date(timeIntervalSince1970: 0) + } + let sinceEpoch = rawValue - ntpUnixEpoch.rawValue let seconds = Double(sinceEpoch >> 32) let fraction = Double(sinceEpoch & 0xFFFF_FFFF) / Double(UInt64(1) << 32) return Date(timeIntervalSince1970: seconds + fraction) diff --git a/Sources/IPCamKit/Transport/RTSPConnection.swift b/Sources/IPCamKit/Transport/RTSPConnection.swift index 4072f73..5024d2f 100644 --- a/Sources/IPCamKit/Transport/RTSPConnection.swift +++ b/Sources/IPCamKit/Transport/RTSPConnection.swift @@ -18,30 +18,91 @@ actor RTSPTransportConnection { private let parser = RTSPParser() private var readBuffer = Data() private var connectionContext: ConnectionContext? - private var readPos: UInt64 = 0 + + /// Hard cap on the unparsed read buffer. A peer that never frames a message — + /// no header terminator, or a `$`-interleaved frame whose length is never + /// satisfied — would otherwise grow this without bound (memory-DoS). A full + /// interleaved frame is at most ~64 KiB and an RTSP message body is capped at + /// `RTSPParser.maxBodyBytes`, so this leaves wide headroom for any real camera. + private static let maxReadBufferBytes = 4 * 1024 * 1024 + + /// Maximum time to wait for the TCP connection to become ready. NWConnection + /// has no built-in connect deadline, so a half-open server would otherwise + /// hang `connect()` forever. + private let connectTimeoutSeconds: Double = 15 + + /// Grace window for a transient `.waiting` state (e.g. connection refused / + /// no route, which NWConnection parks in `.waiting` rather than `.failed`) + /// before surfacing the real error, so an unreachable camera fails fast with + /// its actual cause instead of burning the full connect timeout. + private let waitingGraceSeconds: Double = 2 + + /// Count of interleaved data frames dropped by `receiveResponse` while waiting + /// for an RTSP response (a camera that streamed media before its PLAY reply). + private(set) var droppedInterleavedBeforeResponse = 0 + + /// One-shot guard so the connect continuation resumes exactly once across the + /// state handler and the timeout. Both run on the serial `queue`, so plain + /// access is race-free (hence `@unchecked Sendable`). + private final class ConnectGuard: @unchecked Sendable { + var settled = false + } /// Connect to an RTSP server. func connect(host: String, port: UInt16) async throws { let nwHost = NWEndpoint.Host(host) - let nwPort = NWEndpoint.Port(rawValue: port)! + guard let nwPort = NWEndpoint.Port(rawValue: port) else { + throw RTSPError.connectionFailed("Invalid port \(port)") + } let conn = NWConnection(host: nwHost, port: nwPort, using: .tcp) self.connection = conn - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - conn.stateUpdateHandler = { state in - switch state { - case .ready: - continuation.resume() - case .failed(let error): - continuation.resume(throwing: RTSPError.connectionFailed(error.localizedDescription)) - case .cancelled: - continuation.resume(throwing: RTSPError.unexpectedDisconnection) - default: - break + let guardState = ConnectGuard() + let timeout = connectTimeoutSeconds + do { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + // Resume exactly once; only ever called on the serial `queue`. + @Sendable func settle(_ result: Result) { + guard !guardState.settled else { return } + guardState.settled = true + continuation.resume(with: result) + } + let grace = waitingGraceSeconds + let graceQueue = queue + conn.stateUpdateHandler = { state in + switch state { + case .ready: + settle(.success(())) + case .failed(let error): + settle(.failure(RTSPError.connectionFailed(error.localizedDescription))) + case .waiting(let error): + // NWConnection parks in .waiting for ECONNREFUSED/EHOSTUNREACH + // instead of going to .failed. Give it a brief grace window to + // self-recover, then surface the real error rather than waiting out + // the full connect timeout. A later .ready/.failed settles first + // (the one-shot guard makes this deferred settle a no-op). + graceQueue.asyncAfter(deadline: .now() + grace) { + settle(.failure(RTSPError.connectionFailed(error.localizedDescription))) + } + case .cancelled: + settle(.failure(RTSPError.unexpectedDisconnection)) + default: + break + } + } + queue.asyncAfter(deadline: .now() + timeout) { + settle(.failure(RTSPError.timeout)) } + conn.start(queue: queue) } - conn.start(queue: queue) + } catch { + // On timeout/failure, tear down the half-open socket. A late `.cancelled` + // from this is ignored by the already-settled guard. + conn.cancel() + connection = nil + throw error } // Clear the state handler after connection @@ -93,12 +154,19 @@ actor RTSPTransportConnection { while true { // Try to parse from existing buffer var bufferCopy = readBuffer - if let (msg, consumed) = try parser.parse(&bufferCopy) { + if let (msg, _) = try parser.parse(&bufferCopy) { readBuffer = bufferCopy - readPos += UInt64(consumed) return msg } + // No complete message yet. Bound the buffer before reading more so a peer + // that never frames a message can't exhaust memory. Checked here (after + // the parse attempt) so a message that just completed still parses. + guard readBuffer.count <= Self.maxReadBufferBytes else { + throw RTSPError.connectionFailed( + "RTSP read buffer exceeded \(Self.maxReadBufferBytes) bytes without a complete message") + } + // Need more data let newData = try await readData() readBuffer.append(newData) @@ -106,17 +174,30 @@ actor RTSPTransportConnection { } /// Receive the next RTSP response, skipping interleaved data. + /// + /// Only used on the pre-PLAY request/response path (OPTIONS/DESCRIBE/SETUP/ + /// PLAY). Per RFC 2326 the server must not stream media before the PLAY reply, + /// so no interleaved `.data` is expected here; any that arrives (a + /// non-conformant camera bursting media on the control channel before PLAY) is + /// dropped and counted in `droppedInterleavedBeforeResponse` for diagnostics. func receiveResponse() async throws -> RTSPResponse { while true { let msg = try await receiveMessage() if case .response(let resp) = msg { return resp } - // Skip interleaved data while waiting for response + // Interleaved data before the awaited response — drop and tally it. + droppedInterleavedBeforeResponse += 1 } } /// Read raw data from the connection. + /// + /// Known limitation: there is no per-read idle timeout. A peer that goes + /// silent after setup leaves this parked until `close()` (via `stop()`) is + /// called or the keepalive detects the dead session. An idle timeout is + /// deliberately omitted — sizing it without killing legitimately bursty / + /// low-FPS streams is unsafe — so liveness is the keepalive/stop() contract. private func readData() async throws -> Data { guard let conn = connection else { throw RTSPError.unexpectedDisconnection @@ -149,4 +230,39 @@ actor RTSPTransportConnection { var ctx: ConnectionContext { connectionContext ?? .dummy() } + + /// The resolved remote address (and family) of the control connection, if + /// available. Lets UDP transport pick its local socket family and target the + /// server's RTP/RTCP ports without a second DNS lookup when the SETUP response + /// omits an explicit `source`. The host is a numeric literal — a dotted IPv4 + /// address or a bare IPv6 address (with zone id for link-local) — never + /// bracketed, so it can be handed straight to `NWEndpoint.Host`. Returns `nil` + /// for unresolved endpoints. + func resolvedRemote() -> ResolvedPeer? { + guard case .hostPort(let host, _)? = connection?.currentPath?.remoteEndpoint else { + return nil + } + switch host { + case .ipv4(let addr): + return ResolvedPeer(host: "\(addr)", isIPv6: false) + case .ipv6(let addr): + return ResolvedPeer(host: "\(addr)", isIPv6: true) + case .name: + // An unresolved hostname endpoint is not directly usable as a UDP peer + // (no family known); callers fall back to the URL host / source instead. + return nil + @unknown default: + return nil + } + } +} + +/// The resolved remote peer of the control connection: a numeric host literal +/// plus its address family. Drives the UDP transport's local socket family and +/// RTP/RTCP peer selection. +struct ResolvedPeer: Sendable { + /// Numeric IP literal (dotted IPv4 or bare IPv6, with zone id for link-local). + let host: String + /// `true` for IPv6, `false` for IPv4. + let isIPv6: Bool } diff --git a/Sources/IPCamKit/Transport/UDPTransport.swift b/Sources/IPCamKit/Transport/UDPTransport.swift index 5fb4f89..5b4ef26 100644 --- a/Sources/IPCamKit/Transport/UDPTransport.swift +++ b/Sources/IPCamKit/Transport/UDPTransport.swift @@ -1,127 +1,439 @@ // Copyright (c) 2025 Steel Brain // SPDX-License-Identifier: MIT -// UDP transport for RTP/RTCP using NWConnection +// UDP transport for RTP/RTCP using Apple's Network framework. import Foundation import Network -/// A pair of UDP sockets for RTP (even port) and RTCP (odd port). +/// Address family for a UDP RTP/RTCP socket pair, chosen to match the resolved +/// RTSP control-connection peer so the local bind family matches the server. +enum UDPAddressFamily: Sendable { + case ipv4 + case ipv6 + + /// Wildcard local-bind host for this family (`0.0.0.0` / `::`). + var wildcardHost: NWEndpoint.Host { + switch self { + case .ipv4: return NWEndpoint.Host("0.0.0.0") + case .ipv6: return NWEndpoint.Host("::") + } + } + + /// Loopback literal for this family, used as a last-resort peer fallback. + var loopback: String { + switch self { + case .ipv4: return "127.0.0.1" + case .ipv6: return "::1" + } + } +} + +/// A pair of connected UDP flows for one RTP stream: RTP on an even local port +/// and RTCP on the consecutive odd port (`rtpPort + 1`), as required by RFC 3550 +/// and the `client_port=X-Y` SETUP transport parameter. /// -/// Ports upstream UdpPair from src/lib.rs lines 442-497. -actor UDPPair { +/// Built on Apple's Network framework (`NWListener`/`NWConnection`) — the same +/// stack the RTSP control connection (`RTSPTransportConnection`) uses — so IPv4 +/// and IPv6 peers are handled natively via `NWEndpoint`, with no `Darwin`/BSD +/// socket calls. +/// +/// The lifecycle is what makes UDP awkward: the even/odd local ports must be +/// reserved and advertised in SETUP *before* the server's `server_port` is known, +/// then connected *after* it is reported. Network framework's `NWConnection` +/// requires the remote at creation, so the local port is first reserved by an +/// `NWListener` that stays bound across the whole SETUP round-trip. In +/// `connect()` that listener is released and an `NWConnection` is bound to the +/// same local port and connected to the server — a connected UDP flow, so the +/// kernel filters inbound datagrams to the peer (preserving the symmetric-RTP +/// assumption) and `holePunch()`/sends can omit the address. +/// +/// There is a microsecond window between releasing the reservation and binding +/// the connection where the local port is unowned. It is far smaller than the +/// SETUP round-trip (which the held listener covers), is single-host, and mirrors +/// the TOCTOU the live-test port reservation already tolerates. +/// +/// `@unchecked Sendable`: `rtpPort`/`rtcpPort`/`family`/`queue` are immutable +/// after `bind()`; all other mutable state (`rtpListener`, `rtcpListener`, +/// `rtpConn`, `rtcpConn`, `receiving`, `closed`) is guarded by `lock`. The +/// `NWListener`/`NWConnection` callbacks all run on the private serial `queue`. +final class UDPPair: @unchecked Sendable { + /// The bound local RTP port (even). let rtpPort: UInt16 - private var rtpConnection: NWConnection? - private var rtcpConnection: NWConnection? - private let queue = DispatchQueue(label: "ipcamkit.udp.pair") + /// The bound local RTCP port (`rtpPort + 1`, odd). + let rtcpPort: UInt16 + + private let family: UDPAddressFamily + /// Private serial queue for all Network framework callbacks (reservation, + /// connect handoff, receive loops) — never a Swift Concurrency cooperative + /// thread. + private let queue: DispatchQueue - private static let maxTries = 10 - private static let portRange: Range = 5000..<65000 + private let lock = NSLock() + /// Reservation listeners holding the local ports from `bind()` until `connect()` + /// hands the ports to the data connections. Nil once consumed or closed. + private var rtpListener: NWListener? + private var rtcpListener: NWListener? + /// Connected data flows, set by `connect()`. Nil until connected / after close. + private var rtpConn: NWConnection? + private var rtcpConn: NWConnection? + private var receiving = false + private var closed = false - init(rtpPort: UInt16) { + /// Max attempts to land an even RTP port with a free consecutive odd RTCP + /// port before giving up. Ephemeral ports are ~50/50 even/odd, so a handful + /// of tries is overwhelmingly sufficient. + private static let maxBindTries = 20 + /// How long to wait for a reservation listener to bind, or a data connection + /// to become ready, before treating the attempt as failed. + private static let reserveTimeoutSeconds: Double = 3 + private static let connectTimeoutSeconds: Double = 10 + + private init( + family: UDPAddressFamily, queue: DispatchQueue, + rtpListener: NWListener, rtcpListener: NWListener, + rtpPort: UInt16, rtcpPort: UInt16 + ) { + self.family = family + self.queue = queue + self.rtpListener = rtpListener + self.rtcpListener = rtcpListener self.rtpPort = rtpPort + self.rtcpPort = rtcpPort + } + + /// One-shot resume guard for a `withChecked…Continuation`. All accesses occur + /// on the pair's serial `queue`, so the plain `Bool` is race-free. + private final class Settle: @unchecked Sendable { + private var done = false + func run(_ body: () -> Void) { + guard !done else { return } + done = true + body() + } } - /// Bind a UDP pair to a local IP, finding an available even/odd port pair. - static func bind(localIP: String = "0.0.0.0") async throws -> UDPPair { - for _ in 0.. UDPPair { + let queue = DispatchQueue(label: "ipcamkit.udp.\(family)") + for _ in 0.. Reservation? { + let params = NWParameters.udp + params.allowLocalEndpointReuse = true + let portEndpoint: NWEndpoint.Port + if requestedPort == 0 { + portEndpoint = .any + } else if let p = NWEndpoint.Port(rawValue: requestedPort) { + portEndpoint = p + } else { + return nil + } + params.requiredLocalEndpoint = .hostPort(host: family.wildcardHost, port: portEndpoint) - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - conn.stateUpdateHandler = { state in + guard let listener = try? NWListener(using: params) else { return nil } + + return await withCheckedContinuation { (cont: CheckedContinuation) in + let guardBox = Settle() + // UDP listeners require a new-connection handler; we never accept inbound + // here (the reservation only holds the port), so connections are dropped. + listener.newConnectionHandler = { $0.cancel() } + listener.stateUpdateHandler = { state in switch state { case .ready: - continuation.resume() - case .failed(let error): - continuation.resume(throwing: RTSPError.connectionFailed("UDP RTP: \(error)")) - case .cancelled: - continuation.resume(throwing: RTSPError.unexpectedDisconnection) + if let port = listener.port?.rawValue { + guardBox.run { cont.resume(returning: Reservation(listener: listener, port: port)) } + } else { + listener.cancel() + guardBox.run { cont.resume(returning: nil) } + } + case .failed: + listener.cancel() + guardBox.run { cont.resume(returning: nil) } default: break } } - conn.start(queue: queue) + listener.start(queue: queue) + queue.asyncAfter(deadline: .now() + reserveTimeoutSeconds) { + guardBox.run { + listener.cancel() + cont.resume(returning: nil) + } + } } - conn.stateUpdateHandler = nil } - /// Connect the RTCP socket to a peer address. - func connectRTCP(host: String, port: UInt16) async throws { - let conn = NWConnection( - host: NWEndpoint.Host(host), - port: NWEndpoint.Port(rawValue: port)!, - using: .udp) + // MARK: - Connecting - rtcpConnection = conn + /// Connect both flows to the server's RTP (`peerRTPPort`) and RTCP + /// (`peerRTPPort + 1`) ports. Releases each reservation listener and binds an + /// `NWConnection` to the freed local port, connected to the peer. Connecting + /// filters inbound datagrams to the peer (kernel drops spoofed/stray packets) + /// and lets `holePunch()`/sends omit the address. `peerHost` must be a numeric + /// literal of this pair's address family (or a name `NWEndpoint` can resolve); + /// IPv4 and IPv6 (including link-local zone ids) are both supported. + /// + /// Known limitation: `establish()` is not Task-cancellation-aware, so a + /// cancellation while connecting is observed only when the per-flow connect + /// times out (`connectTimeoutSeconds`), not immediately; the `catch` here then + /// cancels the already-established RTP flow, so nothing leaks past that point. + func connect(peerHost: String, peerRTPPort: UInt16) async throws { + guard peerRTPPort < UInt16.max else { + throw RTSPError.transportNegotiationFailed + } - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - conn.stateUpdateHandler = { state in - switch state { - case .ready: - continuation.resume() - case .failed(let error): - continuation.resume(throwing: RTSPError.connectionFailed("UDP RTCP: \(error)")) - case .cancelled: - continuation.resume(throwing: RTSPError.unexpectedDisconnection) - default: - break + // Lock access is confined to synchronous helpers so the lock is never held + // across an `await` (NSLock is unavailable from async contexts). + guard let (rtpL, rtcpL) = listenersForConnect() else { + throw RTSPError.connectionFailed("UDP connect on a closed or unbound pair") + } + + let rtp = try await establish( + listener: rtpL, localPort: rtpPort, peerHost: peerHost, peerPort: peerRTPPort) + let rtcp: NWConnection + do { + rtcp = try await establish( + listener: rtcpL, localPort: rtcpPort, peerHost: peerHost, peerPort: peerRTPPort + 1) + } catch { + rtp.cancel() + throw error + } + + // A concurrent close() may have raced this connect; honor it. + guard storeConnections(rtp: rtp, rtcp: rtcp) else { + rtp.cancel() + rtcp.cancel() + throw RTSPError.connectionFailed("UDP pair closed during connect") + } + } + + /// Snapshot the reservation listeners for `connect()`, or `nil` if the pair is + /// closed or already connected. Synchronous so it never holds the lock across + /// an `await`. + private func listenersForConnect() -> (rtp: NWListener, rtcp: NWListener)? { + lock.lock() + defer { lock.unlock() } + guard !closed, let rtpListener, let rtcpListener else { return nil } + return (rtpListener, rtcpListener) + } + + /// Adopt the connected flows (clearing the consumed reservation listeners), + /// returning `false` if `close()` raced in first — in which case the caller + /// cancels the connections. Synchronous, for the same reason as above. + private func storeConnections(rtp: NWConnection, rtcp: NWConnection) -> Bool { + lock.lock() + defer { lock.unlock() } + guard !closed else { return false } + rtpConn = rtp + rtcpConn = rtcp + rtpListener = nil + rtcpListener = nil + return true + } + + /// Release `listener` (it holds `localPort`), then bind an `NWConnection` to + /// that same local port and connect it to `peerHost:peerPort`. The listener is + /// cancelled regardless of outcome. Throws on bind/connect failure or timeout. + private func establish( + listener: NWListener, localPort: UInt16, peerHost: String, peerPort: UInt16 + ) async throws -> NWConnection { + guard let nwLocalPort = NWEndpoint.Port(rawValue: localPort), + let nwPeerPort = NWEndpoint.Port(rawValue: peerPort) + else { + listener.cancel() + throw RTSPError.transportNegotiationFailed + } + + let params = NWParameters.udp + params.allowLocalEndpointReuse = true + params.requiredLocalEndpoint = .hostPort(host: family.wildcardHost, port: nwLocalPort) + let conn = NWConnection(host: NWEndpoint.Host(peerHost), port: nwPeerPort, using: params) + let queue = self.queue + + return try await withCheckedThrowingContinuation { + (cont: CheckedContinuation) in + let guardBox = Settle() + // All of these run on `queue` (serial), so `guardBox` is race-free. + @Sendable func settle(_ result: Result) { + guardBox.run { cont.resume(with: result) } + } + // Bind the data connection only once the reservation has fully released + // the port (the listener reaches `.cancelled`). + listener.stateUpdateHandler = { state in + guard case .cancelled = state else { return } + listener.stateUpdateHandler = nil + conn.stateUpdateHandler = { connState in + switch connState { + case .ready: + conn.stateUpdateHandler = nil + settle(.success(conn)) + case .failed(let error): + conn.cancel() + settle(.failure(RTSPError.connectionFailed("UDP connect failed: \(error)"))) + case .cancelled: + settle(.failure(RTSPError.connectionFailed("UDP connection cancelled before ready"))) + default: + break + } + } + conn.start(queue: queue) + } + listener.cancel() + queue.asyncAfter(deadline: .now() + Self.connectTimeoutSeconds) { + // Cancel only if the timeout actually wins the race; once the connection + // reached `.ready` the guard is spent and the live `conn` is left alone. + guardBox.run { + conn.cancel() + cont.resume(with: .failure(RTSPError.timeout)) } } - conn.start(queue: queue) } - conn.stateUpdateHandler = nil } - /// Receive an RTP packet. - func receiveRTP() async throws -> Data { - guard let conn = rtpConnection else { - throw RTSPError.unexpectedDisconnection + /// Best-effort NAT/firewall hole-punch: send one empty datagram from each + /// local port to its connected peer so the server learns our mapping and its + /// RTP/RTCP can return. Harmless on loopback/LAN; errors are ignored (an empty + /// datagram is not a valid RTP/RTCP packet, so the server simply drops it). + func holePunch() { + lock.lock() + let conns = closed ? [] : [rtpConn, rtcpConn].compactMap { $0 } + lock.unlock() + for conn in conns { + conn.send(content: Data(), completion: .contentProcessed { _ in }) } - return try await receiveFrom(conn) } - /// Receive an RTCP packet. - func receiveRTCP() async throws -> Data { - guard let conn = rtcpConnection else { - throw RTSPError.unexpectedDisconnection + // MARK: - Receiving + + /// Begin delivering datagrams: `onRTP`/`onRTCP` are invoked (on the private + /// serial queue) once per received datagram. No-op if already closed or not + /// yet connected, or if receiving already started. + func startReceiving( + onRTP: @escaping @Sendable (Data) -> Void, + onRTCP: @escaping @Sendable (Data) -> Void, + onError: (@Sendable (Error) -> Void)? = nil + ) { + lock.lock() + guard !closed, !receiving, let rtpConn, let rtcpConn else { + lock.unlock() + return } - return try await receiveFrom(conn) + receiving = true + lock.unlock() + receiveLoop(conn: rtpConn, handler: onRTP, onError: onError) + receiveLoop(conn: rtcpConn, handler: onRTCP, onError: onError) } - private func receiveFrom(_ conn: NWConnection) async throws -> Data { - try await withCheckedThrowingContinuation { continuation in - conn.receiveMessage { content, _, _, error in - if let error = error { - continuation.resume(throwing: RTSPError.connectionFailed("UDP recv: \(error)")) - } else if let data = content { - continuation.resume(returning: data) - } else { - continuation.resume(throwing: RTSPError.unexpectedDisconnection) - } + /// Receive one datagram and re-arm. For UDP, `receiveMessage` delivers exactly + /// one datagram per call and its `isComplete` is true *per datagram* — it is + /// NOT end-of-stream — so we re-arm regardless, stopping only on a hard error + /// or once `close()` has run. The re-arm is event-driven (one datagram → one + /// callback), never a busy loop. + private func receiveLoop( + conn: NWConnection, handler: @escaping @Sendable (Data) -> Void, + onError: (@Sendable (Error) -> Void)? + ) { + conn.receiveMessage { [weak self] content, _, _, error in + guard let self else { return } + // Check `closed` before delivering so a datagram completing concurrently + // with close() is neither delivered nor re-armed. + self.lock.lock() + let stillOpen = !self.closed + self.lock.unlock() + guard stillOpen else { return } + if let data = content, !data.isEmpty { + handler(data) + } + // A receive error on a connected UDP flow (e.g. an ICMP port-unreachable + // for a NAT'd camera) is terminal for this flow — stop re-arming (re-arming + // a .failed flow would busy-loop) but surface it so it isn't a silent + // stall. + guard error == nil else { + onError?(error!) + return } + self.receiveLoop(conn: conn, handler: handler, onError: onError) } } - /// Close both sockets. + // MARK: - Teardown + + /// Cancel any reservation listeners and data connections, stopping delivery. + /// Idempotent. Setting `closed` before cancelling stops the receive loops from + /// re-arming, so an in-flight receive completes and unwinds. func close() { - rtpConnection?.cancel() - rtcpConnection?.cancel() - rtpConnection = nil - rtcpConnection = nil + lock.lock() + guard !closed else { + lock.unlock() + return + } + closed = true + let listeners = [rtpListener, rtcpListener].compactMap { $0 } + let conns = [rtpConn, rtcpConn].compactMap { $0 } + rtpListener = nil + rtcpListener = nil + rtpConn = nil + rtcpConn = nil + lock.unlock() + for listener in listeners { + listener.cancel() + } + for conn in conns { + conn.cancel() + } + } + + // MARK: - Host classification + + /// Whether `host` is a numeric IPv4 literal (no DNS/zone handling needed). + static func isNumericIPv4(_ host: String) -> Bool { + IPv4Address(host) != nil + } + + /// Whether `host` is a numeric IPv6 literal, including link-local with a zone + /// id (e.g. `fe80::1%en0`), which `NWEndpoint.Host` resolves natively. + static func isNumericIPv6(_ host: String) -> Bool { + IPv6Address(host) != nil } } diff --git a/Tests/IPCamKitTests/AACTests.swift b/Tests/IPCamKitTests/AACTests.swift index 4abc1ef..d6b5d6a 100644 --- a/Tests/IPCamKitTests/AACTests.swift +++ b/Tests/IPCamKitTests/AACTests.swift @@ -48,6 +48,38 @@ struct AACTests { #expect(rfc3640.parameters.rfc6381Codec == "mp4a.40.2") } + @Test("AAC trusts the ASC over an rtpmap channel mismatch (#5)") + func aacChannelMismatchTrustsASC() throws { + // config=1188 is mono; an rtpmap claiming stereo must NOT reject the stream. + let d = try AACDepacketizer(clockRate: 48_000, channels: 2, formatSpecificParams: aacFmtp) + #expect(d.parameters.clockRate == 48_000) + } + + @Test("AAC accepts an RTP clock that is a small multiple of the ASC rate (#6)") + func aacClockRateMultiple() throws { + // config=1308 encodes AOT2 / 24000 Hz / mono; HE-AAC commonly doubles the + // RTP clock, so 48000 (= 2x) must be accepted. + let heFmtp = + "streamtype=5;profile-level-id=1;mode=AAC-hbr;sizelength=13;" + + "indexlength=3;indexdeltalength=3;config=1308" + _ = try AACDepacketizer(clockRate: 48_000, channels: nil, formatSpecificParams: heFmtp) + // A genuinely unrelated rate (44100 vs 24000) is still rejected. + #expect(throws: DepacketizeError.self) { + _ = try AACDepacketizer(clockRate: 44_100, channels: nil, formatSpecificParams: heFmtp) + } + } + + @Test("AAC rejects an AU-headers-length that isn't a multiple of 16 (#17)") + func aacAuHeadersLengthMultipleOf16() throws { + var d = try AACDepacketizer(clockRate: 48_000, channels: nil, formatSpecificParams: aacFmtp) + // 24 bits is divisible by 8 but not 16: the old guard accepted it and + // truncated to one header (misparse); the tightened guard rejects it. + let payload = Data([0x00, 0x18]) + Data(repeating: 0, count: 10) + #expect(throws: DepacketizeError.self) { + try d.push(makeAACPacket(seq: 0, mark: true, payload: payload)) + } + } + // MARK: - Depacketizer Tests @Test("Depacketize happy path: single, aggregate, fragment") diff --git a/Tests/IPCamKitTests/DescribeParserTests.swift b/Tests/IPCamKitTests/DescribeParserTests.swift index 8793d85..606c0df 100644 --- a/Tests/IPCamKitTests/DescribeParserTests.swift +++ b/Tests/IPCamKitTests/DescribeParserTests.swift @@ -629,6 +629,40 @@ struct DescribeParserTests { #expect(p.streams.count == 1) } + @Test("SDP attribute names are matched case-insensitively") + func caseInsensitiveSDPAttributes() throws { + let sdp = [ + "v=0", + "o=- 0 0 IN IP4 0.0.0.0", + "s=Stream", + "b=AS:512", + "m=video 0 RTP/AVP 96", + "a=RTPMAP:96 H264/90000", + "a=Control:trackID=7", + ].joined(separator: "\r\n") + let session = try SDPParser().parse(sdp) + // Session-level b= is retained (finding #43). + #expect(session.bandwidth == "AS:512") + let media = session.mediaDescriptions[0] + // Off-case a=Control/a=RTPMAP still resolve (findings #45/#48). + let stream = try parseMedia(baseURL: "rtsp://host/path", mediaDescription: media) + #expect(stream.encodingName == "h264") + #expect(stream.clockRateHz == 90000) + #expect(stream.control == "rtsp://host/path/trackID=7") + } + + @Test("Tab-delimited m= line parses") + func tabDelimitedMediaLine() throws { + let sdp = "v=0\r\nm=video\t0\tRTP/AVP\t96\r\na=rtpmap:96 H264/90000\r\n" + let session = try SDPParser().parse(sdp) + let media = session.mediaDescriptions[0] + #expect(media.media == "video") + #expect(media.proto == "RTP/AVP") + let stream = try parseMedia(baseURL: "rtsp://h/p", mediaDescription: media) + #expect(stream.rtpPayloadType == 96) + #expect(stream.encodingName == "h264") + } + // Test 19: rtp_info_trailing_semicolon // Uses gw_sub (single stream) to match upstream test @Test("RTP-Info trailing semicolon handled correctly") @@ -687,6 +721,19 @@ struct DescribeParserTests { #expect(setup.serverPort == 49152) } + // Test 23: anjvision — scheme-less Content-Base header + // The Content-Base is "192.168.1.10:554/stream0/" with no "rtsp://" prefix; + // it must be resolved against the request URL's scheme. Port of retina 6972ac4. + @Test("Anjvision scheme-less Content-Base resolves against request scheme") + func anjvision() throws { + let url = "rtsp://192.168.1.10:554/stream0" + let p = try loadDescribe(url: url, filename: "anjvision_describe.txt") + #expect(p.tool == "LIVE555 Streaming Media v2011.05.25 CHAM.LI@ANJVISION.COM") + #expect(p.baseURL == "rtsp://192.168.1.10:554/stream0/") + #expect(p.streams.count == 1) + #expect(p.streams[0].control == "rtsp://192.168.1.10:554/stream0/trackID=1") + } + // MARK: - Video-less stream configurations (Axis `video=0`) @Test("Axis audio-only SDP (no video stream)") @@ -812,4 +859,69 @@ struct DescribeParserTests { #expect(usable.audio == nil) #expect(usable.metadata != nil) } + + @Test("parseDescribe surfaces a dropped unparseable stream via diagnostics") + func describeDropsUnparseableStreamWithDiagnostic() throws { + // One valid H.264 video stream plus an audio line with a dynamic payload + // type (99) and no rtpmap, which parseMedia can't resolve and drops. + let sdp = [ + "v=0", + "o=- 0 0 IN IP4 0.0.0.0", + "s=S", + "m=video 0 RTP/AVP 96", + "a=rtpmap:96 H264/90000", + "m=audio 0 RTP/AVP 99", + ].joined(separator: "\r\n") + let resp = RTSPResponse( + statusCode: 200, reasonPhrase: "OK", + headers: [("Content-Type", "application/sdp")], + body: Data(sdp.utf8)) + + final class Box: @unchecked Sendable { + let lock = NSLock() + var messages: [String] = [] + func add(_ d: RTSPDiagnostic) { + lock.lock() + defer { lock.unlock() } + messages.append(d.message) + } + } + let box = Box() + let p = try parseDescribe( + requestURL: "rtsp://h/p", response: resp, onDiagnostic: { box.add($0) }) + // The good video stream is kept; the unparseable audio stream is dropped... + #expect(p.streams.count == 1) + #expect(p.streams.first?.media == "video") + // ...and surfaced rather than lost silently. + #expect(box.messages.contains { $0.lowercased().contains("audio") }) + } + + @Test("SETUP tolerates Session timeout=0 (finding #46)") + func setupTimeoutZero() throws { + let resp = RTSPResponse( + statusCode: 200, reasonPhrase: "OK", + headers: [ + ("Session", "12345;timeout=0"), + ("Transport", "RTP/AVP/TCP;unicast;interleaved=0-1"), + ]) + let setup = try parseSetup(response: resp) + #expect(setup.session.timeoutSec == 60) // default cadence, not aborted + #expect(setup.channelId == 0) + } + + @Test("SETUP accepts single-value interleaved, rejects non-consecutive (finding #47)") + func setupInterleavedSingleValue() throws { + func setup(_ interleaved: String) throws -> SetupResponse { + try parseSetup( + response: RTSPResponse( + statusCode: 200, reasonPhrase: "OK", + headers: [ + ("Session", "12345;timeout=60"), + ("Transport", "RTP/AVP/TCP;unicast;interleaved=\(interleaved)"), + ])) + } + #expect(try setup("0").channelId == 0) + #expect(try setup("2-3").channelId == 2) + #expect(throws: RTSPError.self) { _ = try setup("0-5") } + } } diff --git a/Tests/IPCamKitTests/H264DepacketizerTests.swift b/Tests/IPCamKitTests/H264DepacketizerTests.swift index 9a5fc72..0d72235 100644 --- a/Tests/IPCamKitTests/H264DepacketizerTests.swift +++ b/Tests/IPCamKitTests/H264DepacketizerTests.swift @@ -34,6 +34,31 @@ let reolinkFmtp = @Suite("H.264 Depacketizer Tests") struct H264DepacketizerTests { + /// A never-terminating FU-A (START once, never END/MARK, same timestamp) must + /// not grow memory without bound — the access-unit cap aborts it (finding #7). + @Test("Never-terminating FU-A is bounded by the access-unit cap") + func fuaAccessUnitCap() throws { + var d = try H264Depacketizer(clockRate: 90000, formatSpecificParams: nil) + let chunk = Data(repeating: 0x42, count: 60_000) + // FU-A start: indicator type 28 (0x7C), FU header start+type-1 (0x81). + try d.push( + makePacket(seq: 0, timestamp: ts0, mark: false, payload: Data([0x7C, 0x81]) + chunk)) + var threw = false + for seq in 1...500 { + do { + // FU-A continuation (no start/end, type 1), same timestamp, no mark. + try d.push( + makePacket( + seq: UInt16(seq), timestamp: ts0, mark: false, payload: Data([0x7C, 0x01]) + chunk)) + } catch { + threw = true + break + } + } + // 16 MiB cap trips well before 500 * 60 KB (~30 MB) is buffered. + #expect(threw) + } + /// Test 1: Basic depacketization with SEI + STAP-A + FU-A. @Test("Depacketize SEI + STAP-A + FU-A") func depacketize() throws { @@ -55,25 +80,25 @@ struct H264DepacketizerTests { try d.push(makePacket(seq: 1, timestamp: ts0, mark: false, payload: stapPayload)) #expect(d.pull() == nil) - // Packet 3: FU-A start + // Packet 3: FU-A start (non-IDR slice, type 1 — SEI can no longer end an AU) try d.push( makePacket( seq: 2, timestamp: ts0, mark: false, - payload: Data([0x7C, 0x86]) + Data("fu-a start, ".utf8))) + payload: Data([0x7C, 0x81]) + Data("fu-a start, ".utf8))) #expect(d.pull() == nil) // Packet 4: FU-A middle try d.push( makePacket( seq: 3, timestamp: ts0, mark: false, - payload: Data([0x7C, 0x06]) + Data("fu-a middle, ".utf8))) + payload: Data([0x7C, 0x01]) + Data("fu-a middle, ".utf8))) #expect(d.pull() == nil) // Packet 5: FU-A end try d.push( makePacket( seq: 4, timestamp: ts0, mark: true, - payload: Data([0x7C, 0x46]) + Data("fu-a end".utf8))) + payload: Data([0x7C, 0x41]) + Data("fu-a end".utf8))) guard case .success(.videoFrame(let frame)) = d.pull() else { Issue.record("Expected video frame") @@ -90,8 +115,8 @@ struct H264DepacketizerTests { // NAL 3: SEI "stap-a 2" (len=9) expected.append(contentsOf: [0x00, 0x00, 0x00, 0x09, 0x06]) expected.append(Data("stap-a 2".utf8)) - // NAL 4: FU-A reassembled (len=34=0x22, header=0x66) - expected.append(contentsOf: [0x00, 0x00, 0x00, 0x22, 0x66]) + // NAL 4: FU-A reassembled (len=34=0x22, header=0x61 — non-IDR slice) + expected.append(contentsOf: [0x00, 0x00, 0x00, 0x22, 0x61]) expected.append(Data("fu-a start, fu-a middle, fu-a end".utf8)) #expect(frame.data == expected) @@ -104,25 +129,25 @@ struct H264DepacketizerTests { func depacketizeReservedBitSet() throws { var d = try H264Depacketizer(clockRate: 90000, formatSpecificParams: dahuaFmtp) - // FU-A start with reserved bit (0xA6 = START=1, reserved=1, type=6) + // FU-A start with reserved bit (0xA1 = START=1, reserved=1, type=1 slice) try d.push( makePacket( seq: 2, timestamp: ts0, mark: false, - payload: Data([0x7C, 0xA6]) + Data("fu-a start, ".utf8))) + payload: Data([0x7C, 0xA1]) + Data("fu-a start, ".utf8))) #expect(d.pull() == nil) // FU-A middle with reserved bit try d.push( makePacket( seq: 3, timestamp: ts0, mark: false, - payload: Data([0x7C, 0x26]) + Data("fu-a middle, ".utf8))) + payload: Data([0x7C, 0x21]) + Data("fu-a middle, ".utf8))) #expect(d.pull() == nil) // FU-A end with reserved bit try d.push( makePacket( seq: 4, timestamp: ts0, mark: true, - payload: Data([0x7C, 0x66]) + Data("fu-a end".utf8))) + payload: Data([0x7C, 0x61]) + Data("fu-a end".utf8))) guard case .success(.videoFrame(let frame)) = d.pull() else { Issue.record("Expected video frame") @@ -130,11 +155,68 @@ struct H264DepacketizerTests { } var expected = Data() - expected.append(contentsOf: [0x00, 0x00, 0x00, 0x22, 0x66]) + expected.append(contentsOf: [0x00, 0x00, 0x00, 0x22, 0x61]) expected.append(Data("fu-a start, fu-a middle, fu-a end".utf8)) #expect(frame.data == expected) } + /// SEI NAL with the MARK bit set must not end an access unit. + /// + /// Ports upstream `depacketize_sei_with_mark` (h264.rs). The LV-IP22IR40DVBL + /// camera ("H264DVR 1.0" firmware) sends SPS, PPS, and SEI as individual RTP + /// packets each with the MARK bit incorrectly set, all at the same timestamp + /// as the following IDR slice. Per H.264 section 7.4.1.2.3, these non-VCL + /// units precede the primary coded picture, so they must not end an AU. + /// See . + @Test("SEI with MARK does not end access unit") + func depacketizeSEIWithMark() throws { + let fmtp = + "packetization-mode=1;profile-level-id=4d002a;sprop-parameter-sets=Z00AKpWoHgCJ+WEAAAMAAQAAAwAyhA==,aO48gA==" + var d = try H264Depacketizer(clockRate: 90000, formatSpecificParams: fmtp) + + // SPS with (incorrect) mark. + let spsData = Data([ + 0x67, 0x4D, 0x00, 0x2A, 0x95, 0xA8, 0x1E, 0x00, + 0x89, 0xF9, 0x61, 0x00, 0x00, 0x03, 0x00, 0x01, + 0x00, 0x00, 0x03, 0x00, 0x32, 0x84, + ]) + try d.push(makePacket(seq: 0, timestamp: ts0, mark: true, payload: spsData)) + #expect(d.pull() == nil) + + // PPS with (incorrect) mark. + let ppsData = Data([0x68, 0xEE, 0x3C, 0x80]) + try d.push(makePacket(seq: 1, timestamp: ts0, mark: true, payload: ppsData)) + #expect(d.pull() == nil) + + // SEI (reserved payload type 229) with (incorrect) mark. + let seiData = Data([0x06, 0xE5, 0x01, 0xA7, 0x80]) + try d.push(makePacket(seq: 2, timestamp: ts0, mark: true, payload: seiData)) + #expect(d.pull() == nil) + + // IDR slice with mark (correct this time) — only now does the AU close. + try d.push( + makePacket(seq: 3, timestamp: ts0, mark: true, payload: Data([0x65]) + Data("slice".utf8))) + + guard case .success(.videoFrame(let frame)) = d.pull() else { + Issue.record("Expected one access unit containing SPS, PPS, SEI, and IDR") + return + } + + var expected = Data() + expected.append(contentsOf: [0x00, 0x00, 0x00, 0x16]) // SPS, len 22 + expected.append(spsData) + expected.append(contentsOf: [0x00, 0x00, 0x00, 0x04]) // PPS, len 4 + expected.append(ppsData) + expected.append(contentsOf: [0x00, 0x00, 0x00, 0x05]) // SEI, len 5 + expected.append(seiData) + expected.append(contentsOf: [0x00, 0x00, 0x00, 0x06, 0x65]) // IDR, len 6 + expected.append(Data("slice".utf8)) + + #expect(frame.data == expected) + #expect(frame.isRandomAccessPoint) + #expect(d.pull() == nil) + } + /// Test 3: Reolink bad framing at start (SPS with incorrect mark bit). @Test("Reolink bad framing at start") func depacketizeReolinkBadFramingAtStart() throws { @@ -440,25 +522,25 @@ struct H264DepacketizerTests { func emptyFragment() throws { var d = try H264Depacketizer(clockRate: 90000, formatSpecificParams: dahuaFmtp) - // FU-A start + // FU-A start (non-IDR slice, type 1 — SEI can no longer end an AU) try d.push( makePacket( seq: 0, timestamp: ts0, mark: false, - payload: Data([0x7C, 0x86]) + Data("start, ".utf8))) + payload: Data([0x7C, 0x81]) + Data("start, ".utf8))) #expect(d.pull() == nil) // FU-A middle with empty payload try d.push( makePacket( seq: 1, timestamp: ts0, mark: false, - payload: Data([0x7C, 0x06]))) + payload: Data([0x7C, 0x01]))) #expect(d.pull() == nil) // FU-A end try d.push( makePacket( seq: 2, timestamp: ts0, mark: true, - payload: Data([0x7C, 0x46]) + Data("end".utf8))) + payload: Data([0x7C, 0x41]) + Data("end".utf8))) guard case .success(.videoFrame(let frame)) = d.pull() else { Issue.record("Expected frame") @@ -466,7 +548,7 @@ struct H264DepacketizerTests { } var expected = Data() - expected.append(contentsOf: [0x00, 0x00, 0x00, 0x0B, 0x66]) // len=11, header=0x66 + expected.append(contentsOf: [0x00, 0x00, 0x00, 0x0B, 0x61]) // len=11, header=0x61 expected.append(Data("start, end".utf8)) #expect(frame.data == expected) } @@ -635,12 +717,12 @@ struct H264DepacketizerTests { payload: Data([0x7C, 0x86]) + Data("fu-a start, ".utf8))) #expect(d.pull() == nil) - // Plain SEI with different timestamp — triggers error + // Plain non-IDR slice with different timestamp — triggers error // Upstream uses sequence_number: 0 for both packets try d.push( makePacket( seq: 0, timestamp: ts1, mark: true, - payload: Data([0x06]) + Data("plain".utf8))) + payload: Data([0x01]) + Data("plain".utf8))) // First pull: error about timestamp change mid-fragment // Upstream asserts exact error string @@ -659,7 +741,7 @@ struct H264DepacketizerTests { return } var expected = Data() - expected.append(contentsOf: [0x00, 0x00, 0x00, 0x06, 0x06]) + expected.append(contentsOf: [0x00, 0x00, 0x00, 0x06, 0x01]) expected.append(Data("plain".utf8)) #expect(frame.data == expected) @@ -680,10 +762,11 @@ struct H264DepacketizerTests { try d.push(makePacket(seq: 0, timestamp: ts0, mark: false, payload: Data())) #expect(d.pull() == nil) - // A subsequent valid packet still produces a frame. + // A subsequent valid packet still produces a frame. Uses a non-IDR slice + // (type 1) since an SEI (type 6) can no longer end an access unit. try d.push( makePacket( - seq: 1, timestamp: ts0, mark: true, payload: Data([0x06]) + Data("plain".utf8))) + seq: 1, timestamp: ts0, mark: true, payload: Data([0x01]) + Data("plain".utf8))) guard case .success(.videoFrame(let frame)) = d.pull() else { Issue.record("Expected video frame after empty packet") @@ -691,11 +774,45 @@ struct H264DepacketizerTests { } var expected = Data() - expected.append(contentsOf: [0x00, 0x00, 0x00, 0x06, 0x06]) + expected.append(contentsOf: [0x00, 0x00, 0x00, 0x06, 0x01]) expected.append(Data("plain".utf8)) #expect(frame.data == expected) #expect(d.pull() == nil) } + + /// A single-fragment FU-A (both START and END set) carries a complete NAL. + /// + /// Ports upstream `single_fragment_fu_a` (h264.rs). RFC 6184 section 5.8 + /// forbids this, but some cameras wrap small NALs in a one-packet FU-A + /// rather than sending them as a single NAL. Treat it as a complete NAL + /// instead of erroring. + @Test("Single-fragment FU-A treated as complete NAL") + func singleFragmentFuA() throws { + var d = try H264Depacketizer(clockRate: 90000, formatSpecificParams: dahuaFmtp) + #expect(d.seenSingleFragmentFuA == false) + + // FU-A with S=1 E=1, type=1 (non-IDR slice). FU indicator 0x7c (type 28), + // FU header 0xc1 (start + end + type 1). + try d.push( + makePacket( + seq: 0, timestamp: ts0, mark: true, + payload: Data([0x7C, 0xC1]) + Data("small nal".utf8))) + + #expect(d.seenSingleFragmentFuA == true) + + guard case .success(.videoFrame(let frame)) = d.pull() else { + Issue.record("Expected video frame from single-fragment FU-A") + return + } + + // Reconstructed NAL header: NRI=3 (from indicator) | type=1 (from FU hdr) + // = 0x61, then "small nal" (9 bytes); length = 1 + 9 = 10 = 0x0a. + var expectedFu = Data() + expectedFu.append(contentsOf: [0x00, 0x00, 0x00, 0x0A, 0x61]) + expectedFu.append(Data("small nal".utf8)) + #expect(frame.data == expectedFu) + #expect(d.pull() == nil) + } } // MARK: - Test Data diff --git a/Tests/IPCamKitTests/H265DepacketizerTests.swift b/Tests/IPCamKitTests/H265DepacketizerTests.swift index 63a7334..421fdc6 100644 --- a/Tests/IPCamKitTests/H265DepacketizerTests.swift +++ b/Tests/IPCamKitTests/H265DepacketizerTests.swift @@ -249,6 +249,75 @@ struct H265DepacketizerTests { #expect(p.genericParameters.pixelDimensions?.height == 1296) } + /// A never-terminating FU (START once, never END/MARK, same timestamp) must + /// not grow memory without bound — the access-unit cap aborts it (finding #30). + @Test("Never-terminating FU is bounded by the access-unit cap") + func fuAccessUnitCap() throws { + var d = try H265Depacketizer(clockRate: 90000, formatSpecificParams: nil) + let chunk = Data(repeating: 0x42, count: 60_000) + // FU start: NAL header type 49 (0x62 0x01), FU header start+type-20 (0x94). + try d.push( + makeH265Packet( + seq: 0, timestamp: h265Ts0, mark: false, + payload: Data([0x62, 0x01, 0x94]) + chunk)) + var threw = false + for seq in 1...500 { + do { + // FU continuation (no start/end, type 20), same timestamp, no mark. + try d.push( + makeH265Packet( + seq: UInt16(seq), timestamp: h265Ts0, mark: false, + payload: Data([0x62, 0x01, 0x14]) + chunk)) + } catch { + threw = true + break + } + } + #expect(threw) + } + + // Reusable valid dahua sprop trio for fmtp-tolerance tests. + static let dahuaVPS = "QAEMAf//AWAAAAMAsAAAAwAAAwBarAwAAAMABAAAAwAyqA==" + static let dahuaSPS = "QgEBAWAAAAMAsAAAAwAAAwBaoAWCAeFja5JFL83BQYFBAAADAAEAAAMADKE=" + static let dahuaPPS = "RAHA8saNA7NA" + + @Test("fmtp tolerates case-insensitive keys, valueless tokens, trailing ';' (#14/#26/#28)") + func tolerantFmtpParsing() throws { + let fmtp = + "profile-id=1; Sprop-VPS=\(Self.dahuaVPS); SPROP-SPS=\(Self.dahuaSPS); " + + "sprop-pps=\(Self.dahuaPPS); recvonly;" + let p = try H265Parameters.parseFormatSpecificParams(fmtp) + #expect(p.vpsNAL == Data(base64Encoded: Self.dahuaVPS)) + #expect(p.spsNAL == Data(base64Encoded: Self.dahuaSPS)) + #expect(p.ppsNAL == Data(base64Encoded: Self.dahuaPPS)) + } + + @Test("fmtp: tx-mode case-insensitive; sprop-max-don-diff>0 fails loud (#8/#28)") + func txModeAndDonDiff() throws { + let trio = + "sprop-vps=\(Self.dahuaVPS);sprop-sps=\(Self.dahuaSPS);sprop-pps=\(Self.dahuaPPS)" + _ = try H265Parameters.parseFormatSpecificParams("tx-mode=srst;\(trio)") + _ = try H265Parameters.parseFormatSpecificParams("sprop-max-don-diff=0;\(trio)") + #expect(throws: RTSPError.self) { + _ = try H265Parameters.parseFormatSpecificParams("sprop-max-don-diff=1;\(trio)") + } + } + + @Test("fmtp: comma-separated sprop list and Annex B prefix (#27/#29)") + func commaListAndAnnexBPrefix() throws { + let vpsData = Data(base64Encoded: Self.dahuaVPS)! + // Comma-separated VPS list: the first entry is used. + let commaVps = "\(Self.dahuaVPS),QgE=" + let p1 = try H265Parameters.parseFormatSpecificParams( + "sprop-vps=\(commaVps);sprop-sps=\(Self.dahuaSPS);sprop-pps=\(Self.dahuaPPS)") + #expect(p1.vpsNAL == vpsData) + // An Annex B start code prepended to the VPS is stripped. + let prefixed = (Data([0, 0, 0, 1]) + vpsData).base64EncodedString() + let p2 = try H265Parameters.parseFormatSpecificParams( + "sprop-vps=\(prefixed);sprop-sps=\(Self.dahuaSPS);sprop-pps=\(Self.dahuaPPS)") + #expect(p2.vpsNAL == vpsData) + } + /// Short RTP payloads (zero or one byte — too short for the 2-byte H.265 NAL /// header) are tolerated and do not tear the stream down. Previously this /// surfaced as a `DepacketizeError("Short NAL")` that propagated up and @@ -265,6 +334,36 @@ struct H265DepacketizerTests { try d.push(makeH265Packet(seq: 1, timestamp: h265Ts0, mark: false, payload: Data([0x40]))) #expect(d.pull() == nil) } + + /// A single-fragment FU (both START and END set) carries a complete NAL. + /// + /// Ports upstream `single_fragment_fu` (h265.rs). RFC 7798 section 4.4.3 + /// forbids this, but some cameras wrap small NALs in a one-packet FU rather + /// than sending them as a single NAL. Treat it as a complete NAL. + @Test("Single-fragment FU treated as complete NAL") + func singleFragmentFu() throws { + var d = try H265Depacketizer(clockRate: 90000, formatSpecificParams: nil) + #expect(d.seenSingleFragmentFu == false) + + // FU packet (\x62\x01 = type 49), FU header 0xc1 (start + end + type 1 TRAIL_R). + try d.push( + makeH265Packet( + seq: 0, timestamp: h265Ts0, mark: true, + payload: Data([0x62, 0x01, 0xC1]) + Data("small nal".utf8))) + + #expect(d.seenSingleFragmentFu == true) + + guard case .success(.videoFrame(let frame)) = d.pull() else { + Issue.record("Expected video frame from single-fragment FU") + return + } + + // Reconstructed NAL: type=1, layer=0, TID=1 -> header 0x02 0x01, then + // "small nal" (9 bytes); length = 2 (header) + 9 = 11 = 0x0b. + let expected: [UInt8] = [0x00, 0x00, 0x00, 0x0B, 0x02, 0x01] + Array("small nal".utf8) + assertDataEqual(frame.data, expected) + #expect(d.pull() == nil) + } } // MARK: - NAL Tests @@ -295,6 +394,24 @@ struct H265NALTests { #expect(timing.timeScale == 12) } + /// An oversized parameter NAL must be rejected with an error rather than + /// trapping the process in the u16-length HEVC record builder (finding #4). + @Test("Oversized parameter NAL is rejected, not a trap") + func oversizedParameterNALRejected() throws { + let vps = Data(base64Encoded: "QAEMAf//AWAAAAMAsAAAAwAAAwBarAwAAAMABAAAAwAyqA==")! + let sps = Data( + base64Encoded: "QgEBAWAAAAMAsAAAAwAAAwBaoAWCAeFja5JFL83BQYFBAAADAAEAAAMADKE=")! + let pps = Data(base64Encoded: "RAHA8saNA7NA")! + // The valid trio parses. + _ = try H265Parameters.parseVPSSPSPPS(vps: vps, sps: sps, pps: pps) + // A VPS padded past 65535 bytes must throw (the record stores NAL lengths in + // a u16), not trap. + let hugeVPS = vps + Data(repeating: 0, count: 70_000) + #expect(throws: RTSPError.self) { + _ = try H265Parameters.parseVPSSPSPPS(vps: hugeVPS, sps: sps, pps: pps) + } + } + /// Test 8: Parse SPS with inter-predicted ShortTermRefPicSet. @Test("Parse SPS with inter ref pic set prediction") func parseSPSWithInterRefPicSetPrediction() throws { diff --git a/Tests/IPCamKitTests/IntegrationTests.swift b/Tests/IPCamKitTests/IntegrationTests.swift index 94ea595..d7e10e6 100644 --- a/Tests/IPCamKitTests/IntegrationTests.swift +++ b/Tests/IPCamKitTests/IntegrationTests.swift @@ -25,7 +25,7 @@ struct IntegrationTests { #expect(depacketizer.parameters!.genericParameters.pixelDimensions?.height == 360) // Create timeline and inorder parser - var timeline = try Timeline(start: nil, clockRate: clockRate, enforceMaxJumpSecs: 10) + let timeline = try Timeline(start: nil, clockRate: clockRate, enforceMaxJumpSecs: 10) var parser = InorderParser( ssrc: nil, nextSeq: nil, isTcp: true, timeline: timeline) @@ -110,7 +110,7 @@ struct IntegrationTests { func secondGOP() throws { let clockRate: UInt32 = 90000 var depacketizer = try H264Depacketizer(clockRate: clockRate, formatSpecificParams: nil) - var timeline = try Timeline(start: nil, clockRate: clockRate) + let timeline = try Timeline(start: nil, clockRate: clockRate) var parser = InorderParser( ssrc: nil, nextSeq: nil, isTcp: true, timeline: timeline) diff --git a/Tests/IPCamKitTests/LiveIntegrationTests.swift b/Tests/IPCamKitTests/LiveIntegrationTests.swift new file mode 100644 index 0000000..a8c3533 --- /dev/null +++ b/Tests/IPCamKitTests/LiveIntegrationTests.swift @@ -0,0 +1,892 @@ +// Copyright (c) 2025 Steel Brain +// SPDX-License-Identifier: MIT +// Live end-to-end integration tests. +// +// `ffmpeg` publishes a synthetic stream to a `mediamtx` RTSP server, and +// `RTSPClientSession` pulls it back — exercising the full DESCRIBE -> SETUP -> +// PLAY -> depacketize pipeline against a real, spec-compliant server. Each test +// runs `mediamtx` in a single RTP transport mode (TCP-interleaved OR UDP) so the +// matching client transport is validated in isolation: a server in one mode +// rejects a SETUP for the other, so the client cannot silently fall back. +// +// These tests REQUIRE `ffmpeg` and `mediamtx` on `PATH` (see README "Testing"). +// They are serialized so only one server/publisher pair runs at a time. + +import Foundation +import Network +import Testing + +@testable import IPCamKit + +// MARK: - Errors + +private struct LiveError: Error, CustomStringConvertible { + let description: String + init(_ description: String) { self.description = description } +} + +// MARK: - Loopback port helpers (Network framework; IPv4 + IPv6) + +/// Address family for the live fixture's loopback server. Lets one fixture +/// exercise the client over both IPv4 (`127.0.0.1`) and IPv6 (`[::1]`). +enum LiveIPFamily: Sendable { + case ipv4 + case ipv6 + + /// Loopback literal for client/probe connections (bare, for `NWEndpoint`). + var loopback: String { self == .ipv6 ? "::1" : "127.0.0.1" } + /// Loopback host as it appears in an RTSP URL (IPv6 is bracketed). + var urlHost: String { self == .ipv6 ? "[::1]" : "127.0.0.1" } + /// Wildcard bind host for reserving ports in this family. + var wildcardHost: NWEndpoint.Host { + self == .ipv6 ? NWEndpoint.Host("::") : NWEndpoint.Host("0.0.0.0") + } + /// MediaMTX listen address for `port` in this family (`[::]:P` / `:P`). + func mediamtxAddress(_ port: UInt16) -> String { + self == .ipv6 ? "[::]:\(port)" : ":\(port)" + } +} + +/// Thread-safe box for handing a Network framework callback's result back to a +/// semaphore-blocked caller. The `DispatchSemaphore` signal/wait provides the +/// happens-before barrier; the lock guards against any spurious concurrent set. +private final class ResultBox: @unchecked Sendable { + private let lock = NSLock() + private var value: T + init(_ value: T) { self.value = value } + func set(_ newValue: T) { + lock.lock() + value = newValue + lock.unlock() + } + var get: T { + lock.lock() + defer { lock.unlock() } + return value + } +} + +/// Reserve a port by binding an `NWListener` (using `params`: `.tcp` or `.udp`) +/// to `family`'s wildcard address on `port` (0 = OS-assigned ephemeral). The +/// listener stays open so the caller holds the port reserved; the caller must +/// cancel it. Returns the listener plus the bound port, or `nil` on failure. +private func reserveListener( + _ params: NWParameters, family: LiveIPFamily, port: UInt16, queue: DispatchQueue +) -> (listener: NWListener, port: UInt16)? { + params.allowLocalEndpointReuse = true + let portEndpoint: NWEndpoint.Port + if port == 0 { + portEndpoint = .any + } else if let p = NWEndpoint.Port(rawValue: port) { + portEndpoint = p + } else { + return nil + } + params.requiredLocalEndpoint = .hostPort(host: family.wildcardHost, port: portEndpoint) + + guard let listener = try? NWListener(using: params) else { return nil } + let bound = ResultBox(nil) + let sem = DispatchSemaphore(value: 0) + listener.newConnectionHandler = { $0.cancel() } + listener.stateUpdateHandler = { state in + switch state { + case .ready: + bound.set(listener.port?.rawValue) + sem.signal() + case .failed, .cancelled: + // Leaves `bound` nil so the caller treats it as a failed reservation. + sem.signal() + default: + break + } + } + listener.start(queue: queue) + if sem.wait(timeout: .now() + 3) == .timedOut { + listener.cancel() + return nil + } + guard let p = bound.get else { + listener.cancel() + return nil + } + return (listener, p) +} + +/// Reserve a free RTSP TCP port plus a consecutive even/odd RTP/RTCP UDP pair on +/// `family`'s wildcard address. MediaMTX requires the RTP port to be even with +/// RTCP = RTP + 1. All listeners are held simultaneously so the ports are +/// distinct and free, then released for the child process. There is a small +/// TOCTOU window, but the live suite is serialized and binds the ports +/// immediately. The UDP pair is reserved with UDP listeners (not TCP) so the +/// ports are free in the protocol MediaMTX actually binds them in. +func reserveServerPorts( + family: LiveIPFamily +) -> (rtsp: UInt16, rtp: UInt16, rtcp: UInt16)? { + let queue = DispatchQueue(label: "ipcamkit.live.reserve") + var held: [NWListener] = [] + defer { for listener in held { listener.cancel() } } + + guard let rtsp = reserveListener(.tcp, family: family, port: 0, queue: queue) else { + return nil + } + held.append(rtsp.listener) + + for _ in 0..<400 { + guard let candidate = reserveListener(.udp, family: family, port: 0, queue: queue) else { + continue + } + let base = candidate.port + if base % 2 == 0, base > 0, base + 1 != rtsp.port, + let next = reserveListener(.udp, family: family, port: base + 1, queue: queue) + { + held.append(candidate.listener) + held.append(next.listener) + return (rtsp.port, base, base + 1) + } + candidate.listener.cancel() + } + return nil +} + +/// Whether a TCP connection to `family`'s loopback `port` succeeds right now. +private func canConnectLoopback(family: LiveIPFamily, _ port: UInt16) -> Bool { + guard let nwPort = NWEndpoint.Port(rawValue: port) else { return false } + let queue = DispatchQueue(label: "ipcamkit.live.probe") + let conn = NWConnection(host: NWEndpoint.Host(family.loopback), port: nwPort, using: .tcp) + let connected = ResultBox(false) + let sem = DispatchSemaphore(value: 0) + conn.stateUpdateHandler = { state in + switch state { + case .ready: + connected.set(true) + sem.signal() + case .waiting, .failed, .cancelled: + // Not connectable right now. `.waiting` MUST short-circuit here: a refused + // connection (server not yet listening) surfaces as `.waiting` (NWConnection + // treats ECONNREFUSED as transient), so waiting for `.ready`/`.failed` only + // would burn the full 2s timeout on every pre-startup probe and blow the + // ~10s startup budget. The next poll iteration retries on a fresh connection. + sem.signal() + default: + break + } + } + conn.start(queue: queue) + _ = sem.wait(timeout: .now() + 2) + conn.cancel() + return connected.get +} + +// MARK: - Fixture + +/// Owns a MediaMTX server + ffmpeg publisher for the lifetime of one test, and +/// guarantees both are killed on teardown. +/// +/// All blocking process/socket work (port reservation, launch, port-wait) runs +/// on a dedicated `DispatchQueue`, NOT on a Swift Concurrency cooperative +/// thread. Under the test runner's default parallelism, blocking a cooperative +/// thread can wedge the shared pool and deadlock unrelated tests; confining the +/// blocking work to a private thread avoids that. `@unchecked Sendable` is safe +/// because mutable state is only touched inside `startSync` (on `queue`) and in +/// `shutDown` (after `start()` has fully completed — a happens-before barrier). +final class LiveStreamFixture: @unchecked Sendable { + private(set) var rtspURL = "" + + private let transports: [String] + private let family: LiveIPFamily + private let readTimeoutSeconds: Int? + private let ffmpegArgs: [String] + private let marker: String + private let configPath: String + private let logPath: String + private let queue: DispatchQueue + private let mediamtx = Process() + private let ffmpeg = Process() + private var launched: [Process] = [] + + /// - Parameters: + /// - transports: value for MediaMTX `rtspTransports` (e.g. `["tcp"]` or + /// `["udp"]`). This gates both the ffmpeg publisher and the client reader, + /// so a single-element value validates exactly one RTP transport. + /// - ffmpegArgs: ffmpeg args between the program name and the output URL + /// (inputs + codec options); the `-f rtsp ... ` tail is appended. + /// - family: loopback address family the server binds and the client/ffmpeg + /// reach it over (`127.0.0.1` for IPv4, `[::1]` for IPv6). + init( + transports: [String], + family: LiveIPFamily = .ipv4, + readTimeoutSeconds: Int? = nil, + ffmpegArgs: [String] + ) { + let token = UUID().uuidString.prefix(8) + self.transports = transports + self.family = family + self.readTimeoutSeconds = readTimeoutSeconds + self.ffmpegArgs = ffmpegArgs + self.marker = "ipcamkit-live-\(token)" + self.configPath = NSTemporaryDirectory() + "ipcamkit-live-\(token).yml" + self.logPath = NSTemporaryDirectory() + "ipcamkit-live-\(token).log" + self.queue = DispatchQueue(label: "ipcamkit.live.\(token)") + } + + /// PATH that includes the usual Homebrew + system locations so `/usr/bin/env` + /// resolves `mediamtx`/`ffmpeg` regardless of how the test runner was invoked. + private static func childEnvironment() -> [String: String] { + var env = ProcessInfo.processInfo.environment + let extra = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" + env["PATH"] = env["PATH"].map { "\(extra):\($0)" } ?? extra + return env + } + + /// Reserve ports, launch MediaMTX, wait for its RTSP port, then launch the + /// ffmpeg publisher — all on the private queue so the calling cooperative + /// thread only suspends (never blocks). + func start() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + queue.async { + continuation.resume(with: Result { try self.startSync() }) + } + } + } + + private func startSync() throws { + guard let ports = reserveServerPorts(family: family) else { + throw LiveError("could not reserve RTSP + RTP/RTCP server ports") + } + rtspURL = "rtsp://\(family.urlHost):\(ports.rtsp)/\(marker)" + + let readTimeoutLine = readTimeoutSeconds.map { "readTimeout: \($0)s\n" } ?? "" + let config = """ + \(readTimeoutLine)logLevel: error + api: false + metrics: false + pprof: false + playback: false + rtmp: false + hls: false + webrtc: false + srt: false + rtsp: true + rtspTransports: [\(transports.joined(separator: ", "))] + rtspAddress: '\(family.mediamtxAddress(ports.rtsp))' + rtpAddress: '\(family.mediamtxAddress(ports.rtp))' + rtcpAddress: '\(family.mediamtxAddress(ports.rtcp))' + paths: + all_others: + """ + try config.write(toFile: configPath, atomically: true, encoding: .utf8) + + let env = Self.childEnvironment() + + // Capture mediamtx output to a file (not a pipe — pipes can deadlock) so a + // startup failure can be surfaced in the test error. + FileManager.default.createFile(atPath: logPath, contents: nil) + let logHandle = FileHandle(forWritingAtPath: logPath) + + mediamtx.executableURL = URL(fileURLWithPath: "/usr/bin/env") + mediamtx.arguments = ["mediamtx", configPath] + mediamtx.standardOutput = logHandle ?? FileHandle.nullDevice + mediamtx.standardError = logHandle ?? FileHandle.nullDevice + mediamtx.environment = env + + // `rtspTransports` gates the publisher too, not just the reader, so the + // ffmpeg publish leg must use a transport the server accepts. Prefer TCP + // when allowed (no publish-side packet loss); otherwise publish over UDP so + // a UDP-only server still receives the stream. + let publishTransport = transports.contains("tcp") ? "tcp" : "udp" + ffmpeg.executableURL = URL(fileURLWithPath: "/usr/bin/env") + ffmpeg.arguments = + ["ffmpeg", "-hide_banner", "-loglevel", "error"] + + ffmpegArgs + + ["-f", "rtsp", "-rtsp_transport", publishTransport, rtspURL] + ffmpeg.standardOutput = FileHandle.nullDevice + ffmpeg.standardError = FileHandle.nullDevice + ffmpeg.environment = env + + try mediamtx.run() + launched.append(mediamtx) + + // Blocking poll on this private thread (≈10s budget): fine here, off the + // cooperative pool. + var opened = false + for _ in 0..<67 { + if canConnectLoopback(family: family, ports.rtsp) { + opened = true + break + } + Thread.sleep(forTimeInterval: 0.15) + } + guard opened else { + let log = (try? String(contentsOfFile: logPath, encoding: .utf8)) ?? "" + throw LiveError( + "mediamtx RTSP port \(ports.rtsp) did not open within 10s. mediamtx log:\n\(log)") + } + + try ffmpeg.run() + launched.append(ffmpeg) + } + + /// Terminate both processes and remove the temp files. Runs on the calling + /// (cooperative) thread but does only fast, non-blocking work: SIGTERM plus a + /// SIGKILL backstop (`pkill -9 -f `), with no `waitUntilExit`. The + /// processes die asynchronously; the next serialized test uses fresh ports. + func shutDown() { + for p in launched where p.isRunning { p.terminate() } + launched.removeAll() + + let pkill = Process() + pkill.executableURL = URL(fileURLWithPath: "/usr/bin/env") + pkill.arguments = ["pkill", "-9", "-f", marker] + pkill.standardOutput = FileHandle.nullDevice + pkill.standardError = FileHandle.nullDevice + pkill.environment = Self.childEnvironment() + try? pkill.run() + + try? FileManager.default.removeItem(atPath: configPath) + try? FileManager.default.removeItem(atPath: logPath) + } +} + +// MARK: - Frame collection + +private actor ItemSink { + private(set) var items: [PublicCodecItem] = [] + private(set) var finished = false + private(set) var failure: Error? + func add(_ item: PublicCodecItem) { items.append(item) } + func finish(_ error: Error?) { + finished = true + failure = error + } +} + +/// Thread-safe collector for the `RTSPDiagnostic` messages a session emits via +/// its synchronous `onDiagnostic` callback. +private final class DiagnosticLog: @unchecked Sendable { + private let lock = NSLock() + private var messages: [String] = [] + func add(_ message: String) { + lock.lock() + messages.append(message) + lock.unlock() + } + var all: [String] { + lock.lock() + defer { lock.unlock() } + return messages + } +} + +/// Consume `session.frames()` until `until` is satisfied, the stream ends, or +/// `deadline` elapses — never an unbounded await. Returns everything collected. +@discardableResult +private func collectItems( + from session: RTSPClientSession, + deadline: Duration, + until: @escaping @Sendable ([PublicCodecItem]) -> Bool +) async -> [PublicCodecItem] { + let sink = ItemSink() + let consumer = Task { + do { + for try await item in session.frames() { + await sink.add(item) + if until(await sink.items) { break } + } + await sink.finish(nil) + } catch { + await sink.finish(error) + } + } + + let endBy = ContinuousClock.now + deadline + while ContinuousClock.now < endBy { + let snapshot = await sink.items + let isFinished = await sink.finished + if until(snapshot) || isFinished { break } + try? await Task.sleep(for: .milliseconds(120)) + } + consumer.cancel() + return await sink.items +} + +/// Create a fresh session and retry `start()` until it succeeds or `deadline` +/// elapses. A fresh session per attempt avoids carrying partial state across the +/// 404s MediaMTX returns until the publisher's stream is ready. +private func startSessionWithRetry( + deadline: Duration, + _ makeSession: () -> RTSPClientSession +) async throws -> (RTSPClientSession, SessionDescription) { + let endBy = ContinuousClock.now + deadline + var lastError: Error? + while ContinuousClock.now < endBy { + let session = makeSession() + do { + let desc = try await session.start() + return (session, desc) + } catch { + lastError = error + await session.stop() + try? await Task.sleep(for: .milliseconds(400)) + } + } + throw lastError ?? LiveError("RTSPClientSession.start() did not succeed in time") +} + +private func videoFrames(_ items: [PublicCodecItem]) -> [PublicVideoFrame] { + items.compactMap { if case .video(let f) = $0 { return f } else { return nil } } +} + +private func audioFrames(_ items: [PublicCodecItem]) -> [PublicAudioFrame] { + items.compactMap { if case .audio(let f) = $0 { return f } else { return nil } } +} + +/// A detached backstop that SIGKILLs any live test server/publisher after `cap`. +/// This bounds the *whole* test even if an await inside the library is itself +/// unbounded (e.g. an RTSP read with no timeout while the server stalls): +/// killing the server drops the TCP connection, which unblocks the pending +/// read. Cancel it (via `defer`) on normal completion. The pkill matches the +/// shared `ipcamkit-live` marker; the suite is serialized, so only the current +/// test's processes exist. +private func liveWatchdog(_ cap: Duration) -> Task { + Task.detached { + try? await Task.sleep(for: cap) + guard !Task.isCancelled else { return } + let pkill = Process() + pkill.executableURL = URL(fileURLWithPath: "/usr/bin/env") + pkill.arguments = ["pkill", "-9", "-f", "ipcamkit-live"] + pkill.standardOutput = FileHandle.nullDevice + pkill.standardError = FileHandle.nullDevice + try? pkill.run() + pkill.waitUntilExit() + } +} + +// MARK: - Tests + +@Suite("Live Integration Tests", .serialized) +struct LiveIntegrationTests { + + @Test("H.264 over interleaved TCP yields a keyframe with in-band SPS/PPS") + func h264OverTCP() async throws { + let watchdog = liveWatchdog(.seconds(45)) + defer { watchdog.cancel() } + let fixture = LiveStreamFixture( + transports: ["tcp"], + ffmpegArgs: [ + "-re", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=15", + "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", + "-g", "15", "-pix_fmt", "yuv420p", "-an", + ]) + defer { fixture.shutDown() } + try await fixture.start() + + // MediaMTX is TCP-only here, so it rejects a UDP SETUP — the stream only + // works if the client genuinely used RTP-interleaved TCP. + let (session, desc) = try await startSessionWithRetry(deadline: .seconds(20)) { + RTSPClientSession(url: fixture.rtspURL, transport: .tcp) + } + #expect(desc.video?.codec == .h264) + + // Teardown is via fixture.shutDown() (defer), which kills the server and + // drops the connection, ending the frames() producer. We deliberately do + // NOT call session.stop() here: a graceful TEARDOWN issued while the + // streamFrames loop is still reading the shared connection blocks until the + // server's session timeout (~60s), because the producer consumes the + // TEARDOWN response before stop()'s own read sees it. + let items = await collectItems(from: session, deadline: .seconds(12)) { + let vf = videoFrames($0) + return vf.count >= 3 && vf.contains { $0.isKeyframe } + } + + let vf = videoFrames(items) + #expect(!vf.isEmpty, "expected at least one video frame") + #expect(vf.contains { $0.isKeyframe }, "expected a keyframe (IDR)") + // SPS/PPS are surfaced either via the SDP (sprop-parameter-sets) or in-band + // on a frame, depending on how the server advertises them. + let spsAvailable = desc.video?.sps != nil || vf.contains { $0.sps != nil } + let ppsAvailable = desc.video?.pps != nil || vf.contains { $0.pps != nil } + #expect(spsAvailable, "expected SPS via SDP or in-band") + #expect(ppsAvailable, "expected PPS via SDP or in-band") + #expect(vf.allSatisfy { !$0.nalus.isEmpty }, "every frame should carry NAL units") + + // Graceful stop during active streaming must return promptly. This guards + // the control-channel fix: TEARDOWN is routed through the reader loop, so + // stop() no longer blocks until the server's session timeout (~60s). The + // 45s watchdog would fail this test if it regressed. + await session.stop() + } + + @Test("A second concurrent frames() consumer is rejected (finding #12)") + func framesRejectsSecondConsumer() async throws { + let watchdog = liveWatchdog(.seconds(45)) + defer { watchdog.cancel() } + let fixture = LiveStreamFixture( + transports: ["tcp"], + ffmpegArgs: [ + "-re", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=15", + "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", + "-g", "15", "-pix_fmt", "yuv420p", "-an", + ]) + defer { fixture.shutDown() } + try await fixture.start() + + let (session, _) = try await startSessionWithRetry(deadline: .seconds(20)) { + RTSPClientSession(url: fixture.rtspURL, transport: .tcp) + } + + // Consumer 1 owns the stream; it flips the single-consumer guard once its + // reader loop is running. + let sink = ItemSink() + let consumer1 = Task { + do { + for try await item in session.frames() { + await sink.add(item) + } + } catch { + await sink.finish(error) + } + } + defer { consumer1.cancel() } + + // Wait until consumer 1 is actually streaming: a delivered item proves the + // reader loop is past the guard, so isStreaming is set. + let endBy = ContinuousClock.now + .seconds(12) + while ContinuousClock.now < endBy { + if !(await sink.items).isEmpty { break } + try? await Task.sleep(for: .milliseconds(120)) + } + #expect(!(await sink.items).isEmpty, "consumer 1 should be receiving frames") + + // A second concurrent frames() must be rejected with .invalidState and + // deliver nothing. + var secondError: Error? + do { + for try await _ in session.frames() { + Issue.record("second frames() must not deliver any item") + break + } + } catch { + secondError = error + } + if let rtspError = secondError as? RTSPError, case .invalidState = rtspError { + // expected + } else { + Issue.record("expected RTSPError.invalidState, got \(String(describing: secondError))") + } + + await session.stop() + } + + @Test("H.265 over interleaved TCP yields a keyframe with in-band VPS/SPS/PPS") + func h265OverTCP() async throws { + let watchdog = liveWatchdog(.seconds(45)) + defer { watchdog.cancel() } + // `no-open-gop=1` forces closed-GOP IDR keyframes (libx265 defaults to + // open-GOP CRA, which — like retina — is not treated as a random-access + // point). `repeat-headers=1` re-sends VPS/SPS/PPS in-band before each IDR. + let fixture = LiveStreamFixture( + transports: ["tcp"], + ffmpegArgs: [ + "-re", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=15", + "-c:v", "libx265", "-tag:v", "hvc1", "-preset", "ultrafast", + "-x265-params", "keyint=15:min-keyint=15:no-open-gop=1:repeat-headers=1", + "-pix_fmt", "yuv420p", "-an", + ]) + defer { fixture.shutDown() } + try await fixture.start() + + let (session, desc) = try await startSessionWithRetry(deadline: .seconds(20)) { + RTSPClientSession(url: fixture.rtspURL, transport: .tcp) + } + #expect(desc.video?.codec == .h265) + + let items = await collectItems(from: session, deadline: .seconds(12)) { + let vf = videoFrames($0) + return vf.count >= 3 && vf.contains { $0.isKeyframe } + } + + let vf = videoFrames(items) + #expect(!vf.isEmpty, "expected at least one video frame") + #expect(vf.contains { $0.isKeyframe }, "expected a keyframe (IDR)") + // VPS/SPS/PPS arrive either via the SDP or in-band on a keyframe. + let vpsAvailable = desc.video?.vps != nil || vf.contains { $0.vps != nil } + let spsAvailable = desc.video?.sps != nil || vf.contains { $0.sps != nil } + let ppsAvailable = desc.video?.pps != nil || vf.contains { $0.pps != nil } + #expect(vpsAvailable, "expected VPS via SDP or in-band") + #expect(spsAvailable, "expected SPS via SDP or in-band") + #expect(ppsAvailable, "expected PPS via SDP or in-band") + } + + @Test("H.264 + AAC over interleaved TCP yields video and 48 kHz audio") + func h264AacOverTCP() async throws { + let watchdog = liveWatchdog(.seconds(45)) + defer { watchdog.cancel() } + let fixture = LiveStreamFixture( + transports: ["tcp"], + ffmpegArgs: [ + "-re", + "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=15", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", + "-map", "0:v", "-map", "1:a", + "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", + "-g", "15", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-ar", "48000", + ]) + defer { fixture.shutDown() } + try await fixture.start() + + let (session, desc) = try await startSessionWithRetry(deadline: .seconds(20)) { + RTSPClientSession(url: fixture.rtspURL, transport: .tcp) + } + #expect(desc.video?.codec == .h264) + #expect(desc.audio != nil, "expected an audio stream in the SDP") + #expect(desc.audio?.sampleRate == 48000) + + let items = await collectItems(from: session, deadline: .seconds(12)) { + !videoFrames($0).isEmpty && audioFrames($0).count >= 2 + } + + #expect(!videoFrames(items).isEmpty, "expected video frames") + let af = audioFrames(items) + #expect(!af.isEmpty, "expected audio frames") + #expect(af.allSatisfy { $0.sampleRate == 48000 }, "audio sample rate should be 48 kHz") + } + + @Test("Keepalive sustains a session past the server read timeout") + func keepaliveSustainsSession() async throws { + let watchdog = liveWatchdog(.seconds(45)) + defer { watchdog.cancel() } + + // MediaMTX drops a reader that sends nothing within `readTimeout`. With a 3s + // read timeout and a 1s keepalive, the session must survive well beyond 3s — + // which only happens if keepalive requests are interleaved with the RTP and + // acknowledged on the same TCP connection. + let fixture = LiveStreamFixture( + transports: ["tcp"], + readTimeoutSeconds: 3, + ffmpegArgs: [ + "-re", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=15", + "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", + "-g", "15", "-pix_fmt", "yuv420p", "-an", + ]) + defer { fixture.shutDown() } + try await fixture.start() + + let log = DiagnosticLog() + let (session, _) = try await startSessionWithRetry(deadline: .seconds(20)) { + RTSPClientSession( + url: fixture.rtspURL, credentials: nil, transport: .tcp, + userAgent: "IPCamKit", onDiagnostic: { log.add($0.message) }, + keepaliveInterval: .seconds(1)) + } + + // Run until a frame arrives from well past the 3s read-timeout window. + let items = await collectItems(from: session, deadline: .seconds(20)) { + videoFrames($0).contains { $0.timestamp > 6.0 } + } + await session.stop() + + let vf = videoFrames(items) + #expect( + vf.contains { $0.timestamp > 6.0 }, + "stream should survive well past the 3s read timeout via keepalive") + let acked = log.all.filter { $0.contains("keepalive") && $0.contains("acknowledged") } + #expect(!acked.isEmpty, "expected an acknowledged keepalive; diagnostics: \(log.all)") + } + + // MARK: - UDP transport + + @Test("H.264 over UDP yields a keyframe with SPS/PPS") + func h264OverUDP() async throws { + let watchdog = liveWatchdog(.seconds(45)) + defer { watchdog.cancel() } + // MediaMTX is UDP-only here (even/odd rtp/rtcp address pair), so it rejects + // a TCP-interleaved SETUP. The stream only works if the client genuinely + // negotiated client_port and pulled RTP/RTCP over its own UDP socket pair. + let fixture = LiveStreamFixture( + transports: ["udp"], + ffmpegArgs: [ + "-re", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=15", + "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", + "-g", "15", "-pix_fmt", "yuv420p", "-an", + ]) + defer { fixture.shutDown() } + try await fixture.start() + + let (session, desc) = try await startSessionWithRetry(deadline: .seconds(20)) { + RTSPClientSession(url: fixture.rtspURL, transport: .udp) + } + #expect(desc.video?.codec == .h264) + + let items = await collectItems(from: session, deadline: .seconds(12)) { + let vf = videoFrames($0) + return vf.count >= 3 && vf.contains { $0.isKeyframe } + } + + let vf = videoFrames(items) + #expect(!vf.isEmpty, "expected at least one video frame over UDP") + #expect(vf.contains { $0.isKeyframe }, "expected a keyframe (IDR) over UDP") + let spsAvailable = desc.video?.sps != nil || vf.contains { $0.sps != nil } + let ppsAvailable = desc.video?.pps != nil || vf.contains { $0.pps != nil } + #expect(spsAvailable, "expected SPS via SDP or in-band") + #expect(ppsAvailable, "expected PPS via SDP or in-band") + #expect(vf.allSatisfy { !$0.nalus.isEmpty }, "every frame should carry NAL units") + + // Graceful stop during active UDP streaming must return promptly: TEARDOWN + // is routed over the TCP control connection while RTP flows on UDP, and the + // 45s watchdog would fail this test if stop() hung. + await session.stop() + } + + @Test("H.265 over UDP yields a keyframe with VPS/SPS/PPS") + func h265OverUDP() async throws { + let watchdog = liveWatchdog(.seconds(45)) + defer { watchdog.cancel() } + let fixture = LiveStreamFixture( + transports: ["udp"], + ffmpegArgs: [ + "-re", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=15", + "-c:v", "libx265", "-tag:v", "hvc1", "-preset", "ultrafast", + "-x265-params", "keyint=15:min-keyint=15:no-open-gop=1:repeat-headers=1", + "-pix_fmt", "yuv420p", "-an", + ]) + defer { fixture.shutDown() } + try await fixture.start() + + let (session, desc) = try await startSessionWithRetry(deadline: .seconds(20)) { + RTSPClientSession(url: fixture.rtspURL, transport: .udp) + } + #expect(desc.video?.codec == .h265) + + let items = await collectItems(from: session, deadline: .seconds(12)) { + let vf = videoFrames($0) + return vf.count >= 3 && vf.contains { $0.isKeyframe } + } + + let vf = videoFrames(items) + #expect(!vf.isEmpty, "expected at least one video frame over UDP") + #expect(vf.contains { $0.isKeyframe }, "expected a keyframe (IDR) over UDP") + let vpsAvailable = desc.video?.vps != nil || vf.contains { $0.vps != nil } + let spsAvailable = desc.video?.sps != nil || vf.contains { $0.sps != nil } + let ppsAvailable = desc.video?.pps != nil || vf.contains { $0.pps != nil } + #expect(vpsAvailable, "expected VPS via SDP or in-band") + #expect(spsAvailable, "expected SPS via SDP or in-band") + #expect(ppsAvailable, "expected PPS via SDP or in-band") + } + + @Test("H.264 + AAC over UDP yields video and 48 kHz audio") + func h264AacOverUDP() async throws { + let watchdog = liveWatchdog(.seconds(45)) + defer { watchdog.cancel() } + let fixture = LiveStreamFixture( + transports: ["udp"], + ffmpegArgs: [ + "-re", + "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=15", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", + "-map", "0:v", "-map", "1:a", + "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", + "-g", "15", "-pix_fmt", "yuv420p", + "-c:a", "aac", "-ar", "48000", + ]) + defer { fixture.shutDown() } + try await fixture.start() + + let (session, desc) = try await startSessionWithRetry(deadline: .seconds(20)) { + RTSPClientSession(url: fixture.rtspURL, transport: .udp) + } + #expect(desc.video?.codec == .h264) + #expect(desc.audio != nil, "expected an audio stream in the SDP") + #expect(desc.audio?.sampleRate == 48000) + + // Video on one UDP socket pair, audio on another — both must demultiplex. + let items = await collectItems(from: session, deadline: .seconds(12)) { + !videoFrames($0).isEmpty && audioFrames($0).count >= 2 + } + + #expect(!videoFrames(items).isEmpty, "expected video frames over UDP") + let af = audioFrames(items) + #expect(!af.isEmpty, "expected audio frames over UDP") + #expect(af.allSatisfy { $0.sampleRate == 48000 }, "audio sample rate should be 48 kHz") + + await session.stop() + } + + // MARK: - IPv6 transport + + @Test("H.264 over TCP on IPv6 (::1) yields a keyframe with SPS/PPS") + func h264OverTCPIPv6() async throws { + let watchdog = liveWatchdog(.seconds(45)) + defer { watchdog.cancel() } + // MediaMTX bound on [::], pulled over rtsp://[::1]:PORT — proves the + // RTP-interleaved TCP path works over IPv6 end to end. + let fixture = LiveStreamFixture( + transports: ["tcp"], + family: .ipv6, + ffmpegArgs: [ + "-re", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=15", + "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", + "-g", "15", "-pix_fmt", "yuv420p", "-an", + ]) + defer { fixture.shutDown() } + try await fixture.start() + + let (session, desc) = try await startSessionWithRetry(deadline: .seconds(20)) { + RTSPClientSession(url: fixture.rtspURL, transport: .tcp) + } + #expect(desc.video?.codec == .h264) + + let items = await collectItems(from: session, deadline: .seconds(12)) { + let vf = videoFrames($0) + return vf.count >= 3 && vf.contains { $0.isKeyframe } + } + + let vf = videoFrames(items) + #expect(!vf.isEmpty, "expected at least one video frame over IPv6 TCP") + #expect(vf.contains { $0.isKeyframe }, "expected a keyframe (IDR) over IPv6 TCP") + let spsAvailable = desc.video?.sps != nil || vf.contains { $0.sps != nil } + let ppsAvailable = desc.video?.pps != nil || vf.contains { $0.pps != nil } + #expect(spsAvailable, "expected SPS via SDP or in-band") + #expect(ppsAvailable, "expected PPS via SDP or in-band") + + await session.stop() + } + + @Test("H.264 over UDP on IPv6 (::1) yields a keyframe with SPS/PPS") + func h264OverUDPIPv6() async throws { + let watchdog = liveWatchdog(.seconds(45)) + defer { watchdog.cancel() } + // MediaMTX bound on [::] with UDP-only RTP transport, pulled from + // rtsp://[::1]:PORT — proves the UDP RTP/RTCP socket pair works over IPv6. + let fixture = LiveStreamFixture( + transports: ["udp"], + family: .ipv6, + ffmpegArgs: [ + "-re", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=15", + "-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency", + "-g", "15", "-pix_fmt", "yuv420p", "-an", + ]) + defer { fixture.shutDown() } + try await fixture.start() + + let (session, desc) = try await startSessionWithRetry(deadline: .seconds(20)) { + RTSPClientSession(url: fixture.rtspURL, transport: .udp) + } + #expect(desc.video?.codec == .h264) + + let items = await collectItems(from: session, deadline: .seconds(12)) { + let vf = videoFrames($0) + return vf.count >= 3 && vf.contains { $0.isKeyframe } + } + + let vf = videoFrames(items) + #expect(!vf.isEmpty, "expected at least one video frame over IPv6 UDP") + #expect(vf.contains { $0.isKeyframe }, "expected a keyframe (IDR) over IPv6 UDP") + let spsAvailable = desc.video?.sps != nil || vf.contains { $0.sps != nil } + let ppsAvailable = desc.video?.pps != nil || vf.contains { $0.pps != nil } + #expect(spsAvailable, "expected SPS via SDP or in-band") + #expect(ppsAvailable, "expected PPS via SDP or in-band") + + await session.stop() + } +} diff --git a/Tests/IPCamKitTests/RTPTests.swift b/Tests/IPCamKitTests/RTPTests.swift index bd49809..756e44c 100644 --- a/Tests/IPCamKitTests/RTPTests.swift +++ b/Tests/IPCamKitTests/RTPTests.swift @@ -200,6 +200,18 @@ struct TimelineTests { #expect(Int32(truncatingIfNeeded: a) == -1) #expect(Int32(truncatingIfNeeded: b) == 0) } + + /// An unsynced camera commonly sends an NTP timestamp before (or at) the Unix + /// epoch; it must clamp to 1970 rather than wrapping to a far-future date + /// (finding #51). + @Test("NtpTimestamp before the Unix epoch clamps to 1970") + func ntpBeforeEpochClamps() { + let epochRaw: UInt64 = 2_208_988_800 << 32 + #expect(NtpTimestamp(rawValue: 0).date == Date(timeIntervalSince1970: 0)) + #expect(NtpTimestamp(rawValue: epochRaw).date == Date(timeIntervalSince1970: 0)) + // A normal post-1970 value (1 second after epoch) is unchanged. + #expect(NtpTimestamp(rawValue: epochRaw + (1 << 32)).date == Date(timeIntervalSince1970: 1)) + } } // MARK: - Channel Mapping Tests @@ -246,6 +258,15 @@ struct ChannelMappingTests { // Slot 1 (channel 2) still free #expect(mappings.nextUnassigned() == 2) } + + /// streamIndex is an opaque SDP media index, not a channel slot, so a large + /// value must be accepted (finding #35 removed a bogus `< 255` guard). + @Test("Channel mapping accepts a large stream index") + func channelMappingLargeStreamIndex() throws { + var mappings = ChannelMappings() + try mappings.assign(channelId: 0, streamIndex: 300) + #expect(mappings.lookup(0) == ChannelMapping(streamIndex: 300, channelType: .rtp)) + } } // MARK: - InorderParser Tests @@ -257,7 +278,7 @@ struct InorderParserTests { /// Tests that payload_type=50 packets are silently dropped. @Test("Geovision PT=50 packets are skipped") func geovisionPT50() throws { - var timeline = try Timeline(start: nil, clockRate: 90_000, enforceMaxJumpSecs: nil) + let timeline = try Timeline(start: nil, clockRate: 90_000, enforceMaxJumpSecs: nil) var parser = InorderParser( ssrc: 0x0D25_614E, nextSeq: nil, isTcp: true, timeline: timeline) @@ -297,7 +318,7 @@ struct InorderParserTests { localIP: "0.0.0.0", peerIP: "0.0.0.0", localRtpPort: 0, peerRtpPort: 0)) - var timeline = try Timeline(start: nil, clockRate: 90_000, enforceMaxJumpSecs: nil) + let timeline = try Timeline(start: nil, clockRate: 90_000, enforceMaxJumpSecs: nil) var parser = InorderParser( ssrc: 0x0D25_614E, nextSeq: nil, isTcp: false, timeline: timeline) @@ -338,7 +359,7 @@ struct InorderParserTests { func outOfOrderTcp() throws { let captured = DiagnosticBox() - var timeline = try Timeline(start: nil, clockRate: 90_000, enforceMaxJumpSecs: nil) + let timeline = try Timeline(start: nil, clockRate: 90_000, enforceMaxJumpSecs: nil) var parser = InorderParser( ssrc: 0x0D25_614E, nextSeq: nil, isTcp: true, timeline: timeline, onDiagnostic: { captured.append($0) }) @@ -366,6 +387,122 @@ struct InorderParserTests { #expect(captured.events.first?.message.contains("seq=1") == true) #expect(captured.events.first?.message.contains("expected=3") == true) } + + /// A foreign-SSRC RTCP Sender Report under the default dropPackets policy must + /// be dropped BEFORE it anchors the timeline, and warn exactly once per stream. + @Test("Foreign-SSRC RTCP SR is dropped without corrupting the timeline (#9/#33)") + func foreignRtcpSRDoesNotAnchorTimeline() throws { + let captured = DiagnosticBox() + let timeline = try Timeline(start: nil, clockRate: 90_000, enforceMaxJumpSecs: nil) + var parser = InorderParser( + ssrc: 0x0D25_614E, nextSeq: nil, isTcp: true, timeline: timeline, + onDiagnostic: { captured.append($0) }) + + // Foreign SR arrives before any RTP packet: dropped, one warning, timeline + // untouched. + let sr1 = makeSenderReport(ssrc: 0xDEAD_BEEF, rtpTimestamp: 1_000_000) + #expect(try parser.rtcp(ctx: .dummy, streamId: 0, data: sr1) == nil) + #expect(captured.events.count == 1) + #expect(captured.events.first?.severity == .warning) + + // A second foreign SR is still dropped, but no second diagnostic (latched). + let sr2 = makeSenderReport(ssrc: 0xDEAD_BEEF, rtpTimestamp: 2_000_000) + #expect(try parser.rtcp(ctx: .dummy, streamId: 0, data: sr2) == nil) + #expect(captured.events.count == 1) + + // The next RTP packet anchors the timeline to ITS own timestamp (elapsed 0), + // proving the dropped foreign SR did not anchor the origin at 1_000_000. + let rtp = try RTPPacketBuilder( + sequenceNumber: 100, timestamp: 5_000_000, payloadType: 96, + ssrc: 0x0D25_614E, mark: true + ).build(payload: Data("x".utf8)).get().data + let recv = try parser.rtp(data: rtp, ctx: .dummy, streamId: 0, streamCtx: .dummy) + #expect(recv?.timestamp.elapsed == 0) + } + + /// A malformed RTP datagram is dropped (with a single warning), never fatal — + /// one bad packet must not tear down the stream (finding #1). + @Test("Malformed RTP packet is dropped, not fatal") + func malformedRtpDropped() throws { + let captured = DiagnosticBox() + let timeline = try Timeline(start: nil, clockRate: 90_000, enforceMaxJumpSecs: nil) + var parser = InorderParser( + ssrc: nil, nextSeq: nil, isTcp: true, timeline: timeline, + onDiagnostic: { captured.append($0) }) + + // Fewer than 12 bytes is not a valid RTP packet -> RawRTPPacket.parse fails. + #expect( + try parser.rtp(data: Data([0x80, 0x60]), ctx: .dummy, streamId: 0, streamCtx: .dummy) == nil) + #expect(captured.events.count == 1) + #expect(captured.events.first?.severity == .warning) + + // A second malformed packet drops silently (latched). + #expect(try parser.rtp(data: Data([0x80]), ctx: .dummy, streamId: 0, streamCtx: .dummy) == nil) + #expect(captured.events.count == 1) + + // A subsequent valid packet still processes. + let good = try RTPPacketBuilder( + sequenceNumber: 5, timestamp: 100, payloadType: 96, ssrc: 0x1111_2222, mark: true + ).build(payload: Data("ok".utf8)).get().data + #expect(try parser.rtp(data: good, ctx: .dummy, streamId: 0, streamCtx: .dummy) != nil) + } + + /// An RTP SSRC mismatch drops by default, throws only under .abortSession, and + /// is accepted under .processPackets (finding #1). + @Test("RTP SSRC mismatch is policy-gated") + func rtpSsrcMismatchPolicies() throws { + func makeParser(_ policy: UnknownRtcpSsrcPolicy, _ box: DiagnosticBox) throws -> InorderParser { + let tl = try Timeline(start: nil, clockRate: 90_000, enforceMaxJumpSecs: nil) + return InorderParser( + ssrc: 0xAAAA_AAAA, nextSeq: nil, isTcp: false, timeline: tl, + unknownRtcpSsrcPolicy: policy, onDiagnostic: { box.append($0) }) + } + func packet(ssrc: UInt32) -> Data { + try! RTPPacketBuilder( + sequenceNumber: 1, timestamp: 1, payloadType: 96, ssrc: ssrc, mark: true + ).build(payload: Data("x".utf8)).get().data + } + + // dropPackets: mismatch dropped + warned once; matching SSRC still processes. + let dropBox = DiagnosticBox() + var dropParser = try makeParser(.dropPackets, dropBox) + #expect( + try dropParser.rtp( + data: packet(ssrc: 0xDEAD_BEEF), ctx: .dummy, streamId: 0, streamCtx: .dummy) == nil) + #expect(dropBox.events.count == 1) + #expect( + try dropParser.rtp( + data: packet(ssrc: 0xAAAA_AAAA), ctx: .dummy, streamId: 0, streamCtx: .dummy) != nil) + + // abortSession: mismatch throws. + var abortParser = try makeParser(.abortSession, DiagnosticBox()) + #expect(throws: RTSPError.self) { + _ = try abortParser.rtp( + data: packet(ssrc: 0xDEAD_BEEF), ctx: .dummy, streamId: 0, streamCtx: .dummy) + } + + // processPackets: mismatch accepted. + var processParser = try makeParser(.processPackets, DiagnosticBox()) + #expect( + try processParser.rtp( + data: packet(ssrc: 0xDEAD_BEEF), ctx: .dummy, streamId: 0, streamCtx: .dummy) != nil) + } +} + +/// Build a minimal RTCP Sender Report (PT=200, no report blocks) for tests. +private func makeSenderReport(ssrc: UInt32, rtpTimestamp: UInt32, ntp: UInt64 = 0) -> Data { + func be32(_ v: UInt32) -> [UInt8] { + [UInt8(v >> 24), UInt8((v >> 16) & 0xFF), UInt8((v >> 8) & 0xFF), UInt8(v & 0xFF)] + } + var d = Data([0x80, 0xC8, 0x00, 0x06]) // V=2, PT=200, length=6 words => 28 bytes + d.append(contentsOf: be32(ssrc)) + for shift in stride(from: 56, through: 0, by: -8) { + d.append(UInt8((ntp >> shift) & 0xFF)) // 8-byte NTP timestamp + } + d.append(contentsOf: be32(rtpTimestamp)) + d.append(contentsOf: [0, 0, 0, 0]) // packet count + d.append(contentsOf: [0, 0, 0, 0]) // octet count + return d } /// Thread-safe collector for diagnostic events captured during a test. diff --git a/Tests/IPCamKitTests/RTSPAuthTests.swift b/Tests/IPCamKitTests/RTSPAuthTests.swift index 55c8b90..d2de36a 100644 --- a/Tests/IPCamKitTests/RTSPAuthTests.swift +++ b/Tests/IPCamKitTests/RTSPAuthTests.swift @@ -12,7 +12,7 @@ struct RTSPAuthTests { @Test("Basic auth header generation") func basicAuth() { - let auth = RTSPAuthenticator(credentials: Credentials(username: "admin", password: "pass")) + var auth = RTSPAuthenticator(credentials: Credentials(username: "admin", password: "pass")) let header = auth.authorize(method: "DESCRIBE", uri: "rtsp://host/path") #expect(header != nil) #expect(header!.hasPrefix("Basic ")) @@ -72,4 +72,104 @@ struct RTSPAuthTests { #expect(header!.contains("cnonce=")) #expect(header!.contains("opaque=\"xyz\"")) } + + @Test("Digest nonce-count increments per authorized request") + func digestNonceCountIncrements() { + var auth = RTSPAuthenticator( + credentials: Credentials(username: "user", password: "pass")) + auth.handleChallenge("Digest realm=\"test\", nonce=\"abc123\", qop=\"auth\"") + + let first = auth.authorize(method: "DESCRIBE", uri: "rtsp://host/path") + let second = auth.authorize(method: "SETUP", uri: "rtsp://host/path") + #expect(first!.contains("nc=00000001")) + #expect(second!.contains("nc=00000002")) + } + + @Test("Digest qop=auth-int alone is not treated as qop=auth") + func digestQopAuthIntOnly() { + var auth = RTSPAuthenticator( + credentials: Credentials(username: "user", password: "pass")) + auth.handleChallenge("Digest realm=\"test\", nonce=\"abc123\", qop=\"auth-int\"") + + let header = auth.authorize(method: "DESCRIBE", uri: "rtsp://host/path") + #expect(header != nil) + // We don't support auth-int, so we must fall back to the unqualified form + // rather than claiming qop=auth. + #expect(!header!.contains("qop=")) + #expect(!header!.contains("nc=")) + } + + @Test("Digest SHA-256 algorithm is honored") + func digestSHA256() { + var auth = RTSPAuthenticator( + credentials: Credentials(username: "user", password: "pass")) + auth.handleChallenge( + "Digest realm=\"test\", nonce=\"abc123\", algorithm=SHA-256") + + let header = auth.authorize(method: "DESCRIBE", uri: "rtsp://host/path") + #expect(header != nil) + #expect(header!.contains("algorithm=SHA-256")) + // SHA-256 response is 64 hex chars; MD5 would be 32. + let hex = "0123456789abcdef" + if let range = header!.range(of: "response=\"") { + let rest = header![range.upperBound...] + let digest = rest.prefix { hex.contains($0) } + #expect(digest.count == 64) + } else { + Issue.record("no response field") + } + } + + @Test("Digest challenge with empty nonce is rejected") + func digestEmptyNonceRejected() { + var auth = RTSPAuthenticator( + credentials: Credentials(username: "user", password: "pass")) + auth.handleChallenge("Digest realm=\"test\", nonce=\"\"") + #expect(!auth.hasChallenge) + } + + @Test("Basic challenge enables pre-emptive auth (finding #10)") + func basicChallengeEnablesPreemptiveAuth() { + var auth = RTSPAuthenticator(credentials: Credentials(username: "u", password: "p")) + #expect(!auth.hasChallenge) + auth.handleChallenge("Basic realm=\"cam\"") + #expect(auth.hasChallenge) + let header = auth.authorize(method: "DESCRIBE", uri: "rtsp://h/p") + #expect(header?.hasPrefix("Basic ") == true) + } + + @Test("Digest quoted-string fields are escaped (finding #38)") + func digestEscapesQuotedFields() { + var auth = RTSPAuthenticator( + credentials: Credentials(username: "ad\"min", password: "p")) + auth.handleChallenge("Digest realm=\"test\", nonce=\"abc123\"") + let header = auth.authorize(method: "DESCRIBE", uri: "rtsp://h/p") + #expect(header != nil) + // The quote in the username must be backslash-escaped, not left raw (which + // would truncate the field and corrupt the whole header). + #expect(header!.contains("username=\"ad\\\"min\"")) + } + + @Test("Control characters in a challenge value are rejected (no header injection)") + func digestRejectsControlCharsInChallenge() { + var auth = RTSPAuthenticator( + credentials: Credentials(username: "user", password: "pass")) + // A server-injected bare CR in the nonce must not reach the header; dropping + // the nonce leaves the challenge unusable. + auth.handleChallenge("Digest realm=\"r\", nonce=\"ab\rcd\"") + #expect(!auth.hasChallenge) + } + + @Test("Digest challenge value with an escaped quote is decoded (finding #40)") + func digestUnquotesEscapedQuote() { + var auth = RTSPAuthenticator( + credentials: Credentials(username: "user", password: "pass")) + // nonce carries an escaped quote on the wire: ab"cd + auth.handleChallenge("Digest realm=\"r\", nonce=\"ab\\\"cd\"") + #expect(auth.hasChallenge) + let header = auth.authorize(method: "DESCRIBE", uri: "rtsp://h/p") + #expect(header != nil) + // Re-emitted escaped: nonce="ab\"cd". (Hash input uses the raw decoded value.) + #expect(header!.contains("nonce=\"ab\\\"cd\"")) + } } diff --git a/Tests/IPCamKitTests/RTSPParserTests.swift b/Tests/IPCamKitTests/RTSPParserTests.swift index 11511b0..f615068 100644 --- a/Tests/IPCamKitTests/RTSPParserTests.swift +++ b/Tests/IPCamKitTests/RTSPParserTests.swift @@ -119,6 +119,83 @@ struct RTSPParserTests { #expect(resp.statusCode == 200) } + @Test("Tolerate space before the header colon (CSeq : 7)") + func spacedHeaderName() throws { + var data = Data( + "RTSP/1.0 200 OK\r\nCSeq : 7\r\nSession : 12345;timeout=60\r\n\r\n".utf8) + let result = try parser.parse(&data) + guard case .response(let resp) = result?.0 else { + Issue.record("Expected response") + return + } + // Without trimming the field name, "CSeq " never matches "cseq" and + // response routing breaks. See finding #3. + #expect(resp.cseq == 7) + #expect(resp.header("Session") == "12345;timeout=60") + } + + @Test("Parse response with bare-LF line endings") + func bareLFResponse() throws { + var data = Data("RTSP/1.0 200 OK\nCSeq: 3\nContent-Length: 4\n\nbody".utf8) + let result = try parser.parse(&data) + guard case .response(let resp) = result?.0 else { + Issue.record("Expected response") + return + } + #expect(resp.statusCode == 200) + #expect(resp.cseq == 3) + #expect(resp.body == Data("body".utf8)) + #expect(data.isEmpty) + } + + @Test("Mixed CRLF headers with a bare-LF terminator parse cleanly") + func mixedCRLFBareLFTerminator() throws { + // CRLF header lines, but the blank line is a bare LF ("...10\r\n\n"). The + // boundary must not leave a trailing CR on the last header value, which + // would break numeric parses (Content-Length here) and tear down the stream. + var data = Data( + "RTSP/1.0 200 OK\r\nCSeq: 4\r\nContent-Length: 10\r\n\nABCDEFGHIJ".utf8) + let result = try parser.parse(&data) + guard case .response(let resp) = result?.0 else { + Issue.record("Expected response") + return + } + #expect(resp.cseq == 4) + #expect(resp.contentLength == 10) + #expect(resp.body == Data("ABCDEFGHIJ".utf8)) + #expect(data.isEmpty) + } + + @Test("Tolerate tab-delimited status line and trailing junk on the code") + func lenientStatusLine() throws { + var data1 = Data("RTSP/1.0\t200\tOK\r\nCSeq: 1\r\n\r\n".utf8) + let result1 = try parser.parse(&data1) + guard case .response(let r1) = result1?.0 else { + Issue.record("Expected response") + return + } + #expect(r1.statusCode == 200) + #expect(r1.reasonPhrase == "OK") + + var data2 = Data("RTSP/1.0 404Whatever Not Found\r\nCSeq: 1\r\n\r\n".utf8) + let result2 = try parser.parse(&data2) + guard case .response(let r2) = result2?.0 else { + Issue.record("Expected response") + return + } + #expect(r2.statusCode == 404) + #expect(r2.reasonPhrase == "Not Found") + } + + @Test("Reject an absurd Content-Length instead of buffering it") + func rejectHugeContentLength() { + var data = Data( + "RTSP/1.0 200 OK\r\nCSeq: 1\r\nContent-Length: 999999999\r\n\r\n".utf8) + #expect(throws: RTSPError.self) { + _ = try parser.parse(&data) + } + } + // MARK: - Interleaved Data Parsing @Test("Parse interleaved data frame") @@ -163,6 +240,21 @@ struct RTSPParserTests { #expect(result == nil) } + @Test("Max-size interleaved frame parses") + func maxInterleavedFrame() throws { + let payload = Data(repeating: 0xAB, count: 0xFFFF) + var data = Data([0x24, 0x05, 0xFF, 0xFF]) // '$' + channel 5 + length 0xFFFF + data.append(payload) + let result = try parser.parse(&data) + guard case .data(let interleaved) = result?.0 else { + Issue.record("Expected interleaved data") + return + } + #expect(interleaved.channelId == 5) + #expect(interleaved.data.count == 0xFFFF) + #expect(data.isEmpty) + } + // MARK: - Serialization @Test("Serialize RTSP request") diff --git a/Tests/IPCamKitTests/SimpleAudioTests.swift b/Tests/IPCamKitTests/SimpleAudioTests.swift index 28bf209..31fe662 100644 --- a/Tests/IPCamKitTests/SimpleAudioTests.swift +++ b/Tests/IPCamKitTests/SimpleAudioTests.swift @@ -37,4 +37,13 @@ struct SimpleAudioTests { let d = SimpleAudioDepacketizer(clockRate: 16000, bitsPerSample: 16) #expect(d.frameLength(payloadLen: 321) == nil) } + + /// An oversized payload length must return nil rather than trap the process + /// (was a precondition on attacker-controlled length — finding #19). + @Test("Oversized payload length returns nil instead of trapping") + func frameLengthOversized() { + let d = SimpleAudioDepacketizer(clockRate: 8000, bitsPerSample: 8) + #expect(d.frameLength(payloadLen: Int(UInt16.max)) == nil) + #expect(d.frameLength(payloadLen: 70_000) == nil) + } } diff --git a/Tests/IPCamKitTests/TestData/anjvision_describe.txt b/Tests/IPCamKitTests/TestData/anjvision_describe.txt new file mode 100644 index 0000000..890d1c3 --- /dev/null +++ b/Tests/IPCamKitTests/TestData/anjvision_describe.txt @@ -0,0 +1,23 @@ +RTSP/1.0 200 OK +CSeq: 1 +Content-Type: application/sdp +Content-Length: 888 +Content-Base: 192.168.1.10:554/stream0/ + +v=0 +o=- 1109162014219182 1109162014219192 IN IP4 x.y.z.w +s=RTSP/RTP stream from anjvision ipcamera +e=NONE +c=IN IP4 0.0.0.0 +a=tool:LIVE555 Streaming Media v2011.05.25 CHAM.LI@ANJVISION.COM +t=0 0 +a=range:npt=0- +a=control:* +m=video 0 RTP/AVP 96 +a=rtpmap:96 H265/90000 +a=control:trackID=1 +a=fmtp:96 packetization-mode=0;profile-space=0;profile-id=1;tier-flag=0;level-id=153;interop-constraints=000000000000;sprop-vps=QAEMAf//AUAAAAMAAAMAAAMAAAMAmawJ;sprop-sps=QgEBAUAAAAMAAAMAAAMAAAMAmaACgIAsH+Wu5Gwa5VE1BQYFAIA=;sprop-pps=RAHAc8BBgwk=;config=0000000140010c01ffff01400000030000030000030000030099ac090000000142010101400000030000030000030000030099a00280802c1fe5aee46c1ae551350506050080000000014401c073c0418309 +a=x-dimensions: 1280, 720 +a=x-framerate: 5 +a=Media_header:MEDIAINFO=494D4B48010100000400050000000000000000000000000000000000000000000000000000000000; +a=appversion:1.0 diff --git a/Tests/IPCamKitTests/URLHostTests.swift b/Tests/IPCamKitTests/URLHostTests.swift new file mode 100644 index 0000000..66a0b2f --- /dev/null +++ b/Tests/IPCamKitTests/URLHostTests.swift @@ -0,0 +1,36 @@ +// Copyright (c) 2025 Steel Brain +// SPDX-License-Identifier: MIT +// Unit tests for URL host normalization (IPv6 bracket stripping). + +import Testing + +@testable import IPCamKit + +@Suite("URL host parsing") +struct URLHostTests { + + @Test("Strips brackets from an IPv6 literal host") + func stripsIPv6Brackets() { + #expect(unbracketedHost("[::1]") == "::1") + #expect(unbracketedHost("[2001:db8::1]") == "2001:db8::1") + } + + @Test("Preserves the zone id inside a link-local IPv6 host") + func preservesZoneId() { + #expect(unbracketedHost("[fe80::1%en0]") == "fe80::1%en0") + } + + @Test("Leaves IPv4 and hostname hosts unchanged") + func leavesNonBracketedUnchanged() { + #expect(unbracketedHost("127.0.0.1") == "127.0.0.1") + #expect(unbracketedHost("camera.local") == "camera.local") + // A bare IPv6 literal (no brackets) is already in NWEndpoint.Host form. + #expect(unbracketedHost("::1") == "::1") + } + + @Test("Does not mangle a host with only one bracket") + func toleratesUnbalancedBrackets() { + #expect(unbracketedHost("[::1") == "[::1") + #expect(unbracketedHost("::1]") == "::1]") + } +}