Files
terebi/Sources/App/ResolvedSource.swift
T

123 lines
5.3 KiB
Swift

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 headers: String {
headerFields.sorted { $0.key < $1.key }.map { "\($0.key): \($0.value)\r\n" }.joined()
}
fileprivate var validatedHeaders: [String: String] { headerFields }
private enum CodingKeys: String, CodingKey {
case url, format, language, label
case isDefault = "default"
case headerFields = "headers"
}
}
struct ResolvedSource: Sendable {
let url: String
let headers: String
let identity: String
let title: String
let subtitles: [ResolvedSubtitle]
private 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)
let id = document.identity
guard document.version == 1, id.provider == "miruro",
!id.media_id.isEmpty, id.media_id.utf8.count <= 512,
id.media_id.utf8.allSatisfy({ isIDByte($0) }),
id.episode > 0, id.episode <= Int(Int32.max),
validURL(document.url), validHeaders(document.headers),
validText(document.title ?? "", limit: 4096),
document.subtitles.count <= 32,
document.subtitles.filter({ $0.isDefault }).count <= 1 else { throw InvalidSource() }
for subtitle in document.subtitles {
guard validURL(subtitle.url), subtitle.format == "vtt",
validText(subtitle.language, limit: 128), !subtitle.language.isEmpty,
validText(subtitle.label, limit: 4096), !subtitle.label.isEmpty,
validHeaders(subtitle.validatedHeaders) 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: "miruro:\(id.media_id):\(id.episode)", title: document.title ?? "Miruro episode \(id.episode)",
subtitles: document.subtitles)
} catch { throw InvalidSource() }
}
private static func isIDByte(_ byte: UInt8) -> Bool {
(65...90).contains(byte) || (97...122).contains(byte) ||
(48...57).contains(byte) || byte == 45 || byte == 95
}
private static func validText(_ text: String, limit: Int) -> Bool {
text.utf8.count <= limit && !text.unicodeScalars.contains { $0.value < 32 || $0.value == 127 }
}
private 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!)
}
private static func validHeaders(_ headers: [String: String]) -> Bool {
guard headers.count <= 64 else { return false }
var names = Set<String>()
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
}
}