diff --git a/CHANGELOG.md b/CHANGELOG.md index bab123c2..b73404c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +- SideloadedSubtitle + - Fixed an issue where variant playlists failed to load when the master playlist listed them as relative URIs with a query string, because the query string was percent-encoded into the path. + - Fixed an issue where percent-escaped characters in playlist URIs (for example `%2B`, `%2F` or `%3D` in a signed token) were decoded, which could break segment, key or variant requests. + ## [11.0.7] - 2026-11-05 ### Fixed diff --git a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift index aad3cf55..6811714e 100644 --- a/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift +++ b/Code/Sideloaded-TextTracks/Sources/THEOplayerConnectorSideloadedSubtitle/Parsers/PlaylistParser.swift @@ -50,23 +50,19 @@ class PlaylistParser { } func getFullURL(from path: String) -> URL? { - // Remove any existing percentencoding and add 1 pass of percentEncoding to make sure correct URL can be build up on all iOS version - guard let trimmedPath = path.trimmingCharacters(in: .whitespacesAndNewlines).removingPercentEncoding?.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { - return nil - } - var url: URL? = nil - if #available(iOS 17.0, tvOS 17.0, *) { - url = URL(string: trimmedPath, encodingInvalidCharacters: false) // don't allow an extra percentEncoding pass. - } else { - url = URL(string: trimmedPath) - } - if let createdUrl = url, - createdUrl.scheme == nil, - let decodedPath = trimmedPath.removingPercentEncoding { // drop the percentEncoding as this will be reapplied by appendingPathComponent - return self.manifestURL.deletingLastPathComponent().appendingPathComponent(decodedPath) + let uri = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !uri.isEmpty else { return nil } + + if let url = URL(string: uri, relativeTo: self.manifestURL) { + return url.absoluteURL } - - return url + + // Before iOS 17, URL(string:) rejects invalid characters such as raw spaces. + // Escape only those and keep existing %XX escapes and URL delimiters as written. + var allowed = CharacterSet.urlQueryAllowed + allowed.insert(charactersIn: "%#[]") + guard let escaped = uri.addingPercentEncoding(withAllowedCharacters: allowed) else { return nil } + return URL(string: escaped, relativeTo: self.manifestURL)?.absoluteURL } func updateRelativeUri(line: HLSLine) { diff --git a/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift new file mode 100644 index 00000000..8c5be99f --- /dev/null +++ b/Code/Sideloaded-TextTracks/Tests/THEOplayerConnectorSideloadedSubtitleTests/PlaylistParserTests.swift @@ -0,0 +1,320 @@ +import Foundation +import Swifter +import XCTest +@testable import THEOplayerConnectorSideloadedSubtitle + +final class PlaylistParserTests: XCTestCase { + private let manifestURL = URL(string: "https://cdn.example.com/vod/movie/index.m3u8?token=abc")! + private let manifestURLWithoutQuery = URL(string: "https://cdn.example.com/vod/movie/index.m3u8")! + + // MARK: - Relative URIs with a query string (#132) + + func testRelativeURIKeepsQueryString() { + let parser = PlaylistParser(url: manifestURL, request: nil) + + let url = parser.getFullURL(from: "video=391132.m3u8?hdntl=st=1~exp=2~acl=/*~hmac=ff") + + XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/video=391132.m3u8?hdntl=st=1~exp=2~acl=/*~hmac=ff") + } + + func testRelativeURIInSubdirectoryKeepsQueryString() { + let parser = PlaylistParser(url: manifestURL, request: nil) + + let url = parser.getFullURL(from: "keyframes/video.m3u8?accountId=1") + + XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/keyframes/video.m3u8?accountId=1") + } + + func testRelativeURIInParentDirectoryKeepsQueryString() { + let parser = PlaylistParser(url: manifestURL, request: nil) + + let url = parser.getFullURL(from: "../other/video.m3u8?accountId=1") + + XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/other/video.m3u8?accountId=1") + } + + func testRelativeURIWithoutQueryIsResolvedAgainstManifestDirectory() { + let parser = PlaylistParser(url: manifestURL, request: nil) + + let url = parser.getFullURL(from: "video.m3u8") + + XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/video.m3u8") + } + + func testAbsoluteURIIsNotChanged() { + let parser = PlaylistParser(url: manifestURL, request: nil) + + let url = parser.getFullURL(from: "https://other.example.com/video.m3u8?token=xyz") + + XCTAssertEqual(url?.absoluteString, "https://other.example.com/video.m3u8?token=xyz") + } + + // MARK: - Encoded filenames are not double-encoded (#37: %20 must not become %2520) + + func testRelativeURIWithEncodedSpacesIsNotDoubleEncoded() { + let parser = PlaylistParser(url: manifestURLWithoutQuery, request: nil) + + let url = parser.getFullURL(from: "MK-CB-S01E05-es-419-227%20Soy%20una%20tazaUHD.m3u8") + + XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/MK-CB-S01E05-es-419-227%20Soy%20una%20tazaUHD.m3u8") + } + + func testRelativeURIKeepsEncodedLiteralPercent() { + let parser = PlaylistParser(url: manifestURLWithoutQuery, request: nil) + + let url = parser.getFullURL(from: "100%25.m3u8") + + XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/100%25.m3u8") + } + + func testRelativeURIKeepsDoubleEncodedSpace() { + let parser = PlaylistParser(url: manifestURLWithoutQuery, request: nil) + + let url = parser.getFullURL(from: "a%2520b.m3u8") + + XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/a%2520b.m3u8") + } + + // MARK: - Existing percent-escapes are kept as written (#134) + + func testRelativeURIKeepsEscapedQuestionMarkInFileName() { + let parser = PlaylistParser(url: manifestURLWithoutQuery, request: nil) + + let url = parser.getFullURL(from: "video%3Falt.m3u8") + + XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/video%3Falt.m3u8") + XCTAssertNil(url?.query) + } + + func testRelativeURIKeepsEscapedQueryValueBytesAndOrder() { + let parser = PlaylistParser(url: manifestURL, request: nil) + + let url = parser.getFullURL(from: "video.m3u8?b=2&token=a%26b%2Bc%3Dd%25e&a=1") + + XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/video.m3u8?b=2&token=a%26b%2Bc%3Dd%25e&a=1") + } + + func testAbsoluteEncodedURIIsNotChanged() { + let parser = PlaylistParser(url: manifestURL, request: nil) + + let url = parser.getFullURL(from: "https://other.example.com/path%20x/video%3Falt.m3u8?token=a%26b") + + XCTAssertEqual(url?.absoluteString, "https://other.example.com/path%20x/video%3Falt.m3u8?token=a%26b") + } + + func testRelativeURIKeepsEncodedURLInPathSegment() { + let parser = PlaylistParser(url: manifestURLWithoutQuery, request: nil) + + let url = parser.getFullURL(from: "proxy/https%3A%2F%2Forigin.com%2Fvideo.m3u8%3Ftoken%3Dabc") + + XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/proxy/https%3A%2F%2Forigin.com%2Fvideo.m3u8%3Ftoken%3Dabc") + } + + // MARK: - Invalid characters are escaped when the URI can't be parsed (#134: raw spaces before iOS 17) + + func testRelativeURIWithRawSpaceIsEscaped() { + let parser = PlaylistParser(url: manifestURLWithoutQuery, request: nil) + + let url = parser.getFullURL(from: "seg one.ts") + + XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/seg%20one.ts") + } + + // MARK: - Relative references resolve to absolute URLs (#12, RFC 3986 ยง5.2) + + func testRootRelativeURIIsResolvedAgainstManifestHost() { + let parser = PlaylistParser(url: manifestURL, request: nil) + + let url = parser.getFullURL(from: "/root/video.m3u8") + + XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/root/video.m3u8") + } + + func testRootRelativeURIKeepsQueryString() { + let parser = PlaylistParser(url: manifestURL, request: nil) + + let url = parser.getFullURL(from: "/root/video.m3u8?accountId=1") + + XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/root/video.m3u8?accountId=1") + } + + func testSchemeRelativeURIUsesManifestScheme() { + let parser = PlaylistParser(url: manifestURL, request: nil) + + let url = parser.getFullURL(from: "//other.example.com/live/video.m3u8?token=xyz") + + XCTAssertEqual(url?.absoluteString, "https://other.example.com/live/video.m3u8?token=xyz") + } + + func testRelativeURIIsTrimmed() { + let parser = PlaylistParser(url: manifestURL, request: nil) + + let url = parser.getFullURL(from: " \tvideo.m3u8?accountId=1 \r\n") + + XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/movie/video.m3u8?accountId=1") + } + + func testRelativeURIInParentDirectoryDoesNotInheritManifestQuery() { + let parser = PlaylistParser(url: manifestURL, request: nil) + + let url = parser.getFullURL(from: "../other/video.m3u8") + + XCTAssertEqual(url?.absoluteString, "https://cdn.example.com/vod/other/video.m3u8") + } + + // MARK: - Blank playlist lines are not rewritten (VariantPlaylistParser passes every non-# line, #37) + + func testEmptyURIReturnsNil() { + let parser = PlaylistParser(url: manifestURL, request: nil) + + XCTAssertNil(parser.getFullURL(from: "")) + } + + func testWhitespaceOnlyURIReturnsNil() { + let parser = PlaylistParser(url: manifestURL, request: nil) + + XCTAssertNil(parser.getFullURL(from: " \t\r\n")) + } + + // MARK: - Playlist rewriting (local HTTP fixtures) + + // #12: variant and media URIs are made absolute + func testMasterPlaylistRewritesVariantAndMediaURIs() async throws { + let master = """ + #EXTM3U + #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="English",URI="audio/en%20main.m3u8?token=a1" + #EXT-X-STREAM-INF:BANDWIDTH=1000,AUDIO="aud" + video=391132.m3u8?hdntl=st=1~exp=2~acl=/*~hmac=ff + #EXT-X-STREAM-INF:BANDWIDTH=2000,AUDIO="aud" + MK-CB-S01E05-es-419-227%20Soy%20una%20tazaUHD.m3u8 + #EXT-X-I-FRAME-STREAM-INF:BANDWIDTH=500,URI="iframes/i.m3u8" + + """ + let (server, base) = try startServer(["/vod/master.m3u8": master]) + defer { server.stop() } + let parser = MasterPlaylistParser(url: URL(string: "\(base)/vod/master.m3u8?token=parent")!, request: nil) + + let data = await parser.sideLoadSubtitles(subtitles: []) + + let lines = String(decoding: try XCTUnwrap(data), as: UTF8.self).components(separatedBy: "\n") + XCTAssertTrue(lines.contains("\(base)/vod/video=391132.m3u8?hdntl=st=1~exp=2~acl=/*~hmac=ff"), "\(lines)") + XCTAssertTrue(lines.contains("\(base)/vod/MK-CB-S01E05-es-419-227%20Soy%20una%20tazaUHD.m3u8"), "\(lines)") + XCTAssertTrue(lines.contains { $0.hasPrefix("#EXT-X-MEDIA:") && $0.contains("URI=\"\(base)/vod/audio/en%20main.m3u8?token=a1\"") }, "\(lines)") + XCTAssertTrue(lines.contains { $0.hasPrefix("#EXT-X-I-FRAME-STREAM-INF:") && $0.contains("URI=\"\(base)/vod/iframes/i.m3u8\"") }, "\(lines)") + } + + // #17: the final URL after a redirect is the base + func testMasterPlaylistResolvesAgainstRedirectedURL() async throws { + let master = "#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"English\",URI=\"audio.m3u8\"\n#EXT-X-STREAM-INF:BANDWIDTH=1000,AUDIO=\"aud\"\nvideo.m3u8?sig=1\n" + let (server, base) = try startServer(["/final/dir/master.m3u8": master]) + defer { server.stop() } + server["/start/master.m3u8"] = { _ in .movedTemporarily("/final/dir/master.m3u8") } + let parser = MasterPlaylistParser(url: URL(string: "\(base)/start/master.m3u8")!, request: nil) + + let data = await parser.sideLoadSubtitles(subtitles: []) + + XCTAssertEqual(parser.manifestURL.absoluteString, "\(base)/final/dir/master.m3u8") + let lines = String(decoding: try XCTUnwrap(data), as: UTF8.self).components(separatedBy: "\n") + XCTAssertTrue(lines.contains("\(base)/final/dir/video.m3u8?sig=1"), "\(lines)") + XCTAssertTrue(lines.contains { $0.contains("URI=\"\(base)/final/dir/audio.m3u8\"") }, "\(lines)") + } + + // #20: EXT-X-KEY/EXT-X-MAP URI attributes; #37: EXT-X-BYTERANGE between EXTINF and the segment + func testVariantPlaylistRewritesSegmentKeyAndMapURIs() async throws { + let variant = """ + #EXTM3U + #EXT-X-VERSION:7 + #EXT-X-TARGETDURATION:6 + #EXT-X-MAP:URI="init%20file.mp4?sig=a1" + #EXT-X-KEY:METHOD=AES-128,URI="keys/key.bin?token=k1&exp=2" + #EXTINF:6.000, + #EXT-X-BYTERANGE:1000@0 + seg%20one.ts?hdntl=exp=2~acl=/*~hmac=ff + #EXTINF:4.500, + #EXT-X-PROGRAM-DATE-TIME:2024-01-01T00:00:06.000Z + ../shared/seg2.ts + #EXT-X-ENDLIST + + """ + let (server, base) = try startServer(["/vod/movie/video.m3u8": variant]) + defer { server.stop() } + let parser = VariantPlaylistParser(url: URL(string: "\(base)/vod/movie/video.m3u8?token=parent")!, request: nil) + + let result = await parser.parse() + + let parsed = try XCTUnwrap(result) + XCTAssertEqual(parsed.totalPlayListDuration, 10.5, accuracy: 0.0001) + let lines = String(decoding: try XCTUnwrap(parsed.manifestData), as: UTF8.self).components(separatedBy: "\n") + // Only the duration is compared for EXTINF: the parser drops the empty title separator. + let comparableLines = lines.map { $0.hasPrefix("#EXTINF:") ? String($0.prefix { $0 != "," }) : $0 } + XCTAssertEqual(comparableLines, [ + "#EXTM3U", + "#EXT-X-VERSION:7", + "#EXT-X-TARGETDURATION:6", + "#EXT-X-MAP:URI=\"\(base)/vod/movie/init%20file.mp4?sig=a1\"", + lines.first { $0.hasPrefix("#EXT-X-KEY:") } ?? "", + "#EXTINF:6.000", + "#EXT-X-BYTERANGE:1000@0", + "\(base)/vod/movie/seg%20one.ts?hdntl=exp=2~acl=/*~hmac=ff", + "#EXTINF:4.500", + "#EXT-X-PROGRAM-DATE-TIME:2024-01-01T00:00:06.000Z", + "\(base)/vod/shared/seg2.ts", + "#EXT-X-ENDLIST", + ]) + let keyLine = try XCTUnwrap(lines.first { $0.hasPrefix("#EXT-X-KEY:") }) + XCTAssertTrue(keyLine.contains("METHOD=AES-128"), keyLine) + XCTAssertTrue(keyLine.contains("URI=\"\(base)/vod/movie/keys/key.bin?token=k1&exp=2\""), keyLine) + } + + // #134 + func testVariantPlaylistKeepsEscapedSignatureBytes() async throws { + let variant = """ + #EXTM3U + #EXT-X-TARGETDURATION:6 + #EXT-X-KEY:METHOD=AES-128,URI="key.bin?sig=k%2F1%2B%3D" + #EXTINF:6.000, + seg1.ts?exp=2&sig=ab%2Fcd%2B%3D%3D + #EXT-X-ENDLIST + + """ + let (server, base) = try startServer(["/vod/movie/video.m3u8": variant]) + defer { server.stop() } + let parser = VariantPlaylistParser(url: URL(string: "\(base)/vod/movie/video.m3u8")!, request: nil) + + let result = await parser.parse() + + let lines = String(decoding: try XCTUnwrap(try XCTUnwrap(result).manifestData), as: UTF8.self).components(separatedBy: "\n") + XCTAssertTrue(lines.contains("\(base)/vod/movie/seg1.ts?exp=2&sig=ab%2Fcd%2B%3D%3D"), "\(lines)") + let keyLine = try XCTUnwrap(lines.first { $0.hasPrefix("#EXT-X-KEY:") }) + XCTAssertTrue(keyLine.contains("URI=\"\(base)/vod/movie/key.bin?sig=k%2F1%2B%3D\""), keyLine) + } + + // #134: raw-space segment is kept before iOS 17 + func testVariantPlaylistKeepsSegmentWithRawSpace() async throws { + let variant = """ + #EXTM3U + #EXT-X-TARGETDURATION:6 + #EXTINF:6.000, + seg one.ts + #EXT-X-ENDLIST + + """ + let (server, base) = try startServer(["/video.m3u8": variant]) + defer { server.stop() } + let parser = VariantPlaylistParser(url: URL(string: "\(base)/video.m3u8")!, request: nil) + + let result = await parser.parse() + + let lines = String(decoding: try XCTUnwrap(try XCTUnwrap(result).manifestData), as: UTF8.self).components(separatedBy: "\n") + XCTAssertTrue(lines.contains("\(base)/seg%20one.ts"), "\(lines)") + } + + private func startServer(_ playlists: [String: String]) throws -> (HttpServer, String) { + let server = HttpServer() + for (path, playlist) in playlists { + server[path] = { _ in .ok(.data(Data(playlist.utf8), contentType: "application/vnd.apple.mpegurl")) } + } + try server.start(0, forceIPv4: true, priority: .userInitiated) + return (server, "http://127.0.0.1:\(try server.port())") + } +}