import Foundation import Darwin struct ResolvedSubtitle: Decodable, Sendable { let url: String let format: String let language: String let label: String let isDefault: Bool private let headerFields: [String: String] var proxy: MiruroProxy? = nil var headers: String { headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined() } fileprivate var validatedHeaders: [String: String] { headerFields } init(url: String, format: String, language: String, label: String, isDefault: Bool, headers: [String: String], proxy: MiruroProxy? = nil) { self.url = url self.format = format self.language = language self.label = label self.isDefault = isDefault self.headerFields = headers self.proxy = proxy } private enum CodingKeys: String, CodingKey { case url, format, language, label case isDefault = "default" case headerFields = "headers" } } // Optional episode-relative provider timings; playback validates against media duration. struct ProviderInterval: Sendable, Equatable { enum Kind: Sendable { case intro, outro } let kind: Kind let start: Double let end: Double } struct ResolvedSource: Sendable { let url: String let headers: String let identity: String let title: String let subtitles: [ResolvedSubtitle] var intervals: [ProviderInterval] = [] var requirements: MiruroPlaybackRequirements = .init() var playbackLifetime: (any Sendable)? = nil func replacingSubtitles(_ subtitles: [ResolvedSubtitle]) -> ResolvedSource { ResolvedSource(url: url, headers: headers, identity: identity, title: title, subtitles: subtitles, intervals: intervals, requirements: requirements, playbackLifetime: playbackLifetime) } struct Document: Decodable { struct Identity: Decodable { let provider: String let media_id: String let episode: Int } let version: Int let url: String let headers: [String: String] let identity: Identity let title: String? let subtitles: [ResolvedSubtitle] } private struct InvalidSource: LocalizedError { var errorDescription: String? { "Invalid or unreadable resolved source file." } } static func load(from file: URL) throws -> ResolvedSource { do { guard file.isFileURL else { throw InvalidSource() } let descriptor: Int32 = file.withUnsafeFileSystemRepresentation { path in guard let path else { return -1 } return Darwin.open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC) } guard descriptor >= 0 else { throw InvalidSource() } var info = stat() guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else { Darwin.close(descriptor) throw InvalidSource() } let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) defer { try? handle.close() } let data = try handle.read(upToCount: 1_048_577) ?? Data() guard data.count <= 1_048_576 else { throw InvalidSource() } let document = try JSONDecoder().decode(Document.self, from: data) return try validated(document) } catch { throw InvalidSource() } } static func validIdentity(mediaID: String, episode: Int) -> Bool { !mediaID.isEmpty && mediaID.utf8.count <= 512 && mediaID.utf8.allSatisfy({ isIDByte($0) }) && episode > 0 && episode <= Int(Int32.max) } static func validAniListIdentity(mediaID: String, episode: Int) -> Bool { guard let id = Int32(mediaID), id > 0, String(id) == mediaID else { return false } return episode > 0 && episode <= Int(Int32.max) } static func validated(_ document: Document) throws -> ResolvedSource { let id = document.identity guard document.version == 1, (id.provider == "miruro" && validIdentity(mediaID: id.media_id, episode: id.episode)) || (id.provider == "anilist" && validAniListIdentity(mediaID: id.media_id, episode: id.episode)), validURL(document.url), validHeaders(document.headers), validText(document.title ?? "", limit: 4096), validSubtitles(document.subtitles) else { throw InvalidSource() } let headers = document.headers.sorted { $0.key < $1.key } .map { "\($0.key): \($0.value)\r\n" }.joined() return ResolvedSource(url: document.url, headers: headers, identity: "\(id.provider):\(id.media_id):\(id.episode)", title: document.title ?? "\(id.provider == "anilist" ? "AniList \(id.media_id)" : "Miruro") episode \(id.episode)", subtitles: document.subtitles) } static func validSubtitles(_ subtitles: [ResolvedSubtitle]) -> Bool { subtitles.count <= 32 && subtitles.filter({ $0.isDefault }).count <= 1 && subtitles.allSatisfy { validURL($0.url) && $0.format == "vtt" && validText($0.language, limit: 128) && !$0.language.isEmpty && validText($0.label, limit: 4096) && !$0.label.isEmpty && validHeaders($0.validatedHeaders) } } private static func isIDByte(_ byte: UInt8) -> Bool { (65...90).contains(byte) || (97...122).contains(byte) || (48...57).contains(byte) || byte == 45 || byte == 95 } static func validText(_ text: String, limit: Int) -> Bool { text.utf8.count <= limit && !text.unicodeScalars.contains { $0.value < 32 || $0.value == 127 } } static func validURL(_ text: String) -> Bool { guard !text.isEmpty, validText(text, limit: 32768), !text.contains(where: { $0.isWhitespace }), let components = URLComponents(string: text), components.scheme == "https" || components.scheme == "http", let host = components.host, !host.isEmpty, components.user == nil, components.password == nil, components.fragment == nil, components.url != nil else { return false } return components.port == nil || (1...65535).contains(components.port!) } static func validHeaders(_ headers: [String: String]) -> Bool { guard headers.count <= 64 else { return false } var names = Set() var size = 0 for (name, value) in headers { guard !name.isEmpty, name.utf8.count <= 256, name.utf8.allSatisfy({ isIDByte($0) || "!#$%&'*+.^`|~".utf8.contains($0) }), names.insert(name.lowercased()).inserted, value.utf8.count <= 16384, !value.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) else { return false } size += name.utf8.count + value.utf8.count + 4 } return size <= 65536 } }