Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
runs-on: macos-15
timeout-minutes: 30
env:
AUTHORIZATION_TEST_SUITES: 'RefreshableHLSAuthorizationTests|RefreshableSubtitleAuthorizationTests'
AUTHORIZATION_TEST_SUITES: 'RefreshableHLSAuthorizationTests|RefreshableSubtitleAuthorizationTests|LiveTrustEvaluatorTests'
steps:
- uses: actions/checkout@v4

Expand All @@ -43,8 +43,10 @@ jobs:
id: swift-tests
run: swift test --skip "$AUTHORIZATION_TEST_SUITES"

# These suites assert short authorization deadlines. Run them in a separate process
# These suites use short authorization deadlines. Run them in a separate process
# so blocking work in the broader suite cannot starve their async resolvers.
# LiveTrustEvaluatorTests holds the redirect-scope tests, which have those deadlines, and
# the live TLS handshake tests, which share its serialized hold on the global evaluator.
# The shared filter partitions all tests without changing their deadlines or parallelism.
- name: Authorization deadline regressions
if: ${{ !cancelled() && steps.swift-tests.outcome != 'skipped' }}
Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ the public-API contract.

### Changed

- CI runs the HLS and subtitle/resource authorization suites in a separate test process so unrelated blocking tests cannot delay their resolvers. All tests and existing deadlines remain enforced.
- CI runs the HLS, subtitle/resource authorization and live trust-evaluator suites in a separate test process so unrelated blocking tests cannot delay their resolvers. All tests and existing deadlines remain enforced.
- Once a redirect chain has reached HTTPS, the engine drops credential headers (`Authorization`, `Cookie`, Emby/Jellyfin tokens) from every later HTTP hop, including headers returned by `HTTPRequestAuthorization`. A provider that authorizes every URL can no longer send its credentials in cleartext after a downgrade. The HTTP hop still runs, so anonymous redirects keep working, and an origin the host configured as HTTP still receives provider credentials.

### Added

- `LoadOptions.objectAudioRendering` keeps the height channels and object positioning of TrueHD Atmos by rendering its objects into a speaker bed; it is a lossy conversion, not passthrough of the original stream. With `.apac(SpatialSpeakerLayout)` a TrueHD track FFmpeg marks as Atmos is decoded (beds, objects and their metadata), rendered into the chosen 5.1.2 to 9.1.6 speaker bed and delivered as Apple Positional Audio (`CODECS="apac.31.LL"`), which tvOS sends to an Atmos receiver as Dolby MAT. Lossy (320 kbps per bed channel) in place of the lossless 7.1 channel presentation, so it is opt-in; requires OS 26 and falls back to `audioBridgeMode` otherwise. See [formats.md › TrueHD Atmos (object rendering)](docs/formats.md#truehd-atmos-object-rendering).
- `aetherctl serve --atmos-bed <layout>` and `serve --audio-index <n>` serve a TrueHD Atmos track through that path.
- Live HTTPS-to-HTTP redirect tests cover provider refusal, anonymous redirects, static-header stripping and the downgrade credential filter.
- `ExternalSubtitleTrack.httpRequestAuthorization` supplies refreshable headers for primary/secondary sidecars and native subtitle stores without changing registered track IDs or rendition mappings. Authorized container decoding retains AVIO streaming and range access.
- `HTTPRequestAuthorization.data(from:maximumBytes:)` fetches raw auxiliary resources such as font bundles with a caller-supplied byte limit and a whole-transfer deadline, reusing the relay's redirect, authorization, retry, cancellation and TLS policy.

Expand Down
12 changes: 8 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,18 @@ swift build
swift test
```

CI runs `RefreshableHLSAuthorizationTests` and `RefreshableSubtitleAuthorizationTests`
in a separate process because their short deadlines require responsive async resolvers.
Blocking work elsewhere in the suite can delay those resolvers on smaller runners.
CI runs `RefreshableHLSAuthorizationTests`, `RefreshableSubtitleAuthorizationTests` and
`LiveTrustEvaluatorTests` in a separate process because their short authorization deadlines require
responsive async resolvers. Blocking work elsewhere in the suite can delay those resolvers on smaller
runners. `LiveTrustEvaluatorTests` is the serialized parent of every live suite that sets the
process-global `EngineTLS.serverTrustEvaluator`. Nest any new suite that sets it there; the
redirect-scope tests carry the deadlines, and the TLS handshake tests run with them because they share
that global.
The two commands below cover the entire test suite, keeping the existing deadlines
and parallel execution within each group:

```bash
AUTHORIZATION_TEST_SUITES='RefreshableHLSAuthorizationTests|RefreshableSubtitleAuthorizationTests'
AUTHORIZATION_TEST_SUITES='RefreshableHLSAuthorizationTests|RefreshableSubtitleAuthorizationTests|LiveTrustEvaluatorTests'
swift test --skip "$AUTHORIZATION_TEST_SUITES"
swift test --skip-build --filter "$AUTHORIZATION_TEST_SUITES"
```
Expand Down
6 changes: 5 additions & 1 deletion Sources/AetherEngine/Demuxer/RedirectHeaderPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ enum RedirectHeaderPolicy {
if credentialsAllowed(from: originalURL, to: redirectURL) {
return extraHeaders
}
return extraHeaders.filter { !credentialHeaders.contains($0.key.lowercased()) }
return withoutCredentials(extraHeaders)
}

static func withoutCredentials(_ headers: [String: String]) -> [String: String] {
headers.filter { !credentialHeaders.contains($0.key.lowercased()) }
}

/// Builds the request actually handed back to URLSession on redirect: re-applies the
Expand Down
6 changes: 4 additions & 2 deletions Sources/AetherEngine/Network/EngineTLS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,17 @@ public enum EngineTLS {
/// Single disposition shared by the session-level delegate and the
/// per-task delegates in AVIOReader. Anything other than a server-trust
/// challenge the host accepted is left to default handling, so client
/// certificates and HTTP auth behave exactly as before.
/// certificates and HTTP auth behave exactly as before. `evaluator` defaults to the host's
/// answer; unit tests pass their own rather than writing the process global.
static func resolve(
_ challenge: URLAuthenticationChallenge,
evaluator: (@Sendable (URLProtectionSpace) -> Bool)? = EngineTLS.serverTrustEvaluator,
completionHandler: (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard
challenge.protectionSpace.authenticationMethod
== NSURLAuthenticationMethodServerTrust,
let evaluator = serverTrustEvaluator,
let evaluator,
evaluator(challenge.protectionSpace),
let trust = challenge.protectionSpace.serverTrust
else {
Expand Down
19 changes: 16 additions & 3 deletions Sources/AetherEngine/Network/HLSOriginRelay.swift
Original file line number Diff line number Diff line change
Expand Up @@ -393,11 +393,20 @@ final class HLSOriginRelay: @unchecked Sendable {
var retryHeaders: [String: String]?
var challenged = false
var redirects = 0
// Once a chain has reached TLS, no credential crosses a later cleartext hop, whatever the
// provider answers for it. The hop itself still runs, so anonymous redirects keep working.
var reachedTLS = Self.isTLS(origin)
func downgradeSafe(_ headers: [String: String], to destination: URL) -> [String: String] {
reachedTLS && !Self.isTLS(destination) ? RedirectHeaderPolicy.withoutCredentials(headers) : headers
}
while true {
let resolved: [String: String]
do {
if let retryHeaders { resolved = retryHeaders }
else { resolved = try authorize(url, rejectedHeaders: nil, fallback: staticHeaders) }
else {
let answer = try authorize(url, rejectedHeaders: nil, fallback: staticHeaders)
resolved = downgradeSafe(answer, to: url)
}
} catch { reportRequestFailure(); return .failed }
retryHeaders = nil
var request = URLRequest(url: url)
Expand Down Expand Up @@ -434,11 +443,13 @@ final class HLSOriginRelay: @unchecked Sendable {
staticHeaders = RedirectHeaderPolicy.headersToReplay(
extraHeaders: staticHeaders, originalURL: url, redirectURL: destination)
url = destination
reachedTLS = reachedTLS || Self.isTLS(destination)
case .challenge(let response, let sentHeaders):
challenged = true
let respondingURL = response.url ?? url
guard let fresh = try? authorize(respondingURL, rejectedHeaders: sentHeaders, fallback: [:]),
Self.authorizationValue(fresh) != Self.authorizationValue(sentHeaders) else {
let fresh = (try? authorize(respondingURL, rejectedHeaders: sentHeaders, fallback: [:]))
.map { downgradeSafe($0, to: respondingURL) }
guard let fresh, Self.authorizationValue(fresh) != Self.authorizationValue(sentHeaders) else {
reportRequestFailure()
return .held(Fetched(url: respondingURL, status: 401, body: Data(), contentType: nil, contentRange: nil))
}
Expand All @@ -452,6 +463,8 @@ final class HLSOriginRelay: @unchecked Sendable {
"range", "host", "content-length", "transfer-encoding", "connection", "trailer", "te", "upgrade"
]

private static func isTLS(_ url: URL) -> Bool { url.scheme?.lowercased() == "https" }

private static func authorizationValue(_ headers: [String: String]) -> String? {
headers.first { $0.key.caseInsensitiveCompare("Authorization") == .orderedSame }?.value
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/AetherEngine/Network/HTTPRequestAuthorization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import Foundation
/// Direct media AVIO and live ingest still use their existing static headers.
/// The resolver must independently validate every URL, including redirects and playlist-discovered
/// origins. Discovery grants no credential authority. Credentials must never be placed in URLs.
/// Include scheme, host and effective port in that scope. Redirects are authorized afresh. Throw to
/// refuse a destination, or return no credentials to allow an anonymous request. Once a chain has
/// reached HTTPS, credential headers are dropped from every later HTTP hop, resolver output included;
/// a chain that starts on HTTP sends what the resolver returns.
///
/// The engine owns Range, Host, and HTTP framing. A nil rejected-header dictionary asks for a new
/// request. A nonnil dictionary is the actual request headers rejected by one HTTP 401; returning a
Expand Down
Loading
Loading