Files
SpotiFLAC-Mobile/rust_backend/crates/extensions/src/manifest.rs
T
zarzet aa99726439 feat(backend): migrate production backend to Rust
Replace the Go backend with the Rust workspace and route Android/iOS through
UniFFI bindings. Include the migrated extension runtime, network, providers,
media metadata, downloads and library operations, with version 5.0.0+147.

Remove Go sources, adapters, native build selection and CI dependencies.
Build Rust unconditionally and lock the iOS Rust pod using a relative path.
Retain legacy source and migration evidence in a local ignored archive.

Validation: Android Kotlin compile and 53 native tests; Swift Rust-branch
equivalence and syntax; CocoaPods install; workflow, shell, Ruby, plist and
diff checks. Reuse the preceding 100 Rust tests, fmt/Clippy and five-ABI
build checkpoint; no new APK or iOS application build for this cleanup.

Known follow-up: URL/URLSearchParams globals are missing from the Rust JS
runtime. A controlled extension replay confirms a URL-resolution regression;
this commit does not fix that runtime gap.
2026-09-14 22:58:27 +07:00

583 lines
22 KiB
Rust

//! The installed extension manifest contract, independent of JavaScript execution.
use crate::storage::valid_extension_id;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value};
use spotiflac_network::url::UrlParts;
trait GoNull {
fn set_null(&mut self);
}
impl GoNull for String {
fn set_null(&mut self) {}
}
impl GoNull for bool {
fn set_null(&mut self) {}
}
impl GoNull for isize {
fn set_null(&mut self) {}
}
impl<T> GoNull for Vec<T> {
fn set_null(&mut self) {
self.clear();
}
}
impl<T> GoNull for Option<T> {
fn set_null(&mut self) {
*self = None;
}
}
impl GoNull for Map<String, Value> {
fn set_null(&mut self) {
self.clear();
}
}
impl GoNull for Value {
fn set_null(&mut self) {
*self = Value::Null;
}
}
fn is_false(value: &bool) -> bool {
!value
}
fn is_zero(value: &isize) -> bool {
*value == 0
}
// Go treats missing and null scalar fields as their zero values. Keeping wire
// names explicit also prevents Rust naming changes from changing mobile JSON.
macro_rules! manifest_struct {
($name:ident { $($(#[$attr:meta])* $field:ident: $ty:ty => $wire:literal),* $(,)? }) => {
#[derive(Clone, Debug, Default, Serialize)]
pub struct $name {
$(
$(#[$attr])*
#[serde(rename = $wire)]
pub $field: $ty,
)*
}
impl GoNull for $name { fn set_null(&mut self) {} }
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = $name;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a manifest object")
}
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok($name::default())
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
let mut result = $name::default();
while let Some(key) = map.next_key::<String>()? {
$(
if key.eq_ignore_ascii_case($wire) {
match map.next_value::<Option<$ty>>()? {
Some(value) => result.$field = value,
None => result.$field.set_null(),
}
continue;
}
)*
map.next_value::<serde::de::IgnoredAny>()?;
}
Ok(result)
}
}
deserializer.deserialize_any(Visitor)
}
}
};
}
manifest_struct!(ExtensionPermissions {
network: Option<Vec<String>> => "network",
storage: bool => "storage",
file: bool => "file",
#[serde(skip_serializing_if = "is_false")]
allow_http: bool => "allowHttp",
});
manifest_struct!(ExtensionSetting {
key: String => "key", kind: String => "type", label: String => "label",
#[serde(skip_serializing_if = "String::is_empty")]
description: String => "description",
#[serde(skip_serializing_if = "is_false")]
required: bool => "required",
#[serde(skip_serializing_if = "is_false")]
secret: bool => "secret",
#[serde(skip_serializing_if = "Value::is_null")]
default: Value => "default",
#[serde(skip_serializing_if = "Vec::is_empty")]
options: Vec<String> => "options",
#[serde(skip_serializing_if = "String::is_empty")]
action: String => "action",
});
manifest_struct!(QualitySpecificSetting {
key: String => "key", kind: String => "type", label: String => "label",
#[serde(skip_serializing_if = "String::is_empty")]
description: String => "description",
#[serde(skip_serializing_if = "is_false")]
required: bool => "required",
#[serde(skip_serializing_if = "is_false")]
secret: bool => "secret",
#[serde(skip_serializing_if = "Value::is_null")]
default: Value => "default",
#[serde(skip_serializing_if = "Vec::is_empty")]
options: Vec<String> => "options",
});
manifest_struct!(QualityOption {
id: String => "id",
#[serde(skip_serializing_if = "String::is_empty")]
kind: String => "kind",
label: String => "label", description: String => "description",
#[serde(skip_serializing_if = "Vec::is_empty")]
settings: Vec<QualitySpecificSetting> => "settings",
});
manifest_struct!(SearchFilter {
id: String => "id",
#[serde(skip_serializing_if = "String::is_empty")]
label: String => "label",
#[serde(skip_serializing_if = "String::is_empty")]
icon: String => "icon",
});
manifest_struct!(SearchBehavior {
enabled: bool => "enabled",
#[serde(skip_serializing_if = "String::is_empty")]
placeholder: String => "placeholder",
#[serde(skip_serializing_if = "is_false")]
primary: bool => "primary",
#[serde(skip_serializing_if = "String::is_empty")]
icon: String => "icon",
#[serde(skip_serializing_if = "String::is_empty")]
thumbnail_ratio: String => "thumbnailRatio",
#[serde(skip_serializing_if = "is_zero")]
thumbnail_width: isize => "thumbnailWidth",
#[serde(skip_serializing_if = "is_zero")]
thumbnail_height: isize => "thumbnailHeight",
#[serde(skip_serializing_if = "Vec::is_empty")]
filters: Vec<SearchFilter> => "filters",
});
manifest_struct!(UrlHandler {
enabled: bool => "enabled",
#[serde(skip_serializing_if = "Vec::is_empty")]
patterns: Vec<String> => "patterns",
});
manifest_struct!(TrackMatching {
custom_matching: bool => "customMatching",
#[serde(skip_serializing_if = "String::is_empty")]
strategy: String => "strategy",
#[serde(skip_serializing_if = "is_zero")]
duration_tolerance: isize => "durationTolerance",
});
manifest_struct!(PostProcessingHook {
id: String => "id", name: String => "name",
#[serde(skip_serializing_if = "String::is_empty")]
description: String => "description",
#[serde(skip_serializing_if = "is_false")]
default_enabled: bool => "defaultEnabled",
#[serde(skip_serializing_if = "Vec::is_empty")]
supported_formats: Vec<String> => "supportedFormats",
});
manifest_struct!(PostProcessing {
enabled: bool => "enabled",
#[serde(skip_serializing_if = "Vec::is_empty")]
hooks: Vec<PostProcessingHook> => "hooks",
});
manifest_struct!(HealthCheck {
id: String => "id",
#[serde(skip_serializing_if = "String::is_empty")]
label: String => "label",
url: String => "url",
#[serde(skip_serializing_if = "String::is_empty")]
method: String => "method",
#[serde(skip_serializing_if = "String::is_empty")]
service_key: String => "serviceKey",
#[serde(skip_serializing_if = "is_zero")]
timeout_ms: isize => "timeoutMs",
#[serde(skip_serializing_if = "is_zero")]
cache_ttl_seconds: isize => "cacheTtlSeconds",
#[serde(skip_serializing_if = "is_false")]
required: bool => "required",
});
manifest_struct!(SignedSessionEndpoints {
#[serde(skip_serializing_if = "String::is_empty")]
bootstrap: String => "bootstrap",
#[serde(skip_serializing_if = "String::is_empty")]
challenge: String => "challenge",
#[serde(skip_serializing_if = "String::is_empty")]
exchange: String => "exchange",
#[serde(skip_serializing_if = "String::is_empty")]
refresh: String => "refresh",
});
manifest_struct!(SignedSession {
namespace: String => "namespace", base_url: String => "baseUrl",
#[serde(skip_serializing_if = "String::is_empty")]
app_version: String => "appVersion",
#[serde(skip_serializing_if = "String::is_empty")]
platform: String => "platform",
#[serde(skip_serializing_if = "String::is_empty")]
callback_url: String => "callbackUrl",
#[serde(skip_serializing_if = "String::is_empty")]
scheme_label: String => "schemeLabel",
#[serde(skip_serializing_if = "String::is_empty")]
header_prefix: String => "headerPrefix",
#[serde(skip_serializing_if = "is_zero")]
time_window_seconds: isize => "timeWindowSeconds",
endpoints: SignedSessionEndpoints => "endpoints",
});
manifest_struct!(ExtensionManifest {
name: String => "name", display_name: String => "displayName",
version: String => "version", description: String => "description",
#[serde(skip_serializing_if = "String::is_empty")]
homepage: String => "homepage",
#[serde(skip_serializing_if = "String::is_empty")]
icon: String => "icon",
types: Vec<String> => "type",
permissions: ExtensionPermissions => "permissions",
#[serde(skip_serializing_if = "Vec::is_empty")]
settings: Vec<ExtensionSetting> => "settings",
#[serde(skip_serializing_if = "Vec::is_empty")]
quality_options: Vec<QualityOption> => "qualityOptions",
#[serde(skip_serializing_if = "String::is_empty")]
min_app_version: String => "minAppVersion",
#[serde(skip_serializing_if = "is_false")]
skip_metadata_enrichment: bool => "skipMetadataEnrichment",
#[serde(skip_serializing_if = "is_false")]
skip_lyrics: bool => "skipLyrics",
#[serde(skip_serializing_if = "is_false")]
stop_provider_fallback: bool => "stopProviderFallback",
#[serde(skip_serializing_if = "is_false")]
skip_built_in_fallback: bool => "skipBuiltInFallback",
#[serde(skip_serializing_if = "Option::is_none")]
search_behavior: Option<SearchBehavior> => "searchBehavior",
#[serde(skip_serializing_if = "Option::is_none")]
url_handler: Option<UrlHandler> => "urlHandler",
#[serde(skip_serializing_if = "Option::is_none")]
track_matching: Option<TrackMatching> => "trackMatching",
#[serde(skip_serializing_if = "Option::is_none")]
post_processing: Option<PostProcessing> => "postProcessing",
#[serde(skip_serializing_if = "Vec::is_empty")]
service_health: Vec<HealthCheck> => "serviceHealth",
#[serde(skip_serializing_if = "Option::is_none")]
signed_session: Option<SignedSession> => "signedSession",
#[serde(skip_serializing_if = "Vec::is_empty")]
required_runtime_features: Vec<String> => "requiredRuntimeFeatures",
#[serde(skip_serializing_if = "Map::is_empty")]
capabilities: Map<String, Value> => "capabilities",
});
#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
#[error("failed to parse manifest JSON: {0}")]
Json(#[from] serde_json::Error),
#[error("manifest validation error: {field} - {message}")]
Validation { field: String, message: String },
}
fn invalid(field: impl Into<String>, message: impl Into<String>) -> ManifestError {
ManifestError::Validation {
field: field.into(),
message: message.into(),
}
}
fn extension_quality_kind(option: &QualityOption, manifest: Option<&ExtensionManifest>) -> String {
let kind = option.kind.trim().to_ascii_lowercase();
if matches!(kind.as_str(), "lossless" | "lossy" | "spatial") {
return kind;
}
// Installed manifests from before the kind field was introduced need the
// same ID/label inference as the Go backend. Descriptions are deliberately
// excluded because they commonly mention fallback formats.
let token = option.id.trim().to_ascii_lowercase();
let text = format!("{token} {}", option.label.to_ascii_lowercase());
if text.contains("atmos")
|| text.contains("dolby")
|| text.contains("surround")
|| matches!(token.as_str(), "ac4" | "ac-4" | "eac3" | "e-ac-3" | "ec-3")
{
return "spatial".to_owned();
}
if text.contains("lossless")
|| text.contains("flac")
|| text.contains("alac")
|| text.contains("24-bit")
|| text.contains("16-bit")
|| token == "hi_res"
{
return "lossless".to_owned();
}
if token == "high"
|| token == "low"
|| text.contains("mp3")
|| text.contains("aac")
|| text.contains("opus")
|| text.contains("vorbis")
{
return "lossy".to_owned();
}
if matches!(token.as_str(), "best" | "default" | "")
&& let Some(tier) = manifest
.and_then(|value| value.capabilities.get("downloadFallbackTier"))
.and_then(Value::as_str)
{
match tier.trim().to_ascii_lowercase().as_str() {
"hi_res" | "lossless" => return "lossless".to_owned(),
"low_res" => return "lossy".to_owned(),
_ => {}
}
}
String::new()
}
impl ExtensionManifest {
pub fn parse(json: &str) -> Result<Self, ManifestError> {
let manifest: Self = serde_json::from_str(json)?;
manifest.validate()?;
Ok(manifest)
}
pub fn validate(&self) -> Result<(), ManifestError> {
if self.name.trim().is_empty() {
return Err(invalid("name", "name is required"));
}
if !valid_extension_id(&self.name) {
return Err(invalid(
"name",
"name must be a lowercase extension ID containing only letters, numbers, '.', '_' or '-'",
));
}
if self.version.trim().is_empty() {
return Err(invalid("version", "version is required"));
}
if self.description.trim().is_empty() {
return Err(invalid("description", "description is required"));
}
if self.types.is_empty() {
return Err(invalid("type", "at least one type is required"));
}
for kind in &self.types {
if !matches!(
kind.as_str(),
"metadata_provider" | "download_provider" | "lyrics_provider"
) {
return Err(invalid(
"type",
format!(
"invalid extension type: {kind} (must be 'metadata_provider', 'download_provider', or 'lyrics_provider')"
),
));
}
}
for (index, setting) in self.settings.iter().enumerate() {
let failure = if setting.key.trim().is_empty() {
Some(("key", "setting key is required"))
} else if setting.kind.is_empty() {
Some(("type", "setting type is required"))
} else if setting.kind == "select" && setting.options.is_empty() {
Some(("options", "select type requires options"))
} else if setting.kind == "button" && setting.action.is_empty() {
Some(("action", "button type requires action (JS function name)"))
} else {
None
};
if let Some((field, message)) = failure {
return Err(invalid(format!("settings[{index}].{field}"), message));
}
}
for (index, quality) in self.quality_options.iter().enumerate() {
if !matches!(quality.kind.as_str(), "" | "lossless" | "lossy" | "spatial") {
return Err(invalid(
format!("qualityOptions[{index}].kind"),
"quality kind must be lossless, lossy, or spatial",
));
}
}
for (index, check) in self.service_health.iter().enumerate() {
let failure = if check.id.trim().is_empty() {
Some(("id", "health check id is required"))
} else if check.url.trim().is_empty() {
Some(("url", "health check url is required"))
} else if !matches!(
check.method.trim().to_uppercase().as_str(),
"" | "GET" | "HEAD"
) {
Some(("method", "health check method must be GET or HEAD"))
} else {
None
};
if let Some((field, message)) = failure {
return Err(invalid(format!("serviceHealth[{index}].{field}"), message));
}
}
if let Some(session) = &self.signed_session {
if !self.permissions.storage {
return Err(invalid(
"permissions.storage",
"signedSession requires storage permission",
));
}
if session.namespace.trim().is_empty() {
return Err(invalid("signedSession.namespace", "namespace is required"));
}
let base = session.base_url.trim();
if base.is_empty() {
return Err(invalid("signedSession.baseUrl", "baseUrl is required"));
}
if !base.to_lowercase().starts_with("https://") {
return Err(invalid("signedSession.baseUrl", "baseUrl must use https"));
}
let parsed = UrlParts::parse(base)
.filter(|url| !url.hostname.is_empty())
.ok_or_else(|| invalid("signedSession.baseUrl", "baseUrl is invalid"))?;
if !self.is_domain_allowed(&parsed.hostname) {
return Err(invalid(
"signedSession.baseUrl",
"baseUrl host must be listed in permissions.network",
));
}
}
if self.has_capability("rawFfmpeg") && !self.permissions.file {
return Err(invalid(
"permissions.file",
"rawFfmpeg capability requires file permission",
));
}
crate::transfer_policy::validate(&self.capabilities)
.map_err(|message| invalid("capabilities.downloadTransfer", message))?;
Ok(())
}
pub fn has_capability(&self, name: &str) -> bool {
self.capabilities.get(name) == Some(&Value::Bool(true))
}
pub fn find_quality(&self, requested: &str) -> Option<&QualityOption> {
let requested = requested.trim();
if requested.is_empty() {
return None;
}
self.quality_options
.iter()
.find(|option| option.id.trim().eq_ignore_ascii_case(requested))
}
pub fn resolve_download_quality(
&self,
requested: &str,
source: Option<&ExtensionManifest>,
) -> Result<String, String> {
let requested = requested.trim();
if self.quality_options.is_empty() {
return Ok(requested.to_owned());
}
let source_quality = source.and_then(|manifest| manifest.find_quality(requested));
let source_option = source_quality.cloned().unwrap_or_else(|| QualityOption {
id: requested.to_owned(),
..QualityOption::default()
});
let kind = extension_quality_kind(&source_option, source);
if let Some(exact) = self.find_quality(requested) {
let target_kind = extension_quality_kind(exact, Some(self));
if source.is_some_and(|manifest| manifest.name == self.name)
|| (!kind.is_empty() && kind == target_kind)
|| (kind.is_empty() && target_kind != "spatial")
{
return Ok(exact.id.trim().to_owned());
}
}
let kind = if kind.is_empty() {
"lossless"
} else {
kind.as_str()
};
let allowed_kinds: Vec<&str> = if matches!(kind, "spatial" | "lossy") {
vec![kind, "lossless"]
} else {
vec![kind]
};
for allowed in allowed_kinds {
if let Some(candidate) = self.quality_options.iter().find(|candidate| {
let id = candidate.id.trim();
!id.is_empty() && extension_quality_kind(candidate, Some(self)) == *allowed
}) {
return Ok(candidate.id.trim().to_owned());
}
}
Err(format!(
"provider {} has no compatible {} quality for {:?}",
self.name, kind, requested
))
}
pub fn has_type(&self, kind: &str) -> bool {
self.types.iter().any(|value| value == kind)
}
pub fn stops_provider_fallback(&self) -> bool {
self.stop_provider_fallback || self.skip_built_in_fallback
}
pub fn is_domain_allowed(&self, domain: &str) -> bool {
let domain = domain.trim().to_lowercase();
self.permissions
.network
.as_deref()
.unwrap_or_default()
.iter()
.any(|allowed| {
let allowed = allowed.trim().to_lowercase();
allowed == domain || (allowed.starts_with("*.") && domain.ends_with(&allowed[1..]))
})
}
pub fn matches_url(&self, url: &str) -> bool {
let Some(handler) = &self.url_handler else {
return false;
};
if !handler.enabled {
return false;
}
let url = url.trim().to_lowercase();
let parsed = UrlParts::parse(&url);
handler.patterns.iter().any(|pattern| {
let pattern = pattern.trim().to_lowercase();
if pattern.is_empty() {
return false;
}
if !pattern.contains('/') && pattern.ends_with(':') {
return url.starts_with(&pattern);
}
let Some(parsed) = parsed.as_ref().filter(|url| !url.hostname.is_empty()) else {
return false;
};
let pattern = pattern
.split_once("://")
.map_or(pattern.as_str(), |(_, rest)| rest);
let (host, path) = pattern.split_once('/').unwrap_or((pattern, ""));
!host.is_empty()
&& (parsed.hostname == host || parsed.hostname.ends_with(&format!(".{host}")))
&& (path.is_empty() || parsed.path.starts_with(format!("/{path}").as_bytes()))
})
}
}