fix: validate AniList reports against version two schema

This commit is contained in:
2026-10-25 15:55:49 +01:00
parent ab61d83c69
commit 6463d47ed8
3 changed files with 28 additions and 11 deletions
+20 -8
View File
@@ -37,25 +37,37 @@ static NSString *nativePath(const std::string &path) {
return [NSString stringWithUTF8String:path.c_str()]; return [NSString stringWithUTF8String:path.c_str()];
} }
static NSArray<NSDictionary *> *records(const std::string &path) { static NSArray<NSDictionary *> *records(const std::string &path, int version = 1) {
NSString *text = [NSString stringWithContentsOfFile:nativePath(path) NSString *text = [NSString stringWithContentsOfFile:nativePath(path)
encoding:NSUTF8StringEncoding error:nullptr]; encoding:NSUTF8StringEncoding error:nullptr];
require(text != nil && [text hasSuffix:@"\n"], "finished JSONL has a complete final line"); require(text != nil && [text hasSuffix:@"\n"], "finished JSONL has a complete final line");
NSMutableArray<NSDictionary *> *result = [NSMutableArray array]; NSMutableArray<NSDictionary *> *result = [NSMutableArray array];
NSArray<NSString *> *lines = [text componentsSeparatedByString:@"\n"]; NSArray<NSString *> *lines = [text componentsSeparatedByString:@"\n"];
NSSet *keys = [NSSet setWithArray:@[@"version", @"event", @"media_url", @"position_seconds", NSSet *keys = [NSSet setWithArray:@[@"version", @"event", version == 2 ? @"media_identity" : @"media_url",
@"duration_seconds", @"completion_eligible"]]; @"position_seconds", @"duration_seconds", @"completion_eligible"]];
for (NSUInteger i = 0; i + 1 < lines.count; ++i) { for (NSUInteger i = 0; i + 1 < lines.count; ++i) {
id object = [NSJSONSerialization JSONObjectWithData:[lines[i] dataUsingEncoding:NSUTF8StringEncoding] id object = [NSJSONSerialization JSONObjectWithData:[lines[i] dataUsingEncoding:NSUTF8StringEncoding]
options:0 error:nullptr]; options:0 error:nullptr];
require([object isKindOfClass:[NSDictionary class]], "consumer decodes every JSONL record"); require([object isKindOfClass:[NSDictionary class]], "consumer decodes every JSONL record");
NSDictionary *row = object; NSDictionary *row = object;
require([[NSSet setWithArray:row.allKeys] isEqual:keys], "records have exactly the six specified fields"); require([[NSSet setWithArray:row.allKeys] isEqual:keys], "records have exactly the six specified fields");
require([row[@"version"] isKindOfClass:[NSNumber class]] && [row[@"version"] intValue] == 1, require([row[@"version"] isKindOfClass:[NSNumber class]] && [row[@"version"] intValue] == version,
"schema version is numeric one"); "schema version matches the expected source contract");
require([@[@"progress", @"ended", @"closed"] containsObject:row[@"event"]], "known event type"); require([@[@"progress", @"ended", @"closed"] containsObject:row[@"event"]], "known event type");
require([row[@"media_url"] isKindOfClass:[NSString class]] && if (version == 2) {
ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "HTTP(S) consumer identity"); NSDictionary *identity = row[@"media_identity"];
require([identity isKindOfClass:[NSDictionary class]] &&
[[NSSet setWithArray:identity.allKeys] isEqual:
[NSSet setWithArray:@[@"provider", @"media_id", @"episode"]]] &&
[identity[@"provider"] isKindOfClass:[NSString class]] &&
[identity[@"media_id"] isKindOfClass:[NSString class]] &&
[identity[@"episode"] isKindOfClass:[NSNumber class]], "structured episode consumer identity");
NSString *key = [NSString stringWithFormat:@"%@:%@:%@", identity[@"provider"], identity[@"media_id"], identity[@"episode"]];
require(ProgressReporter::validMediaIdentity(key.UTF8String), "canonical episode consumer identity");
} else {
require([row[@"media_url"] isKindOfClass:[NSString class]] &&
ProgressReporter::validMediaURL([row[@"media_url"] UTF8String]), "HTTP(S) consumer identity");
}
require([row[@"position_seconds"] isKindOfClass:[NSNumber class]] && require([row[@"position_seconds"] isKindOfClass:[NSNumber class]] &&
std::isfinite([row[@"position_seconds"] doubleValue]) && std::isfinite([row[@"position_seconds"] doubleValue]) &&
[row[@"position_seconds"] doubleValue] >= 0, "position is finite and nonnegative"); [row[@"position_seconds"] doubleValue] >= 0, "position is finite and nonnegative");
@@ -316,7 +328,7 @@ int main(int argc, char **argv) {
reporter.finish(); reporter.finish();
require(reporter.error().empty(), "AniList progress drains successfully"); require(reporter.error().empty(), "AniList progress drains successfully");
} }
rows = records(anilistPath); rows = records(anilistPath, 2);
require(rows.count == 2, "AniList initial and closed snapshots"); require(rows.count == 2, "AniList initial and closed snapshots");
for (NSDictionary *row in rows) { for (NSDictionary *row in rows) {
require([row[@"version"] intValue] == 2 && row[@"media_url"] == nil && require([row[@"version"] intValue] == 2 && row[@"media_url"] == nil &&
+5 -1
View File
@@ -40,9 +40,13 @@ struct ZokoCheck {
try require(source.title == "AniList 1 · Episode 1 (sub)" && source.subtitles.count == 1 && source.subtitles[0].isDefault, try require(source.title == "AniList 1 · Episode 1 (sub)" && source.subtitles.count == 1 && source.subtitles[0].isDefault,
"honest fallback title and VTT metadata") "honest fallback title and VTT metadata")
try require(source.headers == ZokoResolver.referer && source.subtitles[0].headers == ZokoResolver.referer, "only fixed public Referer scoped to declared CDN") try require(source.headers == ZokoResolver.referer && source.subtitles[0].headers == ZokoResolver.referer, "only fixed public Referer scoped to declared CDN")
let semicolonless = try page(config).replacingOccurrences(of: "';</script>", with: "'</script>")
let deployedSource = try ZokoResolver.parse(page: semicolonless, mediaID: "1", episode: 1)
try require(deployedSource.identity == source.identity, "deployed semicolonless static assignment is accepted")
let missing = "<span>// Error</span><span>404</span><h1>Not <span>found.</span></h1><p>We couldn't find anything to play here.</p>" let missing = "<span>// Error</span><span>404</span><h1>Not <span>found.</span></h1><p>We couldn't find anything to play here.</p>"
try expect(.unavailable) { _ = try ZokoResolver.parse(page: missing, mediaID: "1", episode: 27) } try expect(.unavailable) { _ = try ZokoResolver.parse(page: missing, mediaID: "1", episode: 27) }
for protected in ["<title>Just a moment</title>", "<script src='/cdn-cgi/challenge-platform/x'></script>"] { for protected in ["<title>Just a moment</title>", "<script src='/cdn-cgi/challenge-platform/x'></script>",
"<h1>Sorry, you have been blocked</h1>"] {
try expect(.protection) { _ = try ZokoResolver.parse(page: protected, mediaID: "1", episode: 1) } try expect(.protection) { _ = try ZokoResolver.parse(page: protected, mediaID: "1", episode: 1) }
} }
for malformed in ["<html></html>", "window.__P='broken';", try page(config) + page(config), String(repeating: "x", count: ZokoResolver.bodyLimit + 1)] { for malformed in ["<html></html>", "window.__P='broken';", try page(config) + page(config), String(repeating: "x", count: ZokoResolver.bodyLimit + 1)] {
+3 -2
View File
@@ -76,7 +76,7 @@ enum ZokoResolver {
static func parse(page: String, mediaID: String, episode: Int) throws -> ResolvedSource { static func parse(page: String, mediaID: String, episode: Int) throws -> ResolvedSource {
guard page.utf8.count <= bodyLimit, ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed } guard page.utf8.count <= bodyLimit, ResolvedSource.validAniListIdentity(mediaID: mediaID, episode: episode) else { throw ZokoError.malformed }
let pattern = #"window\.__P\s*=\s*[\"']([A-Za-z0-9+/=]+)[\"']\s*;"# let pattern = #"window\.__P\s*=\s*[\"']([A-Za-z0-9+/=]+)[\"']\s*;?\s*</script\s*>"#
let regex = try NSRegularExpression(pattern: pattern) let regex = try NSRegularExpression(pattern: pattern)
let matches = regex.matches(in: page, range: NSRange(page.startIndex..., in: page)) let matches = regex.matches(in: page, range: NSRange(page.startIndex..., in: page))
guard matches.count == 1, let match = matches.first, let range = Range(match.range(at: 1), in: page) else { guard matches.count == 1, let match = matches.first, let range = Range(match.range(at: 1), in: page) else {
@@ -84,7 +84,8 @@ enum ZokoResolver {
let lower = page.lowercased() let lower = page.lowercased()
let visible = lower.replacingOccurrences(of: "<[^>]*>", with: " ", options: .regularExpression) let visible = lower.replacingOccurrences(of: "<[^>]*>", with: " ", options: .regularExpression)
if visible.contains("error"), visible.contains("404"), visible.contains("couldn't find anything to play here") { throw ZokoError.unavailable } if visible.contains("error"), visible.contains("404"), visible.contains("couldn't find anything to play here") { throw ZokoError.unavailable }
if lower.contains("cf-chl-") || lower.contains("challenge-platform") || lower.contains("just a moment") || lower.contains("captcha") { throw ZokoError.protection } if lower.contains("cf-chl-") || lower.contains("challenge-platform") || lower.contains("just a moment") ||
lower.contains("captcha") || lower.contains("sorry, you have been blocked") { throw ZokoError.protection }
throw ZokoError.malformed throw ZokoError.malformed
} }
guard let encoded = Data(base64Encoded: String(page[range])), !encoded.isEmpty else { throw ZokoError.malformed } guard let encoded = Data(base64Encoded: String(page[range])), !encoded.isEmpty else { throw ZokoError.malformed }