diff --git a/CHANGELOG.md b/CHANGELOG.md index 84f69ff13..5d9d2b482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ the public-API contract. ## [Unreleased] +### Fixed + +- External subtitles inherit media request headers only at the same HTTP scheme, host and effective port. Explicit subtitle headers remain authoritative, including an empty set. Primary/secondary overlays, native subtitle stores and remote-HLS sidecar renditions use the same initial-request rule; existing redirect and certificate policies are unchanged. + ### Changed - **The documentation no longer says TrueHD Atmos objects are always lost.** The Known limitations diff --git a/Sources/AetherEngine/AetherEngine+Subtitles.swift b/Sources/AetherEngine/AetherEngine+Subtitles.swift index 02ab63cbf..37fda4cd0 100644 --- a/Sources/AetherEngine/AetherEngine+Subtitles.swift +++ b/Sources/AetherEngine/AetherEngine+Subtitles.swift @@ -1366,6 +1366,21 @@ extension AetherEngine { // MARK: - External subtitle tracks (#88) + /// Implicit media headers belong only to the media's exact HTTP origin. Explicit + /// sidecar headers (including an empty dictionary) are the host's own decision. + nonisolated static func resolvedSubtitleHeaders( + for url: URL, explicit: [String: String]?, mediaURL: URL?, + mediaHeaders: [String: String] + ) -> [String: String] { + if let explicit { return explicit } + guard let mediaURL, let scheme = mediaURL.scheme?.lowercased(), + scheme == "http" || scheme == "https", + url.scheme?.lowercased() == scheme, + RedirectHeaderPolicy.credentialsAllowed(from: mediaURL, to: url) + else { return [:] } + return mediaHeaders + } + /// Register an external subtitle file as a first-class track (AetherEngine#88): it appears in /// `subtitleTracks` with a synthetic id and `isExternal == true` and is selectable via /// `selectSubtitleTrack(index:)`. Overlay-only (no native WebVTT rendition / PiP); declare via @@ -1459,7 +1474,7 @@ extension AetherEngine { table: nativeSubtitleTrackTable, registry: externalSubtitleRegistry, stores: session.nativeSubtitleCueStoresForSession, - defaultHeaders: loadedOptions.httpHeaders) + defaultHeaders: loadedOptions.httpHeaders, mediaURL: loadedURL) guard !jobs.isEmpty else { return } externalNativeStoreFillTask = BlockingWork.detached(priority: .utility) { [jobs] in for job in jobs { @@ -1512,7 +1527,7 @@ extension AetherEngine { if activeSecondaryExternalSubtitleTrackID == id { clearSecondarySubtitle() } } - /// Fetch and decode a sidecar subtitle file (.srt / .ass / .vtt / .ssa) via `SubtitleDecoder.decodeFile`, replacing `subtitleCues` atomically. `httpHeaders` nil forwards `LoadOptions.httpHeaders` (same auth as the media, #32). Prefer registering via `addExternalSubtitleTrack` + `selectSubtitleTrack` (#88), which keeps the track listed and `activeSubtitleTrackIndex` populated; this API stays for compatibility and one-shot use. + /// Fetch and decode a sidecar subtitle file (.srt / .ass / .vtt / .ssa) via `SubtitleDecoder.decodeFile`, replacing `subtitleCues` atomically. `httpHeaders` nil inherits `LoadOptions.httpHeaders` only for the same HTTP scheme, host and effective port as the media; cross-origin sidecars need explicit headers. Prefer registering via `addExternalSubtitleTrack` + `selectSubtitleTrack` (#88), which keeps the track listed and `activeSubtitleTrackIndex` populated; this API stays for compatibility and one-shot use. public func selectSidecarSubtitle(url: URL, httpHeaders: [String: String]? = nil) { hostExplicitSubtitleAction = true startSidecarDecode(url: url, httpHeaders: httpHeaders, externalTrackID: nil) @@ -1546,7 +1561,9 @@ extension AetherEngine { sidecarASSHeader = nil isLoadingSubtitles = true - let effectiveHeaders = httpHeaders ?? loadedOptions.httpHeaders + let effectiveHeaders = Self.resolvedSubtitleHeaders( + for: url, explicit: httpHeaders, mediaURL: loadedURL, + mediaHeaders: loadedOptions.httpHeaders) // ASS/SSA sidecars honour preserveASSMarkup so hosts can drive a styled renderer. SRT/VTT fall back to plain text regardless. let preserveASS = loadedOptions.preserveASSMarkup sidecarTask = Task { [weak self] in @@ -1612,7 +1629,9 @@ extension AetherEngine { pgsStaleArrivalGates[.secondary]?.reset() // #100 isLoadingSecondarySubtitles = true - let effectiveHeaders = httpHeaders ?? loadedOptions.httpHeaders + let effectiveHeaders = Self.resolvedSubtitleHeaders( + for: url, explicit: httpHeaders, mediaURL: loadedURL, + mediaHeaders: loadedOptions.httpHeaders) secondarySidecarTask = Task { [weak self] in let result: SidecarDecodeResult do { @@ -2257,7 +2276,7 @@ extension AetherEngine { table: [NativeSubtitleTrackEntry], registry: [Int: ExternalSubtitleTrack], stores: [NativeSubtitleCueStore], - defaultHeaders: [String: String] + defaultHeaders: [String: String], mediaURL: URL? = nil ) -> [ExternalSubtitleFillJob] { struct Key: Hashable { let url: URL @@ -2270,7 +2289,9 @@ extension AetherEngine { // .sup at load would violate the selection gating). guard !entry.needsOCR, let extID = entry.externalID, let track = registry[extID], ordinal < stores.count else { continue } - let key = Key(url: track.url, headers: track.httpHeaders ?? defaultHeaders) + let key = Key(url: track.url, headers: resolvedSubtitleHeaders( + for: track.url, explicit: track.httpHeaders, mediaURL: mediaURL, + mediaHeaders: defaultHeaders)) if targetsByKey[key] == nil { order.append(key) } targetsByKey[key, default: []].append( .init(streamIndex: track.sourceStreamIndex, store: stores[ordinal])) diff --git a/Sources/AetherEngine/Native/RemoteHLSSubtitleProvider.swift b/Sources/AetherEngine/Native/RemoteHLSSubtitleProvider.swift index 3b4f74b2c..603a00097 100644 --- a/Sources/AetherEngine/Native/RemoteHLSSubtitleProvider.swift +++ b/Sources/AetherEngine/Native/RemoteHLSSubtitleProvider.swift @@ -27,6 +27,7 @@ final class RemoteHLSSubtitleProvider: HLSSegmentProvider, @unchecked Sendable { private let programDuration: Double private let stores: [NativeSubtitleCueStore] private let defaultHeaders: [String: String] + private let mediaURL: URL? /// Guards `fillTask` alone: the fill is started from the proxy's build (off the main actor) and /// cancelled from the engine's teardown (on it), so the handle is genuinely shared. private let fillLock = NSLock() @@ -40,7 +41,7 @@ final class RemoteHLSSubtitleProvider: HLSSegmentProvider, @unchecked Sendable { private let vttFillWaitSeconds: TimeInterval init(tracks: [Track], masterBody: String, programDuration: Double, - defaultHeaders: [String: String], + defaultHeaders: [String: String], mediaURL: URL? = nil, vttFillWaitSeconds: TimeInterval = defaultVTTFillWaitSeconds) { self.tracks = tracks self.staticMasterPlaylistBody = masterBody @@ -52,6 +53,7 @@ final class RemoteHLSSubtitleProvider: HLSSegmentProvider, @unchecked Sendable { let finiteDuration = programDuration.isFinite ? programDuration : 1 self.programDuration = min(max(1, finiteDuration), RemoteHLSSubtitleProxy.maxProgramDurationSeconds) self.defaultHeaders = defaultHeaders + self.mediaURL = mediaURL self.vttFillWaitSeconds = vttFillWaitSeconds self.stores = tracks.map { _ in NativeSubtitleCueStore() } } @@ -79,7 +81,7 @@ final class RemoteHLSSubtitleProvider: HLSSegmentProvider, @unchecked Sendable { /// once and a single bad stream index cannot blank its siblings. func startFill() { cancelFill() - let jobs = Self.fillJobs(tracks: tracks, stores: stores, defaultHeaders: defaultHeaders) + let jobs = Self.fillJobs(tracks: tracks, stores: stores, defaultHeaders: defaultHeaders, mediaURL: mediaURL) guard !jobs.isEmpty else { return } let task = BlockingWork.detached(priority: .utility) { [jobs] in for job in jobs { @@ -127,7 +129,7 @@ final class RemoteHLSSubtitleProvider: HLSSegmentProvider, @unchecked Sendable { /// `AetherEngine.externalSubtitleFillJobs`, which keys off the loopback's rendition table. static func fillJobs(tracks: [Track], stores: [NativeSubtitleCueStore], - defaultHeaders: [String: String]) -> [AetherEngine.ExternalSubtitleFillJob] { + defaultHeaders: [String: String], mediaURL: URL? = nil) -> [AetherEngine.ExternalSubtitleFillJob] { struct Key: Hashable { let url: URL let headers: [String: String] @@ -135,7 +137,9 @@ final class RemoteHLSSubtitleProvider: HLSSegmentProvider, @unchecked Sendable { var order: [Key] = [] var targetsByKey: [Key: [AetherEngine.ExternalSubtitleFillJob.Target]] = [:] for (ordinal, track) in tracks.enumerated() where ordinal < stores.count { - let key = Key(url: track.source.url, headers: track.source.httpHeaders ?? defaultHeaders) + let key = Key(url: track.source.url, headers: AetherEngine.resolvedSubtitleHeaders( + for: track.source.url, explicit: track.source.httpHeaders, mediaURL: mediaURL, + mediaHeaders: defaultHeaders)) if targetsByKey[key] == nil { order.append(key) } targetsByKey[key, default: []].append( .init(streamIndex: track.source.sourceStreamIndex, store: stores[ordinal])) diff --git a/Sources/AetherEngine/Native/RemoteHLSSubtitleProxy.swift b/Sources/AetherEngine/Native/RemoteHLSSubtitleProxy.swift index fd0b28a30..de2f4e336 100644 --- a/Sources/AetherEngine/Native/RemoteHLSSubtitleProxy.swift +++ b/Sources/AetherEngine/Native/RemoteHLSSubtitleProxy.swift @@ -140,7 +140,7 @@ enum RemoteHLSSubtitleProxy { let provider = RemoteHLSSubtitleProvider(tracks: tracks, masterBody: master, programDuration: duration, - defaultHeaders: httpHeaders) + defaultHeaders: httpHeaders, mediaURL: originURL) let relay: HLSOriginRelay? = needsRelay ? HLSOriginRelay() : nil // Audit NET-109: the grant belongs to the URL the host handed over. A redirect target is // only allowed, or a cross-host redirect would move the token to the edge and leave the diff --git a/Tests/AetherEngineTests/RemoteHLSSubtitleProxyTests.swift b/Tests/AetherEngineTests/RemoteHLSSubtitleProxyTests.swift index 22e7f7768..83210e7c1 100644 --- a/Tests/AetherEngineTests/RemoteHLSSubtitleProxyTests.swift +++ b/Tests/AetherEngineTests/RemoteHLSSubtitleProxyTests.swift @@ -68,7 +68,8 @@ struct RemoteHLSSubtitleProxyTests { let tracks = [Self.track(100_000, url: URL(string: "https://origin.test/en.srt")!)] let stores = tracks.map { _ in NativeSubtitleCueStore() } let jobs = RemoteHLSSubtitleProvider.fillJobs(tracks: tracks, stores: stores, - defaultHeaders: ["Authorization": "Bearer x"]) + defaultHeaders: ["Authorization": "Bearer x"], + mediaURL: URL(string: "https://origin.test/movie.m3u8")) #expect(jobs.first?.headers == ["Authorization": "Bearer x"]) } diff --git a/Tests/AetherEngineTests/SubtitleHeaderOriginTests.swift b/Tests/AetherEngineTests/SubtitleHeaderOriginTests.swift new file mode 100644 index 000000000..d4e979486 --- /dev/null +++ b/Tests/AetherEngineTests/SubtitleHeaderOriginTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing +@testable import AetherEngine + +@Suite("External subtitle initial-request header origins") +struct SubtitleHeaderOriginTests { + @Test func originAndExplicitAuthority() { + let media = URL(string: "https://Media.Example:443/movie.mkv")! + let headers = ["Authorization": "synthetic-media", "X-Custom-Token": "synthetic-custom"] + func resolve(_ target: String, _ explicit: [String: String]? = nil) -> [String: String] { + AetherEngine.resolvedSubtitleHeaders(for: URL(string: target)!, explicit: explicit, + mediaURL: media, mediaHeaders: headers) + } + #expect(resolve("https://media.example/en.srt") == headers) + for target in ["https://other.example/en.srt", "https://media.example:444/en.srt", + "http://media.example/en.srt", "file:///tmp/en.srt"] { + #expect(resolve(target).isEmpty) + #expect(resolve(target, ["Authorization": "subtitle"]) == ["Authorization": "subtitle"]) + } + #expect(resolve("https://media.example/en.srt", [:]).isEmpty) + #expect(AetherEngine.resolvedSubtitleHeaders( + for: URL(string: "https://media.example/en.srt")!, explicit: nil, + mediaURL: URL(string: "http://media.example/movie.mkv"), mediaHeaders: headers).isEmpty) + #expect(AetherEngine.resolvedSubtitleHeaders( + for: media, explicit: nil, mediaURL: nil, mediaHeaders: headers).isEmpty) + } + + @Test func nativeStoreFillKeepsHeadersTrackBound() { + let media = URL(string: "https://media.example/movie.mkv")! + let registry: [Int: ExternalSubtitleTrack] = [ + 100_000: .init(url: URL(string: "https://media.example/en.srt")!, name: "same"), + 100_001: .init(url: URL(string: "https://other.example/fr.srt")!, name: "other"), + 100_002: .init(url: URL(string: "https://other.example/de.srt")!, name: "explicit", + httpHeaders: ["Authorization": "subtitle"]), + ] + let entries = registry.keys.sorted().map { + AetherEngine.NativeSubtitleTrackEntry(sourceStreamIndex: nil, externalID: $0, language: "en", isForced: false) + } + let jobs = AetherEngine.externalSubtitleFillJobs( + table: entries, registry: registry, stores: entries.map { _ in NativeSubtitleCueStore() }, + defaultHeaders: ["Authorization": "media"], mediaURL: media) + #expect(jobs.map(\.headers) == [["Authorization": "media"], [:], ["Authorization": "subtitle"]]) + let tracks = registry.keys.sorted().map { + RemoteHLSSubtitleProvider.Track(externalID: $0, source: registry[$0]!) + } + let remoteJobs = RemoteHLSSubtitleProvider.fillJobs( + tracks: tracks, stores: tracks.map { _ in NativeSubtitleCueStore() }, + defaultHeaders: ["Authorization": "media"], mediaURL: media) + #expect(remoteJobs.map(\.headers) == jobs.map(\.headers)) + + } +} + +#if os(macOS) +@MainActor +@Suite("Sidecar requests observed at their origin", .timeLimit(.minutes(2))) +struct SubtitleHeaderOriginRequestTests { + private let script = #""" +import http.server +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + text = self.headers.get('Authorization', 'none') + '|' + self.headers.get('X-Custom-Token', 'none') + body = ('1\n00:00:00,000 --> 00:00:02,000\n' + text + '\n').encode() + self.send_response(200) + self.send_header('Content-Type', 'application/x-subrip') + self.send_header('Cache-Control', 'no-store') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, *args): pass +server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) +print('READY ' + str(server.server_port), flush=True) +server.serve_forever() +"""# + + @Test("Both overlays scope the actual first request", arguments: [false, true]) + func actualRequests(secondary: Bool) async throws { + let origin = try #require(await PythonOrigin.launch(prefix: "sidecar-header-origin", script: script)) + defer { + origin.process.terminate() + try? FileManager.default.removeItem(at: origin.workDir) + } + let url = URL(string: "http://127.0.0.1:\(origin.port)/en.srt")! + let engine = try AetherEngine() + defer { engine.stop() } + engine.setLoadedOptionsForTesting(.init(httpHeaders: ["Authorization": "media", "X-Custom-Token": "custom"])) + func select(_ headers: [String: String]?) async throws -> [String] { + if secondary { + engine.selectSecondarySidecarSubtitle(url: url, httpHeaders: headers) + await engine.secondarySidecarTask?.value + return engine.secondarySubtitleCues.compactMap(\.text) + } + engine.selectSidecarSubtitle(url: url, httpHeaders: headers) + await engine.sidecarTask?.value + return engine.subtitleCues.compactMap(\.text) + } + // The media origin need not be contacted. Different ports alone must isolate its headers. + let otherPort = origin.port == 65535 ? 65534 : Int(origin.port) + 1 + engine.loadedURL = URL(string: "http://127.0.0.1:\(otherPort)/movie.mkv")! + #expect(try await select(nil) == ["none|none"]) + #expect(try await select(["Authorization": "subtitle"]) == ["subtitle|none"]) + engine.loadedURL = URL(string: "http://127.0.0.1:\(origin.port)/movie.mkv")! + #expect(try await select(nil) == ["media|custom"]) + #expect(try await select([:]) == ["none|none"]) + } +} +#endif diff --git a/docs/api.md b/docs/api.md index 16362779c..cd0401d26 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1378,3 +1378,15 @@ Public for the CLI, the test suite, or a diagnostic overlay, and outside the sha - **Dolby Vision Profile 7 FEL tooling** (`@_spi(Diagnostics)`): `FelProbeResult` / `felDecodeProbe` (`aetherctl feltest`) and `felResidualEnabled` (`aetherctl play --cycle-el`), which exist only with the `DualLayerDolbyVision` trait, `DoviRpuSurvey` / `DoviRpuSurveyResult` / `DoviRpuFrameSurvey` / `doviRpuSurvey` (`aetherctl dvsurvey`), and, in `DEBUG` builds with the `DualLayerDolbyVision` trait only, `armDolbyVisionPinTest` / `dolbyVisionPinIsArmed` / `dolbyVisionPinIsActive` (`aetherctl play --dv-pin`). Hosts import none of them; the host surface of the FEL route is `LoadOptions.preferDualLayerDolbyVision` and, in `SessionReloadRefusal`, the new case `.dualLayerRouteNeedsFreshLoad`. See [Dolby Vision FEL](dolby-vision-fel.md) and [CLI](cli.md). - **`HLSLiveIngestReader`'s internals** (`terminalError`, `upstreamTargetDuration`, `observedLiveCadenceSeconds`, `closedLiveCadenceSeconds`, `upstreamSegmentDurationSeconds`, `companionAudioReader`): fixture and diagnostic reads. The last two are the closed evidence the served TARGETDURATION is sealed from (AE#447); `upstreamTargetDuration` is the upstream's own claim, reported in the seal line and derived from nowhere. - **`SubtitleChannel`**: the primary / secondary selector on the engine's internal subtitle routing. No public signature takes one; a host picks the channel by calling the primary or the secondary method. + +### External subtitle request headers + +When an external subtitle has no explicit `httpHeaders`, media headers are inherited only if +its URL has the same HTTP scheme, host and effective port as the media URL. An omitted default +port equals an explicit default port; a scheme change, including HTTP to HTTPS, is a different +origin for this initial sidecar request. Unknown media origins and local files inherit nothing. +This applies to primary and secondary overlays, native subtitle cue-store fills and remote-HLS +sidecar renditions. Set `ExternalSubtitleTrack.httpHeaders` or the sidecar call's `httpHeaders` +explicitly for a subtitle server requiring authentication at another origin. An explicit empty +dictionary disables inheritance. Redirects still follow the existing redirect-header policy, +and certificate trust still uses `EngineTLS.serverTrustEvaluator` independently.