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.
This commit is contained in:
zarzet committed 2026-09-14 22:58:27 +07:00
1 parent 1da0da18a6
commit aa99726439
452 files changed
+64417 -65088

No files matched your search

+354
View File
@@ -0,0 +1,354 @@
//! Shared OAuth state and one-time callback ownership for managed runtimes.
use aes_gcm::aead::{OsRng, rand_core::RngCore};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::Serialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use zeroize::Zeroizing;
mod client;
pub use client::ExtensionAuth;
const SECOND: i128 = 1_000_000_000;
const PENDING_TTL: i128 = 180 * SECOND;
pub trait AuthClock: Send + Sync {
fn now_nanos(&self) -> i128;
}
struct SystemClock;
impl AuthClock for SystemClock {
fn now_nanos(&self) -> i128 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or_else(
|error| -(error.duration().as_nanos() as i128),
|time| time.as_nanos() as i128,
)
}
}
#[derive(Clone, Serialize)]
pub struct PendingAuthRequest {
pub extension_id: String,
pub auth_url: String,
pub callback_url: String,
#[serde(skip)]
pub state: String,
#[serde(skip)]
pub created_at: i128,
}
#[derive(Default)]
struct Record {
code: Zeroizing<String>,
access_token: Zeroizing<String>,
refresh_token: Zeroizing<String>,
verifier: Zeroizing<String>,
challenge: String,
authenticated: bool,
expires_at: Option<i128>,
}
#[derive(Default)]
struct State {
records: BTreeMap<String, Record>,
generations: BTreeMap<String, u64>,
pending: BTreeMap<String, PendingAuthRequest>,
owners: BTreeMap<String, String>,
closed: bool,
}
pub struct AuthRegistry {
state: Mutex<State>,
clock: Arc<dyn AuthClock>,
}
impl Default for AuthRegistry {
fn default() -> Self {
Self::with_clock(Arc::new(SystemClock))
}
}
impl AuthRegistry {
pub fn with_clock(clock: Arc<dyn AuthClock>) -> Self {
Self {
state: Mutex::default(),
clock,
}
}
pub fn now(&self) -> i128 {
self.clock.now_nanos()
}
fn edit<T>(&self, id: &str, edit: impl FnOnce(&mut Record) -> T) -> Result<T, String> {
let mut state = self.state.lock().expect("auth registry lock");
if state.closed {
return Err("extension environment closed".into());
}
let generation = state.generations.entry(id.to_owned()).or_default();
*generation = generation.wrapping_add(1);
Ok(edit(state.records.entry(id.to_owned()).or_default()))
}
pub fn set_code(&self, id: &str, code: &str) -> Result<(), String> {
self.edit(id, |record| record.code = Zeroizing::new(code.to_owned()))
}
pub fn set_tokens(
&self,
id: &str,
access: &str,
refresh: &str,
expires_in: i64,
) -> Result<(), String> {
let expires = (expires_in > 0)
.then(|| self.now() + i128::from(expires_in.wrapping_mul(1_000_000_000)));
self.edit(id, |record| {
record.access_token = Zeroizing::new(access.to_owned());
record.refresh_token = Zeroizing::new(refresh.to_owned());
record.authenticated = !access.is_empty();
record.expires_at = expires;
})
}
pub fn code(&self, id: &str) -> Option<String> {
self.state
.lock()
.expect("auth registry lock")
.records
.get(id)
.filter(|record| !record.code.is_empty())
.map(|record| record.code.to_string())
}
pub fn authenticated(&self, id: &str) -> bool {
let now = self.now();
self.state
.lock()
.expect("auth registry lock")
.records
.get(id)
.is_some_and(|record| {
record.authenticated && record.expires_at.is_none_or(|expires| now <= expires)
})
}
pub fn tokens(&self, id: &str) -> Value {
let state = self.state.lock().expect("auth registry lock");
let Some(record) = state.records.get(id) else {
return json!({});
};
let mut result = json!({"access_token":record.access_token.as_str(),"refresh_token":record.refresh_token.as_str(),"is_authenticated":record.authenticated});
if let Some(expires) = record.expires_at {
result["expires_at"] = json!(expires.div_euclid(SECOND) as i64);
result["is_expired"] = json!(self.now() > expires);
}
result
}
pub fn clear(&self, id: &str) {
let mut state = self.state.lock().expect("auth registry lock");
if state.closed {
return;
}
state.records.remove(id);
let generation = state.generations.entry(id.to_owned()).or_default();
*generation = generation.wrapping_add(1);
Self::remove_pending(&mut state, id, self.now());
}
pub fn register_pending(&self, mut request: PendingAuthRequest) -> Result<(), String> {
if request.extension_id.trim().is_empty() {
return Err("extension id is required".into());
}
if request.state.is_empty() {
request.state = callback_state()?;
}
if request.created_at == 0 {
request.created_at = self.now();
}
let mut state = self.state.lock().expect("auth registry lock");
if state.closed {
return Err("extension environment closed".into());
}
if let Some(owner) = state
.owners
.get(&request.state)
.filter(|owner| **owner != request.extension_id)
{
let same = state.pending.get(owner).is_some_and(|previous| {
previous.state == request.state
&& previous.auth_url == request.auth_url
&& previous.callback_url == request.callback_url
&& previous.created_at == request.created_at
});
if !same {
return Err("callback state is already registered".into());
}
}
if state
.pending
.get(&request.extension_id)
.is_some_and(|previous| previous.state != request.state)
{
Self::remove_pending(&mut state, &request.extension_id, self.now());
}
state
.owners
.entry(request.state.clone())
.or_insert_with(|| request.extension_id.clone());
state.pending.insert(request.extension_id.clone(), request);
Ok(())
}
fn remove_pending(state: &mut State, id: &str, now: i128) {
let Some(request) = state.pending.remove(id) else {
return;
};
if state
.owners
.get(&request.state)
.is_none_or(|owner| owner != id)
{
return;
}
state.owners.remove(&request.state);
if let Some((id, _)) = state.pending.iter().find(|(_, candidate)| {
candidate.state == request.state && now - candidate.created_at < PENDING_TTL
}) {
state.owners.insert(request.state, id.clone());
}
}
pub fn clear_pending(&self, id: &str) {
Self::remove_pending(
&mut self.state.lock().expect("auth registry lock"),
id,
self.now(),
);
}
pub fn pending(&self, id: &str) -> Option<PendingAuthRequest> {
let mut state = self.state.lock().expect("auth registry lock");
if state
.pending
.get(id)
.is_some_and(|request| self.now() - request.created_at >= PENDING_TTL)
{
Self::remove_pending(&mut state, id, self.now());
}
state.pending.get(id).cloned()
}
pub(crate) fn has_fresh_challenge(&self, id: &str) -> bool {
self.pending(id).is_some_and(|request| {
request.extension_id == id
&& !request.auth_url.trim().is_empty()
&& (0..PENDING_TTL).contains(&(self.now() - request.created_at))
})
}
pub fn all_pending(&self) -> Vec<PendingAuthRequest> {
self.state
.lock()
.expect("auth registry lock")
.pending
.values()
.cloned()
.collect()
}
pub fn resolve_callback(&self, nonce: &str, consume: bool) -> Result<String, String> {
let nonce = nonce.trim();
if nonce.is_empty() {
return Err("callback state is required".into());
}
let mut state = self.state.lock().expect("auth registry lock");
let owner = state.owners.get(nonce).cloned();
let valid = owner
.as_ref()
.and_then(|owner| state.pending.get(owner))
.is_some_and(|request| {
request.state == nonce && self.now() - request.created_at < PENDING_TTL
});
if !valid || consume {
state.owners.remove(nonce);
state.pending.retain(|_, request| request.state != nonce);
}
if valid {
Ok(owner.expect("validated callback owner"))
} else {
Err("callback state is invalid, expired, or already used".into())
}
}
pub fn shutdown(&self) {
let mut state = self.state.lock().expect("auth registry lock");
*state = State {
closed: true,
..State::default()
};
}
}
pub fn callback_state() -> Result<String, String> {
let mut random = Zeroizing::new([0_u8; 32]);
OsRng
.try_fill_bytes(random.as_mut())
.map_err(|error| format!("generate callback state: {error}"))?;
Ok(URL_SAFE_NO_PAD.encode(random.as_ref()))
}
pub fn pkce_verifier(length: usize) -> Result<String, String> {
let length = length.clamp(43, 128);
let mut random = Zeroizing::new(vec![0; length]);
OsRng
.try_fill_bytes(&mut random)
.map_err(|error| error.to_string())?;
let mut verifier = URL_SAFE_NO_PAD.encode(random.as_slice());
verifier.truncate(length);
Ok(verifier)
}
pub fn pkce_challenge(verifier: &str) -> String {
URL_SAFE_NO_PAD.encode(Sha256::digest(verifier))
}
#[cfg(test)]
mod tests {
use super::*;
struct Clock;
impl AuthClock for Clock {
fn now_nanos(&self) -> i128 {
1000 * SECOND
}
}
#[test]
fn verification_requires_own_nonempty_challenge_between_zero_and_180_seconds_old() {
for (age, url, expected) in [
(-1, "https://example.test/verify", false),
(0, "https://example.test/verify", true),
(179, "https://example.test/verify", true),
(180, "https://example.test/verify", false),
(0, " \n", false),
] {
let auth = AuthRegistry::with_clock(Arc::new(Clock));
auth.register_pending(PendingAuthRequest {
extension_id: "example.auth".into(),
auth_url: url.into(),
callback_url: "spotiflac://callback".into(),
state: "state".into(),
created_at: (1000 - age) * SECOND,
})
.unwrap();
assert_eq!(auth.has_fresh_challenge("example.auth"), expected);
assert!(!auth.has_fresh_challenge("example.other"));
}
}
}
@@ -0,0 +1,324 @@
use super::{AuthRegistry, PendingAuthRequest, callback_state, pkce_challenge, pkce_verifier};
use serde_json::{Map, Value, json};
use spotiflac_core::app_version::AppVersion;
use spotiflac_network::{HttpRequest, NetworkSession, query};
use std::collections::BTreeMap;
use std::sync::Arc;
use zeroize::Zeroizing;
pub struct ExtensionAuth {
id: String,
registry: Arc<AuthRegistry>,
network: Arc<NetworkSession>,
app_version: AppVersion,
}
impl ExtensionAuth {
pub fn new(
id: &str,
registry: Arc<AuthRegistry>,
network: Arc<NetworkSession>,
app_version: impl Into<AppVersion>,
) -> Self {
Self {
id: id.to_owned(),
registry,
network,
app_version: app_version.into(),
}
}
pub fn call(
&self,
method: &str,
arguments: &[Value],
expires_is_float: bool,
check: impl Fn() -> Result<(), String>,
) -> Result<Value, String> {
check()?;
let first = arguments.first().unwrap_or(&Value::Null);
match method {
"getAuthCode" => Ok(self
.registry
.code(&self.id)
.map_or(Value::Null, Value::String)),
"getTokens" => Ok(self.registry.tokens(&self.id)),
"isAuthenticated" => Ok(json!(self.registry.authenticated(&self.id))),
"clearAuth" => {
self.registry.clear(&self.id);
Ok(json!(true))
}
"setAuthCode" => {
if arguments.is_empty() {
return Ok(json!(false));
}
self.registry.edit(&self.id, |record| {
if let Some(code) = first.as_str() {
record.code = Zeroizing::new(code.to_owned());
} else if let Some(value) = first.as_object() {
if let Some(code) = value.get("code").and_then(Value::as_str) {
record.code = Zeroizing::new(code.to_owned());
}
if let Some(access) = value.get("access_token").and_then(Value::as_str) {
record.access_token = Zeroizing::new(access.to_owned());
record.authenticated = true;
}
if let Some(refresh) = value.get("refresh_token").and_then(Value::as_str) {
record.refresh_token = Zeroizing::new(refresh.to_owned());
}
if expires_is_float
&& let Some(expires) = value.get("expires_in").and_then(Value::as_f64)
{
record.expires_at = Some(
self.registry.now()
+ i128::from((expires as i64).wrapping_mul(1_000_000_000)),
);
}
}
})?;
Ok(json!(true))
}
"generatePKCE" => {
let length = first.as_u64().unwrap_or(64) as usize;
let verifier = pkce_verifier(length)?;
self.store_pkce(&verifier, false)?;
Ok(
json!({"verifier":verifier,"challenge":pkce_challenge(&verifier),"method":"S256"}),
)
}
"getPKCE" => {
let state = self.registry.state.lock().expect("auth registry lock");
Ok(state.records.get(&self.id).filter(|record| !record.verifier.is_empty())
.map_or_else(|| json!({}), |record| json!({"verifier":record.verifier.as_str(),"challenge":record.challenge,"method":"S256"})))
}
"openAuthUrl" => {
if arguments.is_empty() {
return Err("auth URL is required".into());
}
let mut url = self
.network
.validate_auth_url(first.as_str().unwrap_or(""), &check)?;
let nonce = callback_state()?;
let mut params = query::parse(&url.raw_query);
query::set(&mut params, "state", &nonce);
url.raw_query = query::encode(&params);
self.registry.register_pending(PendingAuthRequest {
extension_id: self.id.clone(),
auth_url: url.display_url(),
callback_url: arguments
.get(1)
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
state: nonce,
created_at: self.registry.now(),
})?;
self.registry.set_code(&self.id, "")?;
Ok(json!({"success":true,"message":"Auth URL will be opened by the app"}))
}
"startOAuthWithPKCE" | "exchangeCodeWithPKCE" => {
if arguments.is_empty() {
return Err("config object is required".into());
}
let config = first
.as_object()
.ok_or_else(|| "config must be an object".to_owned())?;
if method == "startOAuthWithPKCE" {
self.start(config, &check)
} else {
self.exchange(config, &check)
}
}
_ => Err("unknown auth method".into()),
}
}
fn store_pkce(&self, verifier: &str, clear_code: bool) -> Result<(), String> {
self.registry.edit(&self.id, |record| {
record.verifier = Zeroizing::new(verifier.to_owned());
record.challenge = pkce_challenge(verifier);
if clear_code {
record.code = Zeroizing::default();
}
})
}
fn start(
&self,
config: &Map<String, Value>,
check: &impl Fn() -> Result<(), String>,
) -> Result<Value, String> {
let auth_url = string(config, "authUrl");
let client = string(config, "clientId");
let redirect = string(config, "redirectUri");
if auth_url.is_empty() || client.is_empty() || redirect.is_empty() {
return Err("authUrl, clientId, and redirectUri are required".into());
}
let mut url = self.network.validate_auth_url(auth_url, check)?;
let verifier = Zeroizing::new(
pkce_verifier(64).map_err(|error| format!("failed to generate PKCE: {error}"))?,
);
let challenge = pkce_challenge(&verifier);
self.store_pkce(&verifier, true)?;
let mut params = query::parse(&url.raw_query);
for (key, value) in [
("client_id", client),
("redirect_uri", redirect),
("response_type", "code"),
("code_challenge", &challenge),
("code_challenge_method", "S256"),
] {
query::set(&mut params, key, value);
}
let scope = string(config, "scope");
if !scope.is_empty() {
query::set(&mut params, "scope", scope);
}
extra_params(config, &mut params);
let nonce =
callback_state().map_err(|error| format!("failed to generate OAuth state: {error}"))?;
query::set(&mut params, "state", &nonce);
url.raw_query = query::encode(&params);
let auth_url = url.display_url();
self.registry
.register_pending(PendingAuthRequest {
extension_id: self.id.clone(),
auth_url: auth_url.clone(),
callback_url: redirect.to_owned(),
state: nonce,
created_at: self.registry.now(),
})
.map_err(|error| format!("failed to register OAuth callback: {error}"))?;
Ok(
json!({"success":true,"authUrl":auth_url,"pkce":{"verifier":verifier.as_str(),"challenge":challenge,"method":"S256"}}),
)
}
fn exchange(
&self,
config: &Map<String, Value>,
check: &impl Fn() -> Result<(), String>,
) -> Result<Value, String> {
let token_url = string(config, "tokenUrl");
let client = string(config, "clientId");
let code = string(config, "code");
if token_url.is_empty() || client.is_empty() || code.is_empty() {
return Err("tokenUrl, clientId, and code are required".into());
}
let (verifier, generation) = {
let state = self.registry.state.lock().expect("auth registry lock");
let verifier = state
.records
.get(&self.id)
.map(|record| record.verifier.clone())
.unwrap_or_default();
(
verifier,
state.generations.get(&self.id).copied().unwrap_or(0),
)
};
if verifier.is_empty() {
return Err(
"no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first".into(),
);
}
self.network.validate_url(token_url)?;
let mut params = query::Query::new();
for (key, value) in [
("grant_type", "authorization_code"),
("client_id", client),
("code", code),
("code_verifier", &verifier),
] {
query::set(&mut params, key, value);
}
let redirect = string(config, "redirectUri");
if !redirect.is_empty() {
query::set(&mut params, "redirect_uri", redirect);
}
extra_params(config, &mut params);
let response = self.network.request(
HttpRequest {
url: token_url.to_owned(),
method: "POST".into(),
body: query::encode(&params),
headers: BTreeMap::from([(
"Content-Type".into(),
"application/x-www-form-urlencoded".into(),
)]),
default_json: false,
user_agent: self.app_version.user_agent(),
},
check,
)?;
let body = Zeroizing::new(response.body);
let token = match serde_json::from_slice::<Value>(&body) {
Ok(Value::Object(token)) => token,
Ok(Value::Null) => Map::new(),
result => {
let message = match result {
Err(error) => error.to_string(),
_ => "token response must be an object".into(),
};
return Ok(
json!({"success":false,"error":format!("failed to parse token response: {message}"),"body":crate::redact::preview(&body, 1000)}),
);
}
};
if let Some(error) = token.get("error").and_then(Value::as_str) {
return Ok(
json!({"success":false,"error":error,"error_description":string(&token,"error_description")}),
);
}
let access = string(&token, "access_token");
let refresh = string(&token, "refresh_token");
let expires = token
.get("expires_in")
.and_then(Value::as_f64)
.unwrap_or(0.0);
if access.is_empty() {
return Ok(
json!({"success":false,"error":"no access_token in response","body":crate::redact::preview(&body, 1000)}),
);
}
check()?;
let mut state = self.registry.state.lock().expect("auth registry lock");
if state.closed || state.generations.get(&self.id).copied().unwrap_or(0) != generation {
return Err("authentication state changed during token exchange".into());
}
let record = state.records.entry(self.id.clone()).or_default();
record.access_token = Zeroizing::new(access.to_owned());
record.refresh_token = Zeroizing::new(refresh.to_owned());
record.authenticated = true;
if expires > 0.0 {
record.expires_at = Some(
self.registry.now() + i128::from((expires as i64).wrapping_mul(1_000_000_000)),
);
}
record.verifier = Zeroizing::default();
record.challenge.clear();
let generation = state.generations.entry(self.id.clone()).or_default();
*generation = generation.wrapping_add(1);
let mut result = json!({"success":true,"access_token":access,"refresh_token":refresh,"token_type":token.get("token_type")});
if expires > 0.0 {
result["expires_in"] = json!(expires);
}
if let Some(scope) = token.get("scope").and_then(Value::as_str) {
result["scope"] = json!(scope);
}
Ok(result)
}
}
fn string<'a>(config: &'a Map<String, Value>, key: &str) -> &'a str {
config.get(key).and_then(Value::as_str).unwrap_or("")
}
// The JS bridge exports extraParams with Go's fmt formatting, rather than JSON.
fn extra_params(config: &Map<String, Value>, params: &mut query::Query) {
if let Some(extras) = config.get("extraParams").and_then(Value::as_object) {
for (key, value) in extras {
query::set(params, key, value.as_str().unwrap_or(""));
}
}
}
@@ -0,0 +1,330 @@
//! Root ownership for installed extensions and shared provider services.
mod cover;
mod downloads;
mod health;
mod library;
mod lyrics;
mod metadata;
mod musicbrainz;
mod provider_metadata;
mod reenrich;
mod share;
mod tags;
pub use lyrics::LyricsRequest;
pub use metadata::MetadataOptions;
use crate::RuntimeLimits;
use crate::environment::ExtensionEnvironment;
use crate::lyrics::InstalledLyricsFetcher;
use crate::manager::{ExtensionManager, ManagerError};
use spotiflac_core::lyrics::config::{self, FetchOptions};
use spotiflac_providers::deezer::DeezerClient;
use spotiflac_providers::lyrics::{LyricsService, builtin::BuiltinLyricsClient};
use spotiflac_providers::musicbrainz::MusicBrainzClient;
use spotiflac_providers::resolver::{PlatformResolverChain, availability::AvailabilityService};
use std::ops::Deref;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex};
/// Retained by the native owner, never by a VM or provider worker. Shutdown
/// finishes provider work and persistence before releasing directory locks.
pub struct Backend {
manager: Arc<ExtensionManager>,
lyrics: Arc<LyricsService>,
availability: Arc<AvailabilityService>,
deezer: Arc<DeezerClient>,
musicbrainz: MusicBrainzClient,
share_cache: Mutex<share::Cache>,
cover: cover::Cover,
library_cover_directory: Mutex<String>,
library_scan: library::ScanState,
prepared_downloads: Mutex<downloads::PreparedDownloads>,
health: Arc<health::Health>,
closed: AtomicBool,
operations: Mutex<usize>,
idle: Condvar,
lyrics_settings: Mutex<()>,
shutdown: Mutex<Option<Result<(), String>>>,
}
impl Backend {
pub fn new(
sources: &Path,
data: &Path,
master_key: &str,
app_version: &str,
limits: RuntimeLimits,
) -> Result<Self, ManagerError> {
let environment = ExtensionEnvironment::new(data, master_key, app_version)
.map_err(|error| ManagerError(error.to_string()))?;
Self::with_environment(sources, environment, limits, MetadataOptions::default())
}
/// Apply persisted settings before restoring caches that depend on them.
pub fn with_lyrics_settings(
sources: &Path,
data: &Path,
master_key: &str,
app_version: &str,
limits: RuntimeLimits,
providers_json: &str,
options_json: &str,
) -> Result<Self, ManagerError> {
let providers = config::decode_providers(providers_json)
.map_err(|error| ManagerError(error.to_string()))?;
let mut options = FetchOptions::default();
options
.update_json(options_json)
.map_err(|error| ManagerError(error.to_string()))?;
let environment = ExtensionEnvironment::new(data, master_key, app_version)
.map_err(|error| ManagerError(error.to_string()))?;
Self::with_environment_and_lyrics(
sources,
environment,
limits,
MetadataOptions::default(),
&providers,
options,
)
}
/// Transfer a native environment into this root. Provider configuration is
/// native-owned and all services reuse the environment's network/policy.
pub fn with_environment(
sources: &Path,
environment: ExtensionEnvironment,
limits: RuntimeLimits,
options: MetadataOptions,
) -> Result<Self, ManagerError> {
Self::with_environment_and_lyrics(
sources,
environment,
limits,
options,
&[],
FetchOptions::default(),
)
}
fn with_environment_and_lyrics(
sources: &Path,
environment: ExtensionEnvironment,
limits: RuntimeLimits,
options: MetadataOptions,
lyrics_providers: &[String],
lyrics_options: FetchOptions,
) -> Result<Self, ManagerError> {
let manager = Arc::new(ExtensionManager::with_environment(
sources,
environment,
limits,
)?);
let environment = manager.environment();
let network = environment
.network_service()
.map_err(|error| ManagerError(error.to_string()))?;
let deezer = Arc::new(
DeezerClient::with_endpoint(&network, &options.deezer_endpoint)
.map_err(|error| ManagerError(error.to_string()))?,
);
let musicbrainz = MusicBrainzClient::with_options(&network, options.musicbrainz)
.map_err(|error| ManagerError(error.to_string()))?;
let resolver = PlatformResolverChain::with_options(&network, options.resolver)
.map_err(|error| ManagerError(error.to_string()))?;
let availability = Arc::new(AvailabilityService::new(Arc::new(resolver), deezer.clone()));
let lyrics = Arc::new(LyricsService::new(Arc::new(InstalledLyricsFetcher::new(
&manager,
BuiltinLyricsClient::new(
&network,
environment.shared_app_version(),
availability.clone(),
),
))));
lyrics
.set_providers(lyrics_providers)
.and_then(|()| lyrics.set_options(lyrics_options))
.map_err(|error| ManagerError(error.to_string()))?;
lyrics
.set_persistence_path(&environment.data_directory().join(".lyrics_cache.json"))
.map_err(|error| ManagerError(error.to_string()))?;
environment.attach_lyrics(&lyrics);
let cover = cover::Cover::new(&network, environment.shared_app_version());
let health = Arc::new(health::Health::new(
&network,
environment.shared_app_version(),
));
Ok(Self {
manager,
lyrics,
availability,
deezer,
musicbrainz,
share_cache: Mutex::default(),
cover,
library_cover_directory: Mutex::default(),
library_scan: library::ScanState::default(),
prepared_downloads: Mutex::default(),
health,
closed: AtomicBool::new(false),
operations: Mutex::new(0),
idle: Condvar::new(),
lyrics_settings: Mutex::new(()),
shutdown: Mutex::new(None),
})
}
pub fn lyrics(&self) -> Arc<LyricsService> {
Arc::clone(&self.lyrics)
}
pub fn availability(&self) -> Arc<AvailabilityService> {
Arc::clone(&self.availability)
}
pub fn get_app_version(&self) -> Result<String, String> {
let _operation = self.enter()?;
self.manager
.environment()
.get_app_version()
.map_err(|error| error.to_string())
}
pub fn set_app_version(&self, version: &str) -> Result<(), String> {
let _operation = self.enter()?;
self.manager
.environment()
.set_app_version(version)
.map_err(|error| error.to_string())
}
pub fn get_extension_pending_auth_json(&self, id: &str) -> Result<String, String> {
let _operation = self.enter()?;
let id = id.trim();
if id.is_empty() {
return Ok(String::new());
}
let environment = self.manager.environment();
if let Some(pending) = environment
.pending_auth(id)
.map_err(|error| error.to_string())?
{
return serde_json::to_string(&pending).map_err(|error| error.to_string());
}
// A missing or expired challenge may require network preflight. Reuse
// cancellable provider work so owner shutdown interrupts that request.
self.metadata_provider_work(&|| self.check(), |lease| {
self.manager
.preflight_auth(id, lease)
.map(|_| "null".into())
})
.map_err(|error| error.to_string())?;
environment
.pending_auth(id)
.map_err(|error| error.to_string())?
.map(|pending| serde_json::to_string(&pending).map_err(|error| error.to_string()))
.transpose()
.map(Option::unwrap_or_default)
}
/// Drop disposable resources while active requests retain their owner and IO.
/// Memory pressure does not clear persisted lyrics, settings or credentials.
pub fn release_memory(&self, under_pressure: bool) -> Result<(), String> {
let _operation = self.enter()?;
self.manager
.release_idle_download_runtimes()
.map_err(|error| error.to_string())?;
self.manager
.environment()
.cleanup_connections()
.map_err(|error| error.to_string())?;
if under_pressure {
self.cover.clear();
self.lyrics
.drop_memory()
.map_err(|error| error.to_string())?;
self.health.clear_memory_cache();
}
Ok(())
}
pub fn shutdown_checked(&self) -> Result<(), String> {
let mut state = self.shutdown.lock().expect("backend shutdown lock");
if let Some(result) = state.as_ref() {
return result.clone();
}
self.closed.store(true, Ordering::Release);
self.health.shutdown();
self.musicbrainz.shutdown();
self.manager.environment().shared_app_version().close();
let result = self.lyrics.shutdown();
let mut operations = self.operations.lock().expect("backend operations lock");
while *operations != 0 {
operations = self.idle.wait(operations).expect("backend operations wait");
}
drop(operations);
self.share_cache.lock().expect("share cache lock").clear();
self.cover.clear();
self.prepared_downloads
.lock()
.expect("prepared downloads lock")
.clear();
if let Err(error) = &result {
let _ = self.manager.environment().log_buffer().add(
"ERROR",
"Lyrics",
&format!("Failed to persist lyrics cache: {error}"),
);
}
self.availability.shutdown();
self.manager.shutdown();
*state = Some(result.clone());
result
}
/// Preserve the native manager's void shutdown API; flush failures are
/// logged, and native callers can also inspect `shutdown_checked`.
pub fn shutdown(&self) {
let _ = self.shutdown_checked();
}
fn check(&self) -> Result<(), String> {
if self.closed.load(Ordering::Acquire) || self.manager.environment().is_closed() {
Err("backend is closed".into())
} else {
Ok(())
}
}
fn enter(&self) -> Result<Operation<'_>, String> {
let mut operations = self.operations.lock().expect("backend operations lock");
self.check()?;
*operations += 1;
Ok(Operation(self))
}
}
struct Operation<'a>(&'a Backend);
impl Drop for Operation<'_> {
fn drop(&mut self) {
*self.0.operations.lock().expect("backend operations lock") -= 1;
self.0.idle.notify_all();
}
}
impl Deref for Backend {
type Target = ExtensionManager;
fn deref(&self) -> &Self::Target {
&self.manager
}
}
impl Drop for Backend {
fn drop(&mut self) {
self.shutdown();
}
}
@@ -0,0 +1,643 @@
use super::Backend;
use cap_std::fs::OpenOptions;
use spotiflac_core::app_version::AppVersion;
use spotiflac_core::cover::{MAX_DOWNLOAD_BYTES, resize};
use spotiflac_core::tags::{CoverArt, extract_cover};
use spotiflac_network::{
HttpRequest, NetworkService, NetworkSession, random_user_agent, url::UrlParts,
};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::io::Write;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::Path;
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant, UNIX_EPOCH};
type Check<'a> = dyn Fn() -> Result<(), String> + 'a;
type Outcome = Result<Arc<[u8]>, String>;
const CACHE_TTL: Duration = Duration::from_secs(15 * 60);
const SIZE_ERROR: &str = "cover download exceeds 24 MiB limit";
struct Entry {
data: Arc<[u8]>,
expires: Instant,
}
#[derive(Default)]
struct Cache {
entries: BTreeMap<String, Entry>,
bytes: usize,
flights: BTreeMap<String, Arc<Flight>>,
}
#[derive(Default)]
struct Flight {
// Outer None is pending; inner None asks followers to retry after the
// leader's cancellation. One caller cannot cancel another caller's work.
outcome: Mutex<Option<Option<Outcome>>>,
ready: Condvar,
}
pub(super) struct Cover {
network: Arc<NetworkSession>,
version: AppVersion,
cache: Mutex<Cache>,
}
impl Cover {
pub fn new(network: &Arc<NetworkService>, version: AppVersion) -> Self {
Self {
network: network.native_media_session(Duration::from_secs(60)),
version,
cache: Mutex::default(),
}
}
pub fn clear(&self) {
let mut cache = self.cache.lock().expect("cover cache lock");
cache.entries.clear();
cache.bytes = 0;
}
pub(super) fn download(&self, url: &str, limit: i64, check: &Check<'_>) -> Outcome {
let key = if limit <= 0 {
url.to_owned()
} else {
format!("{url}\0max-dimension={limit}")
};
self.cached(&key, check, &|| {
if limit <= 0 {
return self.fetch(url, check);
}
let original = self.download(url, 0, check)?;
match resize(&original, limit, check)
.map_err(|error| format!("resize artwork: {error}"))?
{
std::borrow::Cow::Borrowed(_) => Ok(original),
std::borrow::Cow::Owned(data) => Ok(data.into()),
}
})
}
fn cached(&self, key: &str, check: &Check<'_>, fetch: &dyn Fn() -> Outcome) -> Outcome {
loop {
check()?;
let (flight, leader) = {
let mut cache = self.cache.lock().expect("cover cache lock");
if let Some(entry) = cache.entries.get(key) {
if Instant::now() < entry.expires {
return Ok(entry.data.clone());
}
let entry = cache.entries.remove(key).expect("expired cover entry");
cache.bytes -= entry.data.len();
}
if let Some(flight) = cache.flights.get(key) {
(flight.clone(), false)
} else {
let flight = Arc::new(Flight::default());
cache.flights.insert(key.into(), flight.clone());
(flight, true)
}
};
if !leader {
let mut outcome = flight.outcome.lock().expect("cover flight lock");
loop {
check()?;
match outcome.as_ref() {
Some(Some(result)) => return result.clone(),
Some(None) => break,
None => {
outcome = flight
.ready
.wait_timeout(outcome, Duration::from_millis(25))
.expect("cover flight wait")
.0;
}
}
}
continue;
}
// A decoder/transport panic must not strand coalesced callers.
let result = catch_unwind(AssertUnwindSafe(fetch))
.unwrap_or_else(|_| Err("cover fetch aborted".into()));
let cancelled = check().err();
let mut cache = self.cache.lock().expect("cover cache lock");
if cancelled.is_none()
&& let Ok(data) = &result
&& !data.is_empty()
&& data.len() <= MAX_DOWNLOAD_BYTES
{
cache.bytes += data.len();
cache.entries.insert(
key.into(),
Entry {
data: data.clone(),
expires: Instant::now() + CACHE_TTL,
},
);
while cache.bytes > MAX_DOWNLOAD_BYTES {
let oldest = cache
.entries
.iter()
.min_by_key(|(_, entry)| entry.expires)
.map(|(key, _)| key.clone())
.expect("nonempty cover cache");
cache.bytes -= cache.entries.remove(&oldest).unwrap().data.len();
}
}
cache.flights.remove(key);
*flight.outcome.lock().expect("cover flight lock") =
Some(cancelled.is_none().then(|| result.clone()));
flight.ready.notify_all();
return cancelled.map_or(result, Err);
}
}
fn fetch(&self, url: &str, check: &Check<'_>) -> Outcome {
let parsed = UrlParts::parse(url)
.ok_or_else(|| "failed to create request: invalid URL".to_owned())?;
let user_agent = if parsed.hostname.eq_ignore_ascii_case("api.zarz.moe") {
self.version.user_agent()
} else {
random_user_agent()
};
let mut response = self
.network
.open_response_stream(
HttpRequest {
url: url.into(),
method: "GET".into(),
body: String::new(),
headers: BTreeMap::new(),
default_json: false,
user_agent,
},
check,
)
.map_err(|error| format!("failed to download cover: {error}"))?;
if response.response.status != 200 {
return Err(format!(
"cover download failed: HTTP {}",
response.response.status
));
}
if response
.response
.headers
.get("Content-Length")
.and_then(|values| values.first())
.and_then(|value| value.parse::<u64>().ok())
.is_some_and(|length| length > MAX_DOWNLOAD_BYTES as u64)
{
return Err(SIZE_ERROR.into());
}
let mut data = Vec::new();
let mut buffer = [0; 64 * 1024];
loop {
let capacity = buffer.len().min(MAX_DOWNLOAD_BYTES + 1 - data.len());
let count = response
.read(&mut buffer[..capacity], check)
.map_err(|error| format!("failed to read cover data: {error}"))?;
if count == 0 {
break;
}
data.extend_from_slice(&buffer[..count]);
if data.len() > MAX_DOWNLOAD_BYTES {
return Err(SIZE_ERROR.into());
}
}
Ok(data.into())
}
}
impl Backend {
pub fn extract_cover_to_file(
&self,
audio_path: &str,
output_path: &str,
check: &Check<'_>,
) -> Result<(), String> {
let _operation = self.enter()?;
let check = || self.check().and_then(|()| check());
check()?;
let format = extension(audio_path);
if !matches!(
format.as_str(),
"flac" | "m4a" | "aac" | "mp3" | "ogg" | "opus" | "wav" | "aiff" | "aif" | "aifc"
) {
return Err("unsupported audio format for cover extraction".into());
}
let files = self.manager.environment().native_files()?;
let input = files.resolve_legacy(audio_path)?;
let cover = read_cover(&input, &format, &check)
.map_err(|error| format!("failed to extract cover: {error}"))?;
let write = || {
let output = files.resolve_legacy(output_path)?;
if input.absolute == output.absolute {
return Err("cover output must differ from the audio source".into());
}
let _lock = files.lock(&output, &check)?;
let permissions = output_permissions(&output)?;
publish_cover(&output, permissions, &cover.data, &check)
};
write().map_err(|error| format!("failed to write cover file: {error}"))
}
pub fn save_cover_to_cache_with_hint_and_key(
&self,
audio_path: &str,
hint: &str,
cache_directory: &str,
explicit_key: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.cache_cover(
audio_path,
hint,
cache_directory,
explicit_key,
None,
read_cover,
check,
)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn save_cover_to_cache_for_scan(
&self,
audio_path: &str,
hint: &str,
cache_directory: &str,
explicit_key: &str,
read: impl FnOnce(&crate::files::FilePath, &str, &Check<'_>) -> Result<CoverArt, String>,
check: &Check<'_>,
) -> Result<String, String> {
let format = spotiflac_core::tags::library_extension(audio_path, hint);
// The scan's MP4-family reader also accepts .mp4/.aac. The standalone
// cover export deliberately keeps its narrower legacy suffix policy.
let format = matches!(format.as_str(), "mp4" | "aac").then_some("m4a");
self.cache_cover(
audio_path,
hint,
cache_directory,
explicit_key,
format,
read,
check,
)
}
#[allow(clippy::too_many_arguments)]
fn cache_cover(
&self,
audio_path: &str,
hint: &str,
cache_directory: &str,
explicit_key: &str,
format_override: Option<&str>,
read: impl FnOnce(&crate::files::FilePath, &str, &Check<'_>) -> Result<CoverArt, String>,
check: &Check<'_>,
) -> Result<String, String> {
let _operation = self.enter()?;
let failure = RefCell::new(None::<String>);
let check = || {
if let Some(error) = failure.borrow().as_ref() {
return Err(error.clone());
}
self.check().and_then(|()| check()).inspect_err(|error| {
*failure.borrow_mut() = Some(error.clone());
})
};
check()?;
let files = self.manager.environment().native_files()?;
let input = files.resolve_legacy(audio_path)?;
input.native_display()?;
let key = if !explicit_key.trim().is_empty() {
explicit_key.trim().to_owned()
} else if let Ok(metadata) = input.metadata() {
let modified = metadata
.modified()
.map_err(|error| error.to_string())?
.into_std();
let nanos = match modified.duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_nanos() as i128,
Err(error) => -(error.duration().as_nanos() as i128),
};
format!("{audio_path}|{}|{nanos}", metadata.len())
} else {
audio_path.into()
};
let hash = key.chars().fold(5381_u32, |hash, value| {
hash.wrapping_mul(33).wrapping_add(value as u32)
});
let path =
|extension| Path::new(cache_directory).join(format!("cover_{hash:x}.{extension}"));
let jpg = files.resolve_legacy(&path("jpg").to_string_lossy())?;
let png = files.resolve_legacy(&path("png").to_string_lossy())?;
for cached in [&jpg, &png] {
cached.native_display()?;
if cached.metadata().is_ok_and(|metadata| metadata.is_file()) {
return Ok(cached.display());
}
}
let mut format = extension(audio_path);
if format.is_empty() {
format = extension(hint);
}
if let Some(value) = format_override {
format = value.into();
}
if !matches!(
format.as_str(),
"flac" | "m4a" | "mp3" | "ogg" | "opus" | "wav" | "aiff" | "aif" | "aifc"
) {
return Err(format!(
"unsupported format: {}",
if format.is_empty() {
String::new()
} else {
format!(".{format}")
}
));
}
let mut cover = read(&input, &format, &check)?;
if let Ok(std::borrow::Cow::Owned(resized)) = resize(
&cover.data,
spotiflac_core::cover::LIBRARY_MAX_DIMENSION,
&check,
) {
cover.mime = if resized.starts_with(b"\x89PNG") {
"image/png"
} else {
"image/jpeg"
}
.into();
cover.data = resized;
}
// Library caching may retain unsupported image bytes, but must never
// swallow cancellation while attempting the optional resize.
check()?;
let output = if cover.mime.contains("png") { png } else { jpg };
let _lock = files.lock(&output, &check)?;
if output.metadata().is_ok_and(|metadata| metadata.is_file()) {
return Ok(output.display());
}
output
.mkdir_parent()
.map_err(|error| format!("failed to create cache dir: {error}"))?;
let permissions = output_permissions(&output)
.map_err(|error| format!("failed to write cover: {error}"))?;
publish_cover(&output, permissions, &cover.data, &check)
.map_err(|error| format!("failed to write cover: {error}"))?;
Ok(output.display())
}
pub fn clear_cover_memory_cache(&self) -> Result<(), String> {
let _operation = self.enter()?;
self.cover.clear();
Ok(())
}
pub fn download_cover_to_file_sized(
&self,
url: &str,
output_path: &str,
max_dimension: i64,
check: &Check<'_>,
) -> Result<(), String> {
let _operation = self.enter()?;
let failure = RefCell::new(None::<String>);
let check = || {
if let Some(error) = failure.borrow().as_ref() {
return Err(error.clone());
}
self.check().and_then(|()| check()).inspect_err(|error| {
*failure.borrow_mut() = Some(error.clone());
})
};
check()?;
if url.is_empty() {
return Err("no cover URL provided".into());
}
let files = self.manager.environment().native_files()?;
// Validate and lock before doing network work for an unauthorized or
// unavailable destination. Publication revalidates the same grant.
let prepare = || {
let output = files.resolve_legacy(output_path)?;
let lock = files.lock(&output, &check)?;
let permissions = output_permissions(&output)?;
Ok((output, lock, permissions))
};
let (output, _lock, permissions) =
prepare().map_err(|error: String| format!("failed to write cover file: {error}"))?;
let data = self
.cover
.download(url, max_dimension, &check)
.map_err(|error| format!("failed to download cover: {error}"))?;
publish_cover(&output, permissions, &data, &check)
.map_err(|error| format!("failed to write cover file: {error}"))
}
}
fn extension(path: &str) -> String {
Path::new(path)
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.to_lowercase()
}
fn read_cover(
input: &crate::files::FilePath,
format: &str,
check: &Check<'_>,
) -> Result<CoverArt, String> {
input.native_display()?;
let mut file = input
.open(OpenOptions::new().read(true))
.map_err(|error| error.to_string())?;
extract_cover(&mut file, format, check)
}
fn output_permissions(
output: &crate::files::FilePath,
) -> Result<Option<cap_std::fs::Permissions>, String> {
output.native_display()?;
output.require_parent()?;
match output.open(OpenOptions::new().write(true)) {
Ok(file) => Ok(Some(
file.metadata()
.map_err(|error| error.to_string())?
.permissions(),
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.to_string()),
}
}
fn publish_cover(
output: &crate::files::FilePath,
permissions: Option<cap_std::fs::Permissions>,
data: &[u8],
check: &Check<'_>,
) -> Result<(), String> {
check()?;
let mut stage = output
.stage_existing_parent()
.map_err(|error| error.to_string())?;
if let Some(permissions) = permissions {
stage
.file
.set_permissions(permissions)
.map_err(|error| error.to_string())?;
}
for chunk in data.chunks(16 * 1024) {
check()?;
stage
.file
.write_all(chunk)
.map_err(|error| error.to_string())?;
}
stage.publish(check)
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc;
use std::thread;
fn cover() -> Cover {
Cover::new(&NetworkService::new().unwrap(), "1".into())
}
#[test]
fn cache_coalesces_and_a_cancelled_caller_does_not_cancel_its_peer() {
for cancel_leader in [false, true] {
let cover = cover();
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let (waiting_tx, waiting_rx) = mpsc::channel();
let cancelled = AtomicBool::new(false);
let calls = AtomicUsize::new(0);
thread::scope(|scope| {
let cover = &cover;
let cancelled = &cancelled;
let calls = &calls;
let leader = scope.spawn(move || {
cover.cached(
"shared",
&|| {
if cancelled.load(Ordering::Acquire) {
Err("leader cancelled".into())
} else {
Ok(())
}
},
&|| {
calls.fetch_add(1, Ordering::AcqRel);
entered_tx.send(()).unwrap();
release_rx.recv_timeout(Duration::from_secs(5)).unwrap();
Ok(Arc::from(&b"leader"[..]))
},
)
});
entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
let follower = scope.spawn(|| {
let checks = Cell::new(0);
cover.cached(
"shared",
&|| {
checks.set(checks.get() + 1);
if checks.get() == 2 {
waiting_tx.send(()).unwrap();
if !cancel_leader {
return Err("follower cancelled".into());
}
}
Ok(())
},
&|| {
calls.fetch_add(1, Ordering::AcqRel);
Ok(Arc::from(&b"follower"[..]))
},
)
});
waiting_rx.recv_timeout(Duration::from_secs(5)).unwrap();
cancelled.store(cancel_leader, Ordering::Release);
release_tx.send(()).unwrap();
let (leader, follower) = (leader.join().unwrap(), follower.join().unwrap());
let expected = if cancel_leader {
assert_eq!(leader.unwrap_err(), "leader cancelled");
assert_eq!(follower.unwrap().as_ref(), b"follower");
assert_eq!(calls.load(Ordering::Acquire), 2);
&b"follower"[..]
} else {
assert_eq!(leader.unwrap().as_ref(), b"leader");
assert_eq!(follower.unwrap_err(), "follower cancelled");
assert_eq!(calls.load(Ordering::Acquire), 1);
&b"leader"[..]
};
assert_eq!(
cover
.cached("shared", &|| Ok(()), &|| panic!("cache miss"))
.unwrap()
.as_ref(),
expected
);
});
}
}
#[test]
fn cache_expires_evicts_by_byte_budget_and_does_not_retain_failures() {
let cover = cover();
let fetches = Cell::new(0);
let fetch = || {
fetches.set(fetches.get() + 1);
Ok(Arc::from(vec![42; 16 << 20]))
};
for key in ["first", "first", "second", "first"] {
assert_eq!(
cover.cached(key, &|| Ok(()), &fetch).unwrap().len(),
16 << 20
);
}
assert_eq!(fetches.get(), 3);
cover
.cache
.lock()
.unwrap()
.entries
.get_mut("first")
.unwrap()
.expires = Instant::now() - Duration::from_secs(1);
cover.cached("first", &|| Ok(()), &fetch).unwrap();
assert_eq!(fetches.get(), 4);
cover.clear();
cover.cached("first", &|| Ok(()), &fetch).unwrap();
assert_eq!(fetches.get(), 5);
for _ in 0..2 {
assert_eq!(
cover
.cached("error", &|| Ok(()), &|| Err("HTTP 500".into()))
.unwrap_err(),
"HTTP 500"
);
}
assert_eq!(
cover
.cached("panic", &|| Ok(()), &|| panic!("decoder panic"))
.unwrap_err(),
"cover fetch aborted"
);
assert_eq!(
cover.cached("panic", &|| Ok(()), &fetch).unwrap().len(),
16 << 20
);
assert_eq!(fetches.get(), 6);
}
}
@@ -0,0 +1,574 @@
use super::*;
use crate::environment::ExtensionEnvironment;
use crate::{RuntimeLimits, backend::MetadataOptions};
use spotiflac_network::{NetworkOptions, NetworkService};
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
const RECORDING: &str = r#"{"recordings":[{"tags":[{"name":"rock","count":1}],"releases":[{"title":"Album","artist-credit":[{"name":"Album Artist"}]}]}]}"#;
const CATALOG: &str = r#"{"id":7,"album":{"id":9},"genres":{"data":[{"name":"Jazz"}]},"label":"Label","copyright":"Copyright"}"#;
fn network() -> (Arc<NetworkService>, Arc<rustls::ServerConfig>) {
let certificate = rcgen::generate_simple_self_signed(vec!["127.0.0.1".into()]).unwrap();
let config = rustls::ServerConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(
vec![certificate.cert.der().clone()],
rustls::pki_types::PrivatePkcs8KeyDer::from(certificate.signing_key.serialize_der()).into(),
)
.unwrap();
let network = NetworkService::with_options(NetworkOptions {
extra_root_pem: certificate.cert.pem().into_bytes(),
doh_upstreams: vec![],
..Default::default()
})
.unwrap();
network.set_allow_private_network(true);
(network, Arc::new(config))
}
fn server(
requests: usize,
body: &'static str,
config: Arc<rustls::ServerConfig>,
) -> (
String,
mpsc::Receiver<()>,
mpsc::Sender<()>,
thread::JoinHandle<Vec<String>>,
) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let url = format!("https://{}", listener.local_addr().unwrap());
let (started, ready) = mpsc::channel();
let (release, released) = mpsc::channel();
let worker = thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(5);
let mut paths = Vec::new();
for index in 0..requests {
let stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
assert!(Instant::now() < deadline, "missing metadata request");
thread::sleep(Duration::from_millis(1));
}
Err(error) => panic!("{error}"),
}
};
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut stream = rustls::StreamOwned::new(
rustls::ServerConnection::new(Arc::clone(&config)).unwrap(),
stream,
);
let mut request = [0; 4096];
let count = stream.read(&mut request).unwrap();
assert!(count > 0);
paths.push(
std::str::from_utf8(&request[..count])
.unwrap()
.split_whitespace()
.nth(1)
.unwrap()
.to_owned(),
);
if index == 0 {
let _ = started.send(());
released.recv_timeout(Duration::from_secs(3)).unwrap();
}
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
}
paths
});
(url, ready, release, worker)
}
fn backend(
directory: &Path,
catalog: String,
recording: String,
network: Arc<NetworkService>,
) -> Arc<Backend> {
let environment = ExtensionEnvironment::with_network(
&directory.join("data"),
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"1",
network,
)
.unwrap();
let mut options = MetadataOptions {
deezer_endpoint: catalog,
..Default::default()
};
options.musicbrainz.endpoint = recording;
Arc::new(
Backend::with_environment(
&directory.join("sources"),
environment,
RuntimeLimits::default(),
options,
)
.unwrap(),
)
}
#[test]
fn extended_metadata_overlaps_requests_preserves_fields_and_reuses_genre_fallback() {
for catalog_body in [
CATALOG,
r#"{"id":7,"album":{"id":9},"label":"Label","copyright":"Copyright"}"#,
] {
let (network, config) = network();
let (catalog, catalog_ready, release_catalog, catalog_worker) =
server(2, catalog_body, Arc::clone(&config));
let (recording, recording_ready, release_recording, recording_worker) =
server(1, RECORDING, config);
let directory = tempfile::tempdir().unwrap();
let backend = backend(directory.path(), catalog, recording, network);
let active = Arc::clone(&backend);
let worker = thread::spawn(move || {
let mut request = DownloadRequest {
isrc: "EXAMPLE12345".into(),
album_name: "Album".into(),
label: "Existing label".into(),
..Default::default()
};
active
.enrich_download_extended(&mut request, &|| Ok(()))
.unwrap();
request
});
// Neither server replies until both independent requests have started.
catalog_ready.recv_timeout(Duration::from_secs(2)).unwrap();
recording_ready
.recv_timeout(Duration::from_secs(2))
.unwrap();
release_catalog.send(()).unwrap();
release_recording.send(()).unwrap();
let mut request = worker.join().unwrap();
assert_eq!(request.album_artist, "Album Artist");
assert_eq!(request.label, "Existing label");
assert_eq!(request.copyright, "Copyright");
assert_eq!(
request.genre,
if catalog_body == CATALOG {
"Jazz"
} else {
"Rock"
}
);
assert_eq!(
catalog_worker.join().unwrap(),
["/2.0/track/isrc:EXAMPLE12345", "/2.0/album/9"]
);
recording_worker.join().unwrap();
// Complete input must not query either server, which is now closed.
backend
.enrich_download_extended(&mut request, &|| Ok(()))
.unwrap();
assert_eq!(request.label, "Existing label");
backend.shutdown();
}
}
#[test]
fn extended_metadata_cancellation_joins_both_blocked_requests() {
for shutdown in [false, true] {
let (network, config) = network();
let (catalog, catalog_ready, release_catalog, catalog_worker) =
server(1, CATALOG, Arc::clone(&config));
let (recording, recording_ready, release_recording, recording_worker) =
server(1, RECORDING, config);
let directory = tempfile::tempdir().unwrap();
let backend = backend(directory.path(), catalog, recording, network);
let cancelled = Arc::new(AtomicBool::new(false));
let stop = Arc::clone(&cancelled);
let active = Arc::clone(&backend);
let worker = thread::spawn(move || {
let mut request = DownloadRequest {
isrc: "EXAMPLE12345".into(),
album_name: "Album".into(),
..Default::default()
};
active.enrich_download_extended(&mut request, &|| {
active.check()?;
if stop.load(Ordering::Acquire) {
Err("download cancelled".into())
} else {
Ok(())
}
})
});
catalog_ready.recv_timeout(Duration::from_secs(2)).unwrap();
recording_ready
.recv_timeout(Duration::from_secs(2))
.unwrap();
if shutdown {
backend.shutdown();
} else {
cancelled.store(true, Ordering::Release);
}
let started = Instant::now();
while !worker.is_finished() {
assert!(
started.elapsed() < Duration::from_secs(2),
"cancel waited for HTTP replies"
);
thread::sleep(Duration::from_millis(1));
}
let result = worker.join().unwrap();
if shutdown {
assert!(result.is_err(), "owner shutdown must cancel metadata");
} else {
assert_eq!(result, Err("download cancelled".into()));
}
release_catalog.send(()).unwrap();
release_recording.send(()).unwrap();
catalog_worker.join().unwrap();
recording_worker.join().unwrap();
backend.shutdown();
}
}
struct GatedServer {
url: String,
started: mpsc::Receiver<(String, mpsc::Sender<()>)>,
stop: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<Vec<String>>>,
}
impl GatedServer {
fn new(config: Arc<rustls::ServerConfig>, replies: &[(&str, u16, &str)]) -> Self {
let replies: std::collections::BTreeMap<_, _> = replies
.iter()
.map(|(path, status, body)| (path.to_string(), (*status, body.to_string())))
.collect();
let replies = Arc::new(replies);
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let url = format!("https://{}", listener.local_addr().unwrap());
let stop = Arc::new(AtomicBool::new(false));
let stopped = stop.clone();
let (ready, started) = mpsc::channel();
let worker = thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(8);
let mut workers = Vec::new();
while !stopped.load(Ordering::Acquire) && Instant::now() < deadline {
let stream = match listener.accept() {
Ok((stream, _)) => stream,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(1));
continue;
}
Err(error) => panic!("gated fixture accept: {error}"),
};
assert!(workers.len() < 8, "unexpected repeated request");
let config = config.clone();
let ready = ready.clone();
let replies = replies.clone();
workers.push(thread::spawn(move || {
stream.set_nonblocking(false).unwrap();
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap();
let mut stream = rustls::StreamOwned::new(
rustls::ServerConnection::new(config).unwrap(), stream,
);
let mut request = Vec::new();
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
let mut bytes = [0; 4096];
let count = stream.read(&mut bytes).unwrap();
assert!(count > 0 && request.len() + count <= 8192);
request.extend_from_slice(&bytes[..count]);
}
let path = std::str::from_utf8(&request).unwrap().split_whitespace().nth(1).unwrap()
.split('?').next().unwrap().to_owned();
let (release, released) = mpsc::channel();
ready.send((path.clone(), release)).unwrap();
let _ = released.recv_timeout(Duration::from_secs(3));
let (status, body) = replies.get(&path).expect("unexpected fixture path");
let _ = write!(stream, "HTTP/1.1 {status} Fixture\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
path
}));
}
workers
.into_iter()
.map(|worker| worker.join().unwrap())
.collect()
});
Self {
url,
started,
stop,
worker: Some(worker),
}
}
fn next(&self) -> (String, mpsc::Sender<()>) {
self.started.recv_timeout(Duration::from_secs(2)).unwrap()
}
fn finish(&mut self) -> Vec<String> {
self.stop.store(true, Ordering::Release);
let mut paths = self.worker.take().unwrap().join().unwrap();
paths.sort();
paths
}
}
impl Drop for GatedServer {
fn drop(&mut self) {
self.stop.store(true, Ordering::Release);
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
#[test]
fn catalog_search_gates_required_tracks_then_overlaps_optional_categories() {
for mode in ["all", "artist", "track_failure"] {
let (network, config) = network();
let track = if mode == "track_failure" {
r#"{"error":{"code":100,"message":"required track failure"}}"#
} else {
r#"{"data":[{"id":1,"title":"Track"}]}"#
};
let artist = if mode == "artist" {
r#"{"data":[{"id":2,"name":"Artist"}]}"#
} else {
r#"{"error":{"code":100,"message":"optional artist failure"}}"#
};
let mut server = GatedServer::new(
config,
&[
("/2.0/search/track", 200, track),
("/2.0/search/artist", 200, artist),
(
"/2.0/search/album",
200,
r#"{"data":[{"id":3,"title":"Album"}]}"#,
),
(
"/2.0/search/playlist",
200,
r#"{"data":[{"id":4,"title":"Playlist"}]}"#,
),
],
);
let directory = tempfile::tempdir().unwrap();
let backend = backend(
directory.path(),
server.url.clone(),
server.url.clone(),
network,
);
let active = backend.clone();
let worker = thread::spawn(move || {
active.search_deezer(
"needle",
2,
2,
if mode == "artist" { "artist" } else { "" },
&|| Ok(()),
)
});
let (first, release) = server.next();
assert_eq!(
first,
if mode == "artist" {
"/2.0/search/artist"
} else {
"/2.0/search/track"
}
);
assert!(matches!(
server.started.recv_timeout(Duration::from_millis(100)),
Err(mpsc::RecvTimeoutError::Timeout)
));
release.send(()).unwrap();
if mode == "all" {
let mut pending = Vec::new();
for _ in 0..3 {
pending.push(server.next());
}
let mut categories: Vec<_> = pending.iter().map(|(path, _)| path.as_str()).collect();
categories.sort();
assert_eq!(
categories,
[
"/2.0/search/album",
"/2.0/search/artist",
"/2.0/search/playlist"
]
);
for (_, release) in pending {
release.send(()).unwrap();
}
}
let result = worker.join().unwrap();
if mode == "track_failure" {
assert!(result.unwrap_err().contains("required track failure"));
} else {
let value: Value = serde_json::from_str(&result.unwrap()).unwrap();
if mode == "all" {
assert_eq!(value["tracks"][0]["spotify_id"], "deezer:1");
assert_eq!(value["artists"], json!([]));
assert_eq!(value["albums"][0]["id"], "deezer:3");
assert_eq!(value["playlists"][0]["id"], "deezer:4");
} else {
assert_eq!(value["artists"][0]["id"], "deezer:2");
assert_eq!(value["tracks"], json!([]));
assert_eq!(value["albums"], json!([]));
assert_eq!(value["playlists"], json!([]));
}
}
let paths = server.finish();
assert_eq!(paths.len(), if mode == "all" { 4 } else { 1 });
backend.shutdown();
}
}
#[test]
fn reenrich_cover_and_lyrics_overlap_preserve_best_effort_results_and_cancel() {
for mode in ["success", "cover_failure", "lyrics_failure", "cancel"] {
let (network, config) = network();
let mut server = GatedServer::new(
config,
&[
(
"/cover",
if mode == "cover_failure" { 404 } else { 200 },
"artwork",
),
(
"/lyrics",
if mode == "lyrics_failure" { 404 } else { 200 },
"New lyrics",
),
],
);
let directory = tempfile::tempdir().unwrap();
let backend = backend(
directory.path(),
server.url.clone(),
server.url.clone(),
network,
);
let archive = directory.path().join("example.lyrics.sflx");
let mut writer = zip::ZipWriter::new(std::fs::File::create(&archive).unwrap());
let manifest = json!({"name":"example.lyrics","displayName":"Example Lyrics","version":"1",
"description":"Generic lyrics fixture","type":["lyrics_provider"],"permissions":{"network":["127.0.0.1"]}});
let source = format!(
r#"registerExtension({{fetchLyrics(){{const response=http.get({});
return response.statusCode===200 ? {{plainLyrics:response.body}} : {{}};}}}});"#,
json!(format!("{}/lyrics", server.url))
);
for (name, body) in [
("manifest.json", manifest.to_string()),
("index.js", source),
] {
writer
.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(body.as_bytes()).unwrap();
}
writer.finish().unwrap();
backend.install(&archive).unwrap();
backend.set_enabled("example.lyrics", true).unwrap();
backend
.set_lyrics_providers_json(r#"["extension:example.lyrics"]"#)
.unwrap();
let audio = directory.path().join("data/track.mp3");
let sidecar = directory.path().join("data/track.lrc");
std::fs::write(&audio, "original audio").unwrap();
std::fs::write(&sidecar, "[00:01.00]Old lyrics").unwrap();
let request = json!({"file_path":"track.mp3","track_name":"Track","artist_name":"Artist",
"cover_url":format!("{}/cover", server.url),"embed_lyrics":true,"lyrics_mode":"both",
"update_fields":["cover","lyrics"]})
.to_string();
let cancelled = Arc::new(AtomicBool::new(false));
let stop = cancelled.clone();
let active = backend.clone();
let worker = thread::spawn(move || {
active.reenrich_file(&request, &|| {
if stop.load(Ordering::Acquire) {
Err("reenrich cancelled".into())
} else {
Ok(())
}
})
});
let first = server.next();
let second = server.next();
let mut paths = [first.0.as_str(), second.0.as_str()];
paths.sort();
assert_eq!(paths, ["/cover", "/lyrics"]);
if mode == "cancel" {
cancelled.store(true, Ordering::Release);
let deadline = Instant::now() + Duration::from_secs(2);
while !worker.is_finished() {
assert!(
Instant::now() < deadline,
"reenrich cancellation waited for server replies"
);
thread::sleep(Duration::from_millis(1));
}
} else {
first.1.send(()).unwrap();
second.1.send(()).unwrap();
}
let result = worker.join().unwrap();
if mode == "cancel" {
assert_eq!(result, Err("reenrich cancelled".into()));
first.1.send(()).unwrap();
second.1.send(()).unwrap();
} else {
let result: Value = serde_json::from_str(&result.unwrap()).unwrap();
assert_eq!(result["method"], "ffmpeg");
assert_eq!(result["write_external_lrc"], true);
let lyrics = result["lyrics"].as_str().unwrap();
assert!(lyrics.contains(if mode == "lyrics_failure" {
"Old lyrics"
} else {
"New lyrics"
}));
assert_eq!(result["metadata"]["LYRICS"], lyrics);
let cover = result["cover_path"].as_str().unwrap();
if mode == "cover_failure" {
assert!(cover.is_empty());
} else {
assert_eq!(std::fs::read(cover).unwrap(), b"artwork");
std::fs::remove_file(cover).unwrap();
}
}
assert_eq!(std::fs::read(&audio).unwrap(), b"original audio");
assert_eq!(std::fs::read(&sidecar).unwrap(), b"[00:01.00]Old lyrics");
assert!(
std::fs::read_dir(directory.path().join("data"))
.unwrap()
.all(|entry| !entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with("reenrich_cover_"))
);
assert_eq!(server.finish(), ["/cover", "/lyrics"]);
backend.shutdown();
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,685 @@
//! Health snapshots share the installed manager's revision and HTTP transport.
use super::Backend;
use crate::manager::ExtensionManager;
use crate::manifest::{ExtensionManifest, HealthCheck};
use serde::Serialize;
use serde_json::Value;
use spotiflac_core::app_version::AppVersion;
use spotiflac_network::{
HttpRequest, NetworkService, policy::NetworkPermissions, policy::private_literal_or_local,
url::UrlParts,
};
use spotiflac_providers::resolver::Check;
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
#[derive(Clone, Serialize)]
struct ResultSnapshot {
extension_id: String,
status: String,
checked_at: String,
checks: Vec<CheckResult>,
}
#[derive(Clone, Serialize)]
struct CheckResult {
id: String,
#[serde(skip_serializing_if = "String::is_empty")]
label: String,
url: String,
method: String,
#[serde(skip_serializing_if = "String::is_empty")]
service_key: String,
required: bool,
status: String,
#[serde(skip_serializing_if = "is_zero")]
http_status: u16,
latency_ms: u128,
#[serde(skip_serializing_if = "String::is_empty")]
message: String,
#[serde(skip_serializing_if = "String::is_empty")]
error: String,
checked_at: String,
}
fn is_zero(value: &u16) -> bool {
*value == 0
}
fn now() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
struct Cached {
revision: u64,
result: ResultSnapshot,
expires: Instant,
}
#[derive(Default)]
struct State {
entries: BTreeMap<String, Cached>,
refreshing: BTreeMap<String, u64>,
workers: Vec<JoinHandle<()>>,
}
pub(super) struct Health {
network: Arc<NetworkService>,
version: AppVersion,
state: Mutex<State>,
closed: AtomicBool,
}
impl Health {
pub fn new(network: &Arc<NetworkService>, version: AppVersion) -> Self {
Self {
network: network.clone(),
version,
state: Mutex::default(),
closed: AtomicBool::new(false),
}
}
pub fn clear_memory_cache(&self) {
self.state
.lock()
.expect("health state lock")
.entries
.clear();
}
pub fn shutdown(&self) {
self.closed.store(true, Ordering::Release);
let workers = {
let mut state = self.state.lock().expect("health state lock");
state.entries.clear();
std::mem::take(&mut state.workers)
};
for worker in workers {
let _ = worker.join();
}
}
fn fetch(
&self,
manager: &ExtensionManager,
manifest: &ExtensionManifest,
revision: u64,
check: &Check<'_>,
) -> Result<ResultSnapshot, String> {
let check = || {
check()?;
if self.closed.load(Ordering::Acquire) || manager.environment().is_closed() {
return Err("health service closed".into());
}
if manager.metadata_revision() != revision {
return Err("extension changed during health check".into());
}
Ok(())
};
check()?;
let mut result = ResultSnapshot {
extension_id: manifest.name.clone(),
status: "unsupported".into(),
checked_at: now(),
checks: Vec::new(),
};
if manifest.service_health.is_empty() {
return Ok(result);
}
result.status = "online".into();
for health_check in &manifest.service_health {
let entry = self.run_check(manifest, health_check, &check)?;
result.status = match (
entry.status.as_str(),
entry.required,
result.status.as_str(),
) {
("offline", true, _) => "offline",
("offline", false, "online") | ("degraded", _, "online") => "degraded",
("unknown", _, "online") => "unknown",
_ => &result.status,
}
.into();
result.checks.push(entry);
}
check()?;
let ttl = manifest
.service_health
.iter()
.filter(|check| check.cache_ttl_seconds > 0)
.map(|check| (check.cache_ttl_seconds as u64).clamp(60, 600))
.min()
.unwrap_or(600);
let ttl = if result.status == "unknown" {
ttl.min(120)
} else {
ttl
};
let mut state = self.state.lock().expect("health state lock");
check()?;
state.entries.retain(|_, entry| entry.revision == revision);
state.entries.insert(
manifest.name.clone(),
Cached {
revision,
result: result.clone(),
expires: Instant::now() + Duration::from_secs(ttl),
},
);
Ok(result)
}
fn fallback_status(self: &Arc<Self>, manager: &Arc<ExtensionManager>, id: &str) -> String {
let revision = manager.metadata_revision();
let Ok(manifest) = manager.health_manifest(id) else {
return "unknown".into();
};
if manifest.service_health.is_empty() {
return "unknown".into();
}
let mut state = self.state.lock().expect("health state lock");
if self.closed.load(Ordering::Acquire) {
return "unknown".into();
}
state.entries.retain(|_, entry| entry.revision == revision);
let mut status = "unknown".to_owned();
if let Some(entry) = state.entries.get(id) {
if Instant::now() < entry.expires {
return entry.result.status.clone();
}
if entry.result.status != "offline" {
status.clone_from(&entry.result.status);
}
}
if state.refreshing.get(id) == Some(&revision) {
return status;
}
// Reap completed native threads, retaining handles for shutdown to join.
let mut index = 0;
while index < state.workers.len() {
if state.workers[index].is_finished() {
let _ = state.workers.swap_remove(index).join();
} else {
index += 1;
}
}
let health = self.clone();
let manager = manager.clone();
let id = id.to_owned();
let running_id = id.clone();
state.refreshing.insert(id.clone(), revision);
match std::thread::Builder::new()
.name("extension-health".into())
.spawn(move || {
let _ = health.fetch(&manager, &manifest, revision, &|| Ok(()));
let mut state = health.state.lock().expect("health state lock");
if state.refreshing.get(&running_id) == Some(&revision) {
state.refreshing.remove(&running_id);
}
}) {
Ok(worker) => state.workers.push(worker),
Err(_) => {
state.refreshing.remove(&id);
}
}
status
}
fn run_check(
&self,
manifest: &ExtensionManifest,
input: &HealthCheck,
check: &Check<'_>,
) -> Result<CheckResult, String> {
check()?;
let mut method = input.method.trim().to_uppercase();
if method.is_empty() {
method = "GET".into();
}
let mut result = CheckResult {
id: input.id.clone(),
label: input.label.clone(),
url: input.url.clone(),
method: method.clone(),
service_key: input.service_key.trim().into(),
required: input.required,
status: "unknown".into(),
http_status: 0,
latency_ms: 0,
message: String::new(),
error: String::new(),
checked_at: now(),
};
let permissions = NetworkPermissions {
domains: manifest.permissions.network.clone().unwrap_or_default(),
allow_http: false,
};
let parsed = UrlParts::parse(&input.url);
let invalid = match &parsed {
None => Some("invalid health URL".to_owned()),
Some(url) if url.scheme != "https" => Some("health check must use https".into()),
Some(url) if url.hostname.is_empty() => {
Some("health check URL hostname is required".into())
}
Some(url) if private_literal_or_local(&url.hostname) => {
Some("private/local health check host is not allowed".into())
}
Some(url) if !permissions.allows_domain(&url.hostname) => Some(format!(
"health check host '{}' is not in extension network permissions",
url.hostname
)),
_ if method != "GET" && method != "HEAD" => {
Some("health check method must be GET or HEAD".into())
}
_ => None,
};
if let Some(error) = invalid {
result.status = "offline".into();
result.error = error;
return Ok(result);
}
let timeout = Duration::from_millis(if input.timeout_ms > 0 {
input.timeout_ms as u64
} else {
4000
});
let start = Instant::now();
let network_check = || {
check()?;
if start.elapsed() >= timeout {
Err("HTTP request timeout exceeded".into())
} else {
Ok(())
}
};
let session = self.network.session(permissions, timeout);
let response = session.open_response_stream(
HttpRequest {
url: input.url.clone(),
method: method.clone(),
body: String::new(),
headers: BTreeMap::from([("Accept".into(), "application/json".into())]),
default_json: false,
user_agent: if parsed
.as_ref()
.is_some_and(|url| url.hostname.eq_ignore_ascii_case("api.zarz.moe"))
{
self.version.user_agent()
} else {
crate::utility_host::random_user_agent()
},
},
network_check,
);
result.latency_ms = start.elapsed().as_millis();
check()?;
let mut response = match response {
Ok(response) => response,
Err(error) => {
result.status = if transient_transport(&error) {
"unknown"
} else {
"offline"
}
.into();
result.error = error;
return Ok(result);
}
};
result.http_status = response.response.status;
result.message = format!(
"{} {}",
response.response.status, response.response.status_text
);
if !(200..300).contains(&result.http_status) {
result.status = "offline".into();
return Ok(result);
}
if method == "HEAD" {
result.status = "online".into();
return Ok(result);
}
let mut body = Vec::new();
let mut buffer = [0; 8192];
while body.len() < 64 * 1024 {
let limit = buffer.len().min(64 * 1024 - body.len());
match response.read(&mut buffer[..limit], network_check) {
Ok(0) => break,
Ok(count) => body.extend_from_slice(&buffer[..count]),
Err(error) => {
check()?;
result.status = "degraded".into();
result.error = error;
result.message.clear();
return Ok(result);
}
}
}
check()?;
let (status, message) = classify_body(&body, &input.service_key);
result.status = status.into();
if !message.is_empty() {
result.message = message;
}
Ok(result)
}
}
impl Backend {
pub fn check_extension_health_json(
&self,
id: &str,
check: &Check<'_>,
) -> Result<String, String> {
let _operation = self.enter()?;
let revision = self.manager.metadata_revision();
let manifest = self
.manager
.health_manifest(id)
.map_err(|error| error.to_string())?;
let result = self.health.fetch(&self.manager, &manifest, revision, &|| {
self.check().and_then(|()| check())
})?;
serde_json::to_string(&result).map_err(|error| error.to_string())
}
pub(super) fn prioritize_healthy_downloads(
&self,
priority: Vec<String>,
protected: &str,
selected: &str,
) -> Result<Vec<String>, String> {
let mut groups = [Vec::new(), Vec::new(), Vec::new()];
for id in priority {
let id = id.trim();
if id.is_empty() {
continue;
}
let status = if id.eq_ignore_ascii_case(protected)
|| !self
.fallback_allowed(id)
.map_err(|error| error.to_string())?
|| !self
.download_manifest(id)
.is_ok_and(|manifest| manifest.has_type("download_provider"))
{
"unknown".into()
} else {
self.health.fallback_status(&self.manager, id)
};
let index = match status.as_str() {
"online" => 0,
"degraded" => 1,
"offline" => continue,
_ => 2,
};
groups[index].push(id.to_owned());
}
let mut ordered: Vec<_> = groups.into_iter().flatten().collect();
if let Some(index) = ordered
.iter()
.position(|id| id.eq_ignore_ascii_case(selected))
{
let id = ordered.remove(index);
ordered.insert(0, id);
}
Ok(ordered)
}
}
fn transient_message(value: &str) -> bool {
let value = value.trim().to_lowercase();
[
"deadline exceeded",
"timeout",
"timed out",
"temporarily unavailable",
"try again",
]
.iter()
.any(|part| value.contains(part))
}
fn transient_transport(value: &str) -> bool {
let value = value.to_lowercase();
transient_message(&value)
|| [
"dns",
"resolve",
"connect",
"unreachable",
"certificate",
"tls",
"unexpected eof",
"incomplete message",
]
.iter()
.any(|part| value.contains(part))
}
fn classify_body(body: &[u8], key: &str) -> (&'static str, String) {
let Ok(payload) = serde_json::from_slice::<Value>(body) else {
return ("online", String::new());
};
let key = key.trim();
if !key.is_empty()
&& let Some(services) = payload["services"].as_object()
{
let Some(service) = services.get(key) else {
return ("unknown", format!("service '{key}' not found"));
};
if !service.is_object() {
return (
"unknown",
format!("service '{key}' has invalid health payload"),
);
}
let text = |field: &str| service[field].as_str().unwrap_or_default();
let message = [text("label"), text("detail"), text("error")]
.into_iter()
.map(str::trim)
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(": ");
let transient = [text("detail"), text("error"), text("label")]
.into_iter()
.any(transient_message);
let ok = service["ok"].as_bool();
let status = if let Some(code) = service["status"].as_f64() {
let code = code as i64;
if (200..300).contains(&code) || (code == 500 && ok == Some(true)) {
"online"
} else if matches!(code, 401 | 403) {
"degraded"
} else if transient || matches!(code, 408 | 429 | 502 | 503 | 504) {
"unknown"
} else {
"offline"
}
} else if matches!(
text("detail").trim().to_lowercase().as_str(),
"auth_required" | "authorization_required" | "login_required" | "unauthorized"
) {
"degraded"
} else if transient {
"unknown"
} else if let Some(ok) = ok {
if ok { "online" } else { "offline" }
} else {
match text("status").trim().to_lowercase().as_str() {
"ok" | "up" | "online" | "healthy" | "operational" => "online",
"degraded" | "partial" | "warning" | "warn" => "degraded",
"down" | "offline" | "error" | "failed" | "fail" | "unhealthy" => "offline",
_ => "unknown",
}
};
return (status, message);
}
let raw = payload["status"].as_str().unwrap_or_default();
let status = match raw.trim().to_lowercase().as_str() {
"degraded" | "partial" | "warning" | "warn" => "degraded",
"down" | "offline" | "error" | "failed" | "fail" | "unhealthy" => {
if transient_message(&String::from_utf8_lossy(body)) {
"unknown"
} else {
"offline"
}
}
_ => "online",
};
(status, raw.into())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::RuntimeLimits;
use crate::backend::MetadataOptions;
use crate::environment::ExtensionEnvironment;
use serde_json::json;
use spotiflac_network::{Lookup, LookupFuture, NetworkOptions};
use std::sync::atomic::AtomicUsize;
struct PendingLookup(Arc<AtomicUsize>);
impl Lookup for PendingLookup {
fn lookup(&self, _: &str) -> LookupFuture {
self.0.fetch_add(1, Ordering::AcqRel);
Box::pin(std::future::pending())
}
}
#[test]
fn health_ordering_recovers_stale_offline_coalesces_and_joins_refresh() {
let root = tempfile::tempdir().unwrap();
let lookups = Arc::new(AtomicUsize::new(0));
let network = NetworkService::with_options(NetworkOptions {
lookup: Arc::new(PendingLookup(lookups.clone())),
doh_upstreams: Vec::new(),
..Default::default()
})
.unwrap();
let environment = ExtensionEnvironment::with_network(
&root.path().join("data"),
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"1",
network,
)
.unwrap();
let backend = Backend::with_environment(
&root.path().join("sources"),
environment,
RuntimeLimits::default(),
MetadataOptions::default(),
)
.unwrap();
let ids = [
"example.selected",
"example.offline",
"example.unknown",
"example.degraded",
"example.online",
];
for id in ids {
let source = root.path().join("sources").join(id);
std::fs::create_dir_all(&source).unwrap();
std::fs::write(source.join("manifest.json"), json!({"name":id,"version":"1","description":"Generic health fixture","type":["download_provider"],
"permissions":{"network":["example.com"]},"serviceHealth":[{"id":"status","url":"https://example.com/status","required":true}]}).to_string()).unwrap();
std::fs::write(source.join("index.js"), "registerExtension({});").unwrap();
}
backend.load_all().unwrap();
for id in ids {
backend.set_enabled(id, true).unwrap();
}
let revision = backend.metadata_revision();
{
let mut state = backend.health.state.lock().unwrap();
for (id, status) in [
(ids[0], "offline"),
(ids[1], "offline"),
(ids[3], "degraded"),
(ids[4], "online"),
] {
state.entries.insert(
id.into(),
Cached {
revision,
expires: Instant::now() + Duration::from_secs(60),
result: ResultSnapshot {
extension_id: id.into(),
status: status.into(),
checked_at: now(),
checks: Vec::new(),
},
},
);
}
}
let priority = || ids.iter().map(|id| (*id).to_owned()).collect();
let first = backend
.prioritize_healthy_downloads(priority(), ids[0], ids[0])
.unwrap();
assert_eq!(first, [ids[0], ids[4], ids[3], ids[2]]);
backend
.health
.state
.lock()
.unwrap()
.entries
.get_mut(ids[1])
.unwrap()
.expires = Instant::now() - Duration::from_secs(1);
let started = Instant::now();
for _ in 0..4 {
let order = backend
.prioritize_healthy_downloads(priority(), ids[0], ids[0])
.unwrap();
assert_eq!(order, [ids[0], ids[4], ids[3], ids[1], ids[2]]);
}
assert!(
started.elapsed() < Duration::from_secs(1),
"fallback waited for unresolved health network work"
);
assert_eq!(backend.health.state.lock().unwrap().workers.len(), 2);
while lookups.load(Ordering::Acquire) == 0 {
assert!(started.elapsed() < Duration::from_secs(3));
std::thread::sleep(Duration::from_millis(5));
}
let cached = backend.health.state.lock().unwrap().entries.len();
assert!(cached > 0);
backend.release_memory(false).unwrap();
assert_eq!(backend.health.state.lock().unwrap().entries.len(), cached);
backend.release_memory(true).unwrap();
{
let state = backend.health.state.lock().unwrap();
assert!(state.entries.is_empty());
assert_eq!(state.workers.len(), 2);
assert!(state.workers.iter().all(|worker| !worker.is_finished()));
}
// A manager mutation invalidates all old snapshots and late refreshes.
backend.set_enabled(ids[1], false).unwrap();
backend.set_enabled(ids[1], true).unwrap();
assert_eq!(
backend
.prioritize_healthy_downloads(priority(), ids[0], ids[0])
.unwrap(),
ids
);
let closed = Instant::now();
backend.shutdown();
assert!(
closed.elapsed() < Duration::from_secs(1),
"shutdown did not interrupt pending health DNS"
);
assert!(backend.health.state.lock().unwrap().workers.is_empty());
assert!(backend.health.state.lock().unwrap().entries.is_empty());
assert!(
backend
.check_extension_health_json(ids[0], &|| Ok(()))
.is_err()
);
}
}
@@ -0,0 +1,431 @@
use super::Backend;
use cap_std::fs::OpenOptions;
use chrono::{SecondsFormat, Utc};
use serde_json::{Value, json};
use spotiflac_core::cue::{self, CueSheet};
use spotiflac_core::tags::{
extract_cover, library_extension, library_id, library_metadata, read_library_metadata,
read_library_metadata_with_cover,
};
use std::cell::RefCell;
use std::path::Path;
use std::time::UNIX_EPOCH;
mod scan;
pub(super) use scan::ScanState;
impl Backend {
pub fn set_library_cover_cache_directory(&self, directory: &str) -> Result<(), String> {
let _operation = self.enter()?;
if !directory.is_empty() {
self.environment()
.native_files()?
.resolve_legacy(directory)?
.native_display()?;
}
*self
.library_cover_directory
.lock()
.expect("library cover directory lock") = directory.into();
Ok(())
}
pub fn read_audio_metadata(
&self,
path: &str,
hint: &str,
cache_key: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Value, String> {
let _operation = self.enter()?;
let failure = RefCell::new(None::<String>);
let check = || {
if let Some(error) = failure.borrow().as_ref() {
return Err(error.clone());
}
self.check().and_then(|()| check()).inspect_err(|error| {
*failure.borrow_mut() = Some(error.clone());
})
};
check()?;
let files = self.environment().native_files()?;
self.scan_audio_file(&files, path, hint, cache_key, &scan_time(), 0, &check)
}
#[allow(clippy::too_many_arguments)]
fn scan_audio_file(
&self,
files: &crate::files::ExtensionFiles,
path: &str,
hint: &str,
cache_key: &str,
scan_time: &str,
mod_time: i64,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Value, String> {
check()?;
let input = files.resolve_legacy(path)?;
let mut file = match input.open_native_read() {
Ok(file) => Some(file),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.to_string()),
};
let mod_time = if mod_time > 0 {
mod_time
} else {
modified(&input)
};
let mut metadata = None;
let directory = self
.library_cover_directory
.lock()
.expect("library cover directory lock")
.clone();
let cover = if directory.is_empty() {
None
} else {
self.save_cover_to_cache_for_scan(
path,
hint,
&directory,
cache_key,
|_, format, check| {
let file = file.as_mut().ok_or("audio file unavailable")?;
if format == "flac" {
let (tags, cover) = read_library_metadata_with_cover(
file, path, hint, scan_time, mod_time, check,
)?;
metadata = Some(tags);
cover.ok_or_else(|| "no cover art found in file".into())
} else {
extract_cover(file, format, check)
}
},
check,
)
.ok()
};
check()?;
let mut result = match metadata {
Some(metadata) => metadata,
None => match file.as_mut() {
Some(file) => read_library_metadata(file, path, hint, scan_time, mod_time, check)?,
None => library_metadata(path, hint, scan_time, mod_time),
},
};
if let Some(cover) = cover.filter(|cover| !cover.is_empty()) {
result["coverPath"] = cover.into();
}
check()?;
if result["hasLyrics"] == false {
let open = |path: &str| {
let input = files.resolve_legacy(path)?;
input.open_native_read().map_err(|error| error.to_string())
};
if let Ok(lyrics) = spotiflac_core::lyrics::file::sidecar(path, &open, check) {
result["hasLyrics"] =
spotiflac_core::lyrics::lrc::has_usable_content(&lyrics).into();
}
}
check()?;
Ok(result)
}
pub fn parse_cue_file(
&self,
path: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<CueSheet, String> {
let _operation = self.enter()?;
let check = || self.check().and_then(|()| check());
check()?;
let input = self.environment().native_files()?.resolve_legacy(path)?;
input.native_display()?;
let mut file = input
.open(OpenOptions::new().read(true))
.map_err(|error| format!("failed to open cue file: {error}"))?;
cue::parse(&mut file, &check)
}
fn resolve_cue_audio(
&self,
path: &str,
name: &str,
audio_directory: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<String, String> {
let base = Path::new(path).parent().unwrap_or(Path::new("."));
let directory = if audio_directory.is_empty() {
base
} else {
Path::new(audio_directory)
};
let files = self.environment().native_files()?;
let exists = |name: &str| -> Result<Option<String>, String> {
check()?;
let candidate = crate::files::clean(&directory.join(name.trim_start_matches('/')));
let candidate = candidate.to_string_lossy();
let input = files.resolve_legacy(&candidate)?;
input.native_display()?;
Ok(input.metadata().is_ok().then(|| candidate.into_owned()))
};
if let Some(path) = exists(name)? {
return Ok(path);
}
let suffix = library_extension(name, "");
let stem = if suffix.is_empty() {
name
} else {
&name[..name.len() - suffix.len() - 1]
};
let extensions = [
"flac", "wav", "aiff", "aif", "ape", "mp3", "ogg", "wv", "m4a",
];
for extension in extensions {
for extension in [extension.to_owned(), extension.to_uppercase()] {
if let Some(path) = exists(&format!("{stem}.{extension}"))? {
return Ok(path);
}
}
}
let stem = Path::new(path)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or_default();
for extension in extensions {
if let Some(path) = exists(&format!("{stem}.{extension}"))? {
return Ok(path);
}
}
let directory_path = files.resolve_legacy(&directory.to_string_lossy())?;
directory_path.native_display()?;
if let Ok(entries) = directory_path.entries() {
let mut candidates = entries.into_iter().filter(|(name, directory)| {
!directory
&& matches!(
library_extension(name, "").as_str(),
"flac" | "wav" | "ape" | "mp3" | "ogg" | "wv" | "m4a" | "aiff"
)
});
if let Some((name, _)) = candidates.next()
&& candidates.next().is_none()
&& let Some(path) = exists(&name)?
{
return Ok(path);
}
}
check()?;
Err(format!(
"audio file not found for cue: {path} (referenced: {name})"
))
}
pub fn parse_cue_file_json(
&self,
path: &str,
audio_directory: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Value, String> {
let _operation = self.enter()?;
let check = || self.check().and_then(|()| check());
let sheet = self
.parse_cue_file(path, &check)
.map_err(|error| format!("failed to parse cue file: {error}"))?;
let audio = self
.resolve_cue_audio(path, &sheet.file_name, audio_directory, &check)
.map_err(|error| {
error.replace(
"audio file not found for cue:",
"audio file not found for cue sheet:",
)
})?;
let tracks: Vec<Value> = sheet
.tracks
.iter()
.enumerate()
.map(|(index, track)| {
let mut value = json!({"number":track.number,"title":track.title,
"artist":prefer(&track.performer, &sheet.performer),"start_sec":track.start_time,
"end_sec":next_start(&sheet, index).unwrap_or(-1.0)});
optional(&mut value, "isrc", &track.isrc);
optional(
&mut value,
"composer",
prefer(&track.composer, &sheet.composer),
);
value
})
.collect();
check()?;
let mut result = json!({"cue_path":path,"audio_path":audio,"album":sheet.title,"artist":sheet.performer,"tracks":tracks});
optional(&mut result, "genre", &sheet.genre);
optional(&mut result, "date", &sheet.date);
Ok(result)
}
#[allow(clippy::too_many_arguments)]
pub fn scan_cue_file_for_library(
&self,
path: &str,
audio_directory: &str,
virtual_prefix: &str,
mod_time: i64,
cache_key: &str,
scan_time: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Value, String> {
let _operation = self.enter()?;
let failure = RefCell::new(None::<String>);
let check = || {
if let Some(error) = failure.borrow().as_ref() {
return Err(error.clone());
}
self.check().and_then(|()| check()).inspect_err(|error| {
*failure.borrow_mut() = Some(error.clone());
})
};
let sheet = self.parse_cue_file(path, &check)?;
let audio = self.resolve_cue_audio(path, &sheet.file_name, audio_directory, &check)?;
self.scan_cue_sheet(
path,
&sheet,
&audio,
virtual_prefix,
mod_time,
cache_key,
scan_time,
&check,
)
}
#[allow(clippy::too_many_arguments)]
fn scan_cue_sheet(
&self,
path: &str,
sheet: &CueSheet,
audio: &str,
virtual_prefix: &str,
mod_time: i64,
cache_key: &str,
scan_time: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Value, String> {
let files = self.environment().native_files()?;
let input = files.resolve_legacy(audio)?;
input.native_display()?;
let format = library_extension(audio, "");
let mut depth = 0;
let mut rate = 0;
let mut duration = 0.0;
if let Ok(mut file) = input.open(OpenOptions::new().read(true)) {
if format == "flac" {
if let Ok(quality) = spotiflac_core::media::probe_quality(&mut file, check) {
depth = quality.bit_depth;
rate = quality.sample_rate;
if rate > 0 && quality.total_samples > 0 {
duration = quality.total_samples as f64 / rate as f64;
}
}
} else if format == "mp3"
&& let Ok(metadata) =
spotiflac_core::tags::read_file_metadata(&mut file, audio, "", check)
{
rate = metadata["sample_rate"].as_i64().unwrap_or_default();
duration = metadata["duration"].as_f64().unwrap_or_default();
}
}
check()?;
let directory = self
.library_cover_directory
.lock()
.expect("library cover directory lock")
.clone();
let cover = if directory.is_empty() {
String::new()
} else {
self.save_cover_to_cache_with_hint_and_key(audio, "", &directory, cache_key, check)
.unwrap_or_default()
};
check()?;
let mod_time = if mod_time > 0 {
mod_time
} else {
modified(&files.resolve_legacy(path)?)
};
let prefix = prefer(virtual_prefix, path);
let mut results = Vec::with_capacity(sheet.tracks.len());
for (index, track) in sheet.tracks.iter().enumerate() {
check()?;
let end = next_start(sheet, index).or_else(|| (duration > 0.0).then_some(duration));
let mut result = json!({
"id":library_id(&format!("{prefix}#track{}", track.number)),
"filePath":format!("{prefix}#track{:02}", track.number),
"trackName":if track.title.is_empty() { format!("Track {:02}", track.number) } else { track.title.clone() },
"artistName":prefer(prefer(&track.performer, &sheet.performer), "Unknown Artist"),
"albumName":prefer(&sheet.title, "Unknown Album"), "scannedAt":scan_time,
"hasLyrics":false,"totalTracks":sheet.tracks.len(),"discNumber":1,"totalDiscs":1,
"format":format!("cue+{format}"),
});
for (key, value) in [
("fileModTime", mod_time),
("trackNumber", track.number),
(
"duration",
end.map_or(0, |end| (end - track.start_time) as i64),
),
("bitDepth", depth),
("sampleRate", rate),
] {
if value != 0 {
result[key] = value.into();
}
}
for (key, value) in [
("albumArtist", sheet.performer.as_str()),
("coverPath", &cover),
("isrc", &track.isrc),
("releaseDate", &sheet.date),
("genre", &sheet.genre),
("composer", prefer(&track.composer, &sheet.composer)),
] {
optional(&mut result, key, value);
}
results.push(result);
}
Ok(results.into())
}
}
fn scan_time() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
}
fn modified(path: &crate::files::FilePath) -> i64 {
path.metadata()
.and_then(|metadata| metadata.modified())
.map(|time| match time.into_std().duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_millis() as i64,
Err(error) => -((error.duration().as_nanos().div_ceil(1_000_000)) as i64),
})
.unwrap_or_default()
}
fn prefer<'a>(value: &'a str, fallback: &'a str) -> &'a str {
if value.is_empty() { fallback } else { value }
}
fn optional(result: &mut Value, key: &str, value: &str) {
if !value.is_empty() {
result[key] = value.into();
}
}
fn next_start(sheet: &CueSheet, index: usize) -> Option<f64> {
sheet.tracks.get(index + 1).map(|track| {
if track.pre_gap >= 0.0 {
track.pre_gap
} else {
track.start_time
}
})
}
@@ -0,0 +1,592 @@
//! One scan owner, bounded worker results and shared full/incremental traversal.
use super::{Backend, library_extension, modified, scan_time};
use cap_std::fs::OpenOptions;
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
use std::io::{BufRead, BufReader, BufWriter, Read, Write};
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, TryLockError, mpsc};
use std::thread;
use std::time::Duration;
type Check<'a> = &'a (dyn Fn() -> Result<(), String> + Sync);
#[derive(Default)]
pub(in crate::backend) struct ScanState {
current: Mutex<Option<Arc<Run>>>,
owner: Mutex<()>,
}
#[derive(Default)]
struct Run {
cancelled: AtomicBool,
failure: Mutex<Option<String>>,
progress: Mutex<Progress>,
}
#[derive(Default, Serialize)]
struct Progress {
total_files: usize,
scanned_files: usize,
current_file: String,
error_count: usize,
progress_pct: f64,
is_complete: bool,
}
struct AudioFile {
path: String,
modified: i64,
size: u64,
}
struct ScanSummary {
deleted: Vec<String>,
skipped: usize,
total: usize,
run: Arc<Run>,
}
impl Backend {
pub fn get_library_scan_progress(&self) -> Result<Value, String> {
let _operation = self.enter()?;
let current = self.library_scan.current.lock().expect("library scan lock");
let value = match current.as_ref() {
Some(run) => serde_json::to_value(&*run.progress.lock().expect("scan progress lock")),
None => serde_json::to_value(Progress::default()),
};
value.map_err(|error| error.to_string())
}
pub fn cancel_library_scan(&self) -> Result<(), String> {
let _operation = self.enter()?;
if let Some(run) = self
.library_scan
.current
.lock()
.expect("library scan lock")
.as_ref()
{
run.cancelled.store(true, Ordering::Release);
}
Ok(())
}
pub fn scan_library_folder(&self, folder: &str, check: Check<'_>) -> Result<Value, String> {
let mut tracks = Vec::new();
self.scan_library(
folder,
None,
true,
&mut |value| {
tracks.push(value);
Ok(())
},
check,
)?;
Ok(tracks.into())
}
pub fn scan_library_folder_incremental(
&self,
folder: &str,
existing_json: &str,
check: Check<'_>,
) -> Result<Value, String> {
// Go tolerates invalid snapshot JSON. A bad value does not discard other
// valid paths from an otherwise well-formed object.
let value: Value = serde_json::from_str(existing_json).unwrap_or(Value::Null);
let existing = value
.as_object()
.map(|object| {
object
.iter()
.filter_map(|(path, value)| {
value
.as_i64()
.or_else(|| value.is_null().then_some(0))
.map(|value| (path.clone(), value))
})
.collect()
})
.unwrap_or_default();
self.incremental(folder, existing, check)
}
pub fn scan_library_folder_incremental_from_snapshot(
&self,
folder: &str,
snapshot: &str,
check: Check<'_>,
) -> Result<Value, String> {
let _operation = self.enter()?;
let mut existing = BTreeMap::new();
if !snapshot.is_empty() {
self.check()?;
check()?;
let input = self
.environment()
.native_files()?
.resolve_legacy(snapshot)?;
input.native_display()?;
let file = input
.open(OpenOptions::new().read(true))
.map_err(|error| format!("failed to load incremental snapshot: {error}"))?;
let mut reader = BufReader::new(file);
let mut line = Vec::new();
loop {
self.check()?;
check()?;
line.clear();
let read = (&mut reader)
.take(64 * 1024)
.read_until(b'\n', &mut line)
.map_err(|error| format!("failed to load incremental snapshot: {error}"))?;
if read == 0 {
break;
}
if read == 64 * 1024 && line.last() != Some(&b'\n') {
return Err(
"failed to load incremental snapshot: bufio.Scanner: token too long".into(),
);
}
if line.last() == Some(&b'\n') {
line.pop();
}
if line.last() == Some(&b'\r') {
line.pop();
}
let line = String::from_utf8_lossy(&line);
if let Some((time, path)) = line.split_once('\t')
&& let Ok(time) = time.parse::<i64>()
{
existing.insert(path.into(), time);
}
}
}
self.incremental(folder, existing, check)
}
fn incremental(
&self,
folder: &str,
existing: BTreeMap<String, i64>,
check: Check<'_>,
) -> Result<Value, String> {
let mut tracks = Vec::new();
let summary = self.scan_library(
folder,
Some(&existing),
true,
&mut |value| {
tracks.push(value);
Ok(())
},
check,
)?;
Ok(
json!({"scanned":tracks,"deletedPaths":if summary.deleted.is_empty() { Value::Null } else { json!(summary.deleted) },"skippedCount":summary.skipped,"totalFiles":summary.total}),
)
}
pub fn scan_library_folder_to_ndjson_file(
&self,
folder: &str,
output: &str,
check: Check<'_>,
) -> Result<u64, String> {
let _operation = self.enter()?;
let check = || self.check().and_then(|()| check());
check()?;
if output.is_empty() {
return Err("output path is empty".into());
}
if supported(output) {
return Err("scan output must not overwrite an audio or CUE file".into());
}
let files = self.environment().native_files()?;
let output = files.resolve_legacy(output)?;
output.native_display()?;
let _lock = files.lock(&output, &check)?;
let mut stage = output
.stage_existing_parent()
.map_err(|error| format!("create scan output: {error}"))?;
let mut count = 0;
let summary;
{
let mut writer = BufWriter::with_capacity(64 * 1024, &mut stage.file);
summary = self.scan_library(
folder,
None,
false,
&mut |value| {
serde_json::to_writer(&mut writer, &value)
.map_err(|error| error.to_string())?;
writer.write_all(b"\n").map_err(|error| error.to_string())?;
count += 1;
Ok(())
},
&check,
)?;
writer
.flush()
.map_err(|error| format!("flush scan output: {error}"))?;
}
stage.publish(&|| {
check()?;
if summary.run.cancelled.load(Ordering::Acquire) {
Err("scan cancelled".into())
} else {
Ok(())
}
})?;
Ok(count)
}
fn collect_library_files(
&self,
folder: &str,
check: Check<'_>,
) -> Result<Vec<AudioFile>, String> {
let files = self.environment().native_files()?;
let mut pending = vec![folder.to_owned()];
let mut collected = Vec::new();
while let Some(path) = pending.pop() {
check()?;
let input = files.resolve_legacy(&path)?;
input.native_display()?;
let metadata = input
.metadata()
.map_err(|error| format!("walk library path {path}: {error}"))?;
if metadata.is_dir() {
let entries = input
.entries()
.map_err(|error| format!("walk library path {path}: {error}"))?;
for (name, directory) in entries.into_iter().rev() {
if !directory && (!supported(&name) || staging(&name)) {
continue;
}
pending.push(
crate::files::clean(&Path::new(&path).join(name))
.to_string_lossy()
.into_owned(),
);
}
} else if supported(&path) && !staging(&path) {
collected.push(AudioFile {
path,
modified: modified(&input),
size: metadata.len(),
});
}
}
Ok(collected)
}
fn scan_library(
&self,
folder: &str,
existing: Option<&BTreeMap<String, i64>>,
ordered: bool,
sink: &mut dyn FnMut(Value) -> Result<(), String>,
check: Check<'_>,
) -> Result<ScanSummary, String> {
let _operation = self.enter()?;
check()?;
if folder.is_empty() {
return Err("folder path is empty".into());
}
// Retain the app data capability for this scan instead of reopening its
// directory for every track. Path resolution still checks live grants.
let files = self.environment().native_files()?;
let input = files.resolve_legacy(folder)?;
input.native_display()?;
let metadata = input
.metadata()
.map_err(|error| format!("folder not found: {error}"))?;
if !metadata.is_dir() {
return Err(format!("path is not a folder: {folder}"));
}
let run = Arc::new(Run::default());
if let Some(previous) = self
.library_scan
.current
.lock()
.expect("library scan lock")
.replace(run.clone())
{
previous.cancelled.store(true, Ordering::Release);
}
let check = || {
let mut failure = run.failure.lock().expect("scan failure lock");
if let Some(error) = failure.as_ref() {
return Err(error.clone());
}
let result = self.check().and_then(|()| {
if run.cancelled.load(Ordering::Acquire) {
Err("scan cancelled".into())
} else {
check()
}
});
if let Err(error) = &result {
*failure = Some(error.clone());
}
result
};
let _owner = loop {
check()?;
match self.library_scan.owner.try_lock() {
Ok(owner) => break owner,
Err(TryLockError::WouldBlock) => thread::sleep(Duration::from_millis(5)),
Err(TryLockError::Poisoned(_)) => return Err("library scan owner poisoned".into()),
}
};
let current = self.collect_library_files(folder, &check)?;
let total = current.len();
run.progress.lock().expect("scan progress lock").total_files = total;
let paths: BTreeSet<_> = current.iter().map(|file| file.path.as_str()).collect();
let mut cue_times = BTreeMap::new();
let mut deleted = Vec::new();
if let Some(existing) = existing {
for (path, time) in existing {
let base = cue_base(path);
if let Some(base) = base {
cue_times.entry(base).or_insert(*time);
}
if !paths.contains(base.unwrap_or(path)) {
deleted.push(path.clone());
}
}
}
let mut skipped = 0;
let tasks: Vec<_> = current
.iter()
.filter(|file| {
let old = existing
.and_then(|existing| existing.get(&file.path))
.copied()
.or_else(|| {
(library_extension(&file.path, "") == "cue")
.then(|| cue_times.get(file.path.as_str()).copied())
.flatten()
});
if old == Some(file.modified) {
skipped += 1;
false
} else {
true
}
})
.collect();
let time = scan_time();
let mut referenced = BTreeSet::new();
let mut parsed = BTreeMap::new();
// An unchanged CUE still owns its backing audio. Inspect all current
// sheets so incremental scans never re-add that audio as a full album.
for file in &current {
check()?;
if library_extension(&file.path, "") == "cue"
&& let Ok(sheet) = self.parse_cue_file(&file.path, &check)
&& !sheet.file_name.is_empty()
&& let Ok(audio) = self.resolve_cue_audio(&file.path, &sheet.file_name, "", &check)
{
referenced.insert(audio.clone());
parsed.insert(file.path.as_str(), (sheet, audio));
}
}
check()?;
let mut results: BTreeMap<usize, Vec<Value>> = BTreeMap::new();
let mut audio = Vec::new();
let mut completed = skipped;
for (index, file) in tasks.iter().enumerate() {
check()?;
if library_extension(&file.path, "") == "cue" {
let result = match parsed.get(file.path.as_str()) {
Some((sheet, audio)) => self.scan_cue_sheet(
&file.path,
sheet,
audio,
"",
file.modified,
"",
&time,
&check,
),
None => self.scan_cue_file_for_library(
&file.path,
"",
"",
file.modified,
"",
&time,
&check,
),
}
.map(|value| value.as_array().expect("cue track array").clone());
check()?;
completed += 1;
update(&run, completed, &file.path, result.is_err());
if let Ok(tracks) = result {
if ordered {
results.insert(index, tracks);
} else {
for value in tracks {
sink(value)?;
}
}
}
} else if referenced.contains(&file.path) {
completed += 1;
update(&run, completed, &file.path, false);
} else {
audio.push((index, *file));
}
}
// Allow short bursts without parking workers after each result. At most
// 64 completed tracks wait; NDJSON still uses bounded memory.
let workers = if audio.len() < 16 {
1
} else {
thread::available_parallelism()
.map_or(2, usize::from)
.clamp(2, 4)
};
let next = AtomicUsize::new(0);
let stop = AtomicBool::new(false);
thread::scope(|scope| -> Result<(), String> {
let (sender, receiver) = mpsc::sync_channel(workers * 16);
for _ in 0..workers {
let sender = sender.clone();
let (audio, next, stop, check, time, files) =
(&audio, &next, &stop, &check, &time, &files);
scope.spawn(move || {
while !stop.load(Ordering::Acquire) {
let Some((index, file)) = audio.get(next.fetch_add(1, Ordering::AcqRel))
else {
break;
};
let result = check().and_then(|()| {
self.scan_audio_file(
files,
&file.path,
"",
&format!("{}|{}|{}", file.path, file.size, file.modified),
time,
file.modified,
check,
)
});
if sender.send((*index, file.path.as_str(), result)).is_err() {
break;
}
}
});
}
drop(sender);
let mut failure = None;
for (index, path, result) in receiver {
completed += 1;
update(&run, completed, path, result.is_err());
if failure.is_none() {
let emit = check().and_then(|()| {
if let Ok(value) = result {
if ordered {
results.insert(index, vec![value]);
} else {
sink(value)?;
}
}
Ok(())
});
if let Err(error) = emit {
failure = Some(error);
stop.store(true, Ordering::Release);
}
}
}
failure.map_or(Ok(()), Err)
})?;
check()?;
for (_, values) in results {
for value in values {
check()?;
sink(value)?;
}
}
check()?;
// Share the existing duplicate index. A scan may observe edits/deletes
// after a previously cached lookup; never retain that stale snapshot.
if existing.is_none() || !tasks.is_empty() || !deleted.is_empty() {
self.environment()
.invalidate_isrc_cache(folder)
.map_err(|error| error.to_string())?;
}
let mut progress = run.progress.lock().expect("scan progress lock");
progress.scanned_files = total;
progress.is_complete = true;
progress.progress_pct = 100.0;
drop(progress);
Ok(ScanSummary {
deleted,
skipped,
total,
run,
})
}
}
fn cue_base(path: &str) -> Option<&str> {
path.rfind("#track")
.filter(|index| *index > 0)
.map(|index| &path[..index])
}
fn supported(path: &str) -> bool {
matches!(
library_extension(path, "").as_str(),
"flac"
| "m4a"
| "mp4"
| "aac"
| "mp3"
| "opus"
| "ogg"
| "ape"
| "wv"
| "mpc"
| "wav"
| "aiff"
| "aif"
| "cue"
)
}
fn staging(path: &str) -> bool {
let name = Path::new(path)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
name.ends_with(".partial")
|| name
.rsplit_once('.')
.is_some_and(|(stem, _)| stem.ends_with(".partial"))
}
fn update(run: &Run, completed: usize, path: &str, error: bool) {
let mut progress = run.progress.lock().expect("scan progress lock");
progress.scanned_files = completed;
progress.current_file = Path::new(path)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
progress.error_count += usize::from(error);
if progress.total_files > 0 {
progress.progress_pct = completed as f64 / progress.total_files as f64 * 100.0;
}
}
@@ -0,0 +1,263 @@
use super::Backend;
use crate::files::ExtensionFiles;
use cap_std::fs::OpenOptions;
use serde::{Deserialize, Serialize};
use spotiflac_core::lyrics::{LyricsResponse, config, file, lrc};
use spotiflac_providers::lyrics::{Check, SearchRequest};
use std::collections::BTreeMap;
use std::io::Write;
/// Native export arguments. Unlike the private JS SDK, file_path is not trimmed.
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
pub struct LyricsRequest {
pub spotify_id: String,
pub track: String,
pub artist: String,
pub file_path: String,
pub duration_ms: i64,
}
#[derive(Default, Serialize)]
struct LrcResult {
lyrics: String,
source: String,
sync_type: String,
instrumental: bool,
}
impl Backend {
pub fn embed_lyrics_to_file(
&self,
path: &str,
lyrics: &str,
check: &Check<'_>,
) -> Result<String, String> {
let fields = BTreeMap::from([
("LYRICS".into(), lyrics.into()),
("UNSYNCEDLYRICS".into(), lyrics.into()),
]);
self.embed_flac_fields_response(
path,
&fields,
"",
("Failed to embed lyrics", "Lyrics embedded successfully"),
check,
)
}
pub fn get_lyrics_lrc(
&self,
request: &LyricsRequest,
check: &Check<'_>,
) -> Result<String, String> {
let _operation = self.enter()?;
let check = || self.check().and_then(|()| check());
Ok(self.lrc_result(request, &check)?.lyrics)
}
pub fn get_lyrics_lrc_with_source(
&self,
request: &LyricsRequest,
check: &Check<'_>,
) -> Result<String, String> {
let _operation = self.enter()?;
let check = || self.check().and_then(|()| check());
serde_json::to_string(&self.lrc_result(request, &check)?).map_err(|error| error.to_string())
}
fn lrc_result(&self, request: &LyricsRequest, check: &Check<'_>) -> Result<LrcResult, String> {
check()?;
if !request.file_path.is_empty() {
let files = self.manager.environment().native_files()?;
return Ok(match existing(&files, &request.file_path, check)? {
Some(lyrics) => {
let source = lrc::extract_source(&lyrics);
LrcResult {
source: if source.is_empty() {
"Embedded".into()
} else {
source
},
sync_type: "EMBEDDED".into(),
instrumental: lrc::is_instrumental_marker(&lyrics),
lyrics,
}
}
None => LrcResult::default(),
});
}
let response = self.fetch_lyrics(request, check)?;
Ok(LrcResult {
lyrics: render(&response, request),
source: response.source,
sync_type: response.sync_type,
instrumental: response.instrumental,
})
}
pub(super) fn fetch_lyrics(
&self,
request: &LyricsRequest,
check: &Check<'_>,
) -> Result<LyricsResponse, String> {
let response = self
.lyrics
.fetch(
SearchRequest {
spotify_id: request.spotify_id.clone(),
track: request.track.clone(),
artist: request.artist.clone(),
duration: request.duration_ms as f64 / 1000.0,
..SearchRequest::default()
},
check,
)
.map_err(|error| error.to_string())?;
check()?;
Ok(response)
}
pub fn get_lyrics_providers_json(&self) -> Result<String, String> {
let _operation = self.enter()?;
serde_json::to_string(&self.lyrics.providers()).map_err(|error| error.to_string())
}
pub fn set_lyrics_providers_json(&self, raw: &str) -> Result<(), String> {
let _operation = self.enter()?;
let providers = config::decode_providers(raw).map_err(|error| error.to_string())?;
self.lyrics
.set_providers(&providers)
.map_err(|error| error.to_string())
}
pub fn get_available_lyrics_providers_json(&self) -> Result<String, String> {
let _operation = self.enter()?;
serde_json::to_string(&config::available_providers()).map_err(|error| error.to_string())
}
pub fn get_lyrics_fetch_options_json(&self) -> Result<String, String> {
let _operation = self.enter()?;
serde_json::to_string(&self.lyrics.options()).map_err(|error| error.to_string())
}
pub fn set_lyrics_fetch_options_json(&self, raw: &str) -> Result<(), String> {
let _operation = self.enter()?;
let _settings = self.lyrics_settings.lock().expect("lyrics settings lock");
let mut options = self.lyrics.options();
options
.update_json(raw)
.map_err(|error| error.to_string())?;
self.lyrics
.set_options(options)
.map_err(|error| error.to_string())
}
pub fn fetch_and_save_lyrics(
&self,
request: &LyricsRequest,
output_path: &str,
check: &Check<'_>,
) -> Result<(), String> {
let _operation = self.enter()?;
let check = || self.check().and_then(|()| check());
check()?;
let files = self.manager.environment().native_files()?;
let previous = if request.file_path.is_empty() {
None
} else {
existing(&files, &request.file_path, &check)?
};
let (content, message) = if let Some(content) = previous {
(
content,
format!("Saved LRC from embedded/sidecar to: {output_path}"),
)
} else {
let response = self
.fetch_lyrics(request, &check)
.map_err(|error| format!("lyrics not found: {error}"))?;
if response.instrumental {
return Err("track is instrumental, no lyrics available".into());
}
let content = render(&response, request);
if content.is_empty() {
return Err("failed to generate LRC content".into());
}
let message = format!(
"Saved LRC to: {output_path} ({} lines)",
response.lines().len()
);
(content, message)
};
let write = || -> Result<(), String> {
let output = files.resolve_legacy(output_path)?;
let _output = files.lock(&output, &check)?;
check()?;
// Go WriteFile does not create missing parents. Reject symlinks and
// non-regular destinations before an atomic replacement as well.
output.native_display()?;
output.require_parent()?;
let permissions = match output.open(OpenOptions::new().write(true)) {
Ok(file) => Some(
file.metadata()
.map_err(|error| error.to_string())?
.permissions(),
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.to_string()),
};
let mut stage = output
.stage_existing_parent()
.map_err(|error| error.to_string())?;
if let Some(permissions) = permissions {
stage
.file
.set_permissions(permissions)
.map_err(|error| error.to_string())?;
}
for chunk in content.as_bytes().chunks(16 * 1024) {
check()?;
stage
.file
.write_all(chunk)
.map_err(|error| error.to_string())?;
}
stage.publish(&check)
};
write().map_err(|error| format!("failed to write LRC file: {error}"))?;
let _ = self
.manager
.environment()
.log_buffer()
.add("INFO", "Lyrics", &message);
Ok(())
}
}
fn render(response: &LyricsResponse, request: &LyricsRequest) -> String {
if response.instrumental {
"[instrumental:true]".into()
} else {
lrc::with_metadata(response, &request.track, &request.artist)
}
}
fn existing(
files: &ExtensionFiles,
path: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Option<String>, String> {
let result = file::extract(
path,
&|path| {
files
.resolve_legacy(path)?
.open(OpenOptions::new().read(true))
.map_err(|error| error.to_string())
},
check,
);
check()?;
Ok(result.ok().filter(|text| lrc::has_usable_content(text)))
}
@@ -0,0 +1,591 @@
use super::Backend;
use serde::Serialize;
use serde_json::{Value, json};
use spotiflac_core::metadata::TrackMetadata;
use spotiflac_providers::deezer::{self, MetadataLookup};
use spotiflac_providers::musicbrainz::MusicBrainzOptions;
use spotiflac_providers::resolver::{Check, ResolverError, ResolverOptions};
use std::time::{Duration, Instant};
/// Trusted native configuration, never extension arguments or persisted settings.
pub struct MetadataOptions {
pub deezer_endpoint: String,
pub resolver: ResolverOptions,
pub musicbrainz: MusicBrainzOptions,
}
impl Default for MetadataOptions {
fn default() -> Self {
Self {
deezer_endpoint: "https://api.deezer.com".into(),
resolver: ResolverOptions::default(),
musicbrainz: MusicBrainzOptions::default(),
}
}
}
fn encode(value: &impl Serialize) -> Result<String, ResolverError> {
serde_json::to_string(value).map_err(|error| ResolverError::Failed(error.to_string()))
}
fn context(error: ResolverError, prefix: &str) -> ResolverError {
match error {
ResolverError::Cancelled(_) | ResolverError::Closed | ResolverError::Busy => error,
_ => ResolverError::Failed(format!("{prefix}: {error}")),
}
}
fn isrc_result(track: &TrackMetadata) -> Value {
// Go's public map includes fields omitted by the ordinary track serializer.
let mut result = json!({
"spotify_id": track.spotify_id, "artists": track.artists, "name": track.name,
"album_name": track.album_name, "album_artist": track.album_artist,
"duration_ms": track.duration_ms, "images": track.images,
"release_date": track.release_date, "track_number": track.track_number,
"total_tracks": track.total_tracks, "disc_number": track.disc_number,
"total_discs": track.total_discs, "external_urls": track.external_urls,
"isrc": track.isrc, "album_id": track.album_id, "artist_id": track.artist_id,
"album_type": track.album_type, "composer": track.composer
});
let id = track
.spotify_id
.strip_prefix("deezer:")
.unwrap_or(&track.spotify_id)
.trim();
if !id.is_empty() {
result["id"] = json!(id);
result["track_id"] = json!(id);
result["success"] = json!(true);
}
result
}
impl Backend {
pub(super) fn metadata_operation<T>(
&self,
seconds: u64,
check: &Check<'_>,
work: impl FnOnce(&Check<'_>) -> Result<T, ResolverError>,
) -> Result<T, String> {
let _operation = self.enter()?;
let started = Instant::now();
let check = || {
self.check()?;
check()?;
if started.elapsed() >= Duration::from_secs(seconds) {
Err("context deadline exceeded".into())
} else {
Ok(())
}
};
check()?;
let result = work(&check);
check()?;
result.map_err(|error| error.to_string())
}
pub fn set_metadata_language(&self, tag: &str) -> Result<(), String> {
let _operation = self.enter()?;
self.deezer.set_language(tag);
Ok(())
}
pub fn set_song_link_region(&self, region: &str) -> Result<(), String> {
let _operation = self.enter()?;
self.availability
.set_region(region)
.map_err(|error| error.to_string())
}
pub fn get_song_link_region(&self) -> Result<String, String> {
let _operation = self.enter()?;
self.availability
.region()
.map_err(|error| error.to_string())
}
// The retired Settings track-ID cache is distinct from catalog caches.
pub fn get_track_cache_size(&self) -> Result<u64, String> {
let _operation = self.enter()?;
Ok(0)
}
pub fn clear_track_id_cache(&self) -> Result<(), String> {
let _operation = self.enter()?;
Ok(())
}
pub fn parse_deezer_url(&self, url: &str) -> Result<(String, String), String> {
let _operation = self.enter()?;
deezer::parse_url(url).map_err(|error| error.to_string())
}
pub fn search_deezer(
&self,
query: &str,
track_limit: isize,
artist_limit: isize,
filter: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
encode(
self.deezer
.search_all(query, track_limit, artist_limit, filter, check)?
.as_ref(),
)
})
}
pub fn get_deezer_metadata(
&self,
kind: &str,
id: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| self.deezer_metadata(kind, id, check))
}
pub(super) fn deezer_metadata(
&self,
kind: &str,
id: &str,
check: &Check<'_>,
) -> Result<String, ResolverError> {
match kind {
"track" => encode(&json!({"track": self.deezer.get_track(id, check)?})),
"album" => encode(self.deezer.get_album(id, check)?.as_ref()),
"artist" => encode(self.deezer.get_artist(id, check)?.as_ref()),
"playlist" => encode(&self.deezer.get_playlist(id, check)?),
_ => Err(ResolverError::Failed(format!(
"unsupported Deezer resource type: {kind}"
))),
}
}
pub fn get_deezer_extended_metadata(
&self,
id: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(15, check, |check| {
if id.is_empty() { return Err(ResolverError::Failed("empty track ID".into())); }
let metadata = self.deezer.get_extended_metadata_by_track_id(id, check)?;
encode(&json!({"genre":metadata.genre,"label":metadata.label,"copyright":metadata.copyright}))
})
}
pub fn search_deezer_by_isrc(&self, isrc: &str, check: &Check<'_>) -> Result<String, String> {
self.search_deezer_by_isrc_for_item_id(isrc, "", check)
}
pub fn search_deezer_by_isrc_for_item_id(
&self,
isrc: &str,
item_id: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(10, check, |check| {
let state = self.manager.environment().download_state();
let lease = if item_id.is_empty() {
None
} else {
Some(
state
.cancellation
.acquire(item_id)
.map_err(|error| ResolverError::Cancelled(error.to_string()))?,
)
};
let check = || {
check()?;
lease.as_ref().map_or(Ok(()), |lease| {
lease.check_active().map_err(|error| error.to_string())
})
};
check().map_err(ResolverError::Cancelled)?;
let track = self.deezer.search_by_isrc(isrc, &check);
check().map_err(ResolverError::Cancelled)?;
encode(&isrc_result(&track?))
})
}
pub fn convert_spotify_to_deezer(
&self,
kind: &str,
id: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
let (deezer_id, fetch_error) = match kind {
"track" => (self.deezer_id_from_spotify(id, check).map_err(|error| context(error, "could not find Deezer equivalent"))?, "failed to fetch Deezer metadata"),
"album" => (self.availability.deezer_album_id(id, check).map_err(|error| context(error, "could not find Deezer album"))?, "failed to fetch Deezer album metadata"),
_ => return Err(ResolverError::Failed(format!("spotify to Deezer conversion only supported for tracks and albums: please search by name for {kind}"))),
};
self.deezer_metadata(kind, &deezer_id, check).map_err(|error| context(error, fetch_error))
})
}
fn deezer_id_from_spotify(&self, id: &str, check: &Check<'_>) -> Result<String, ResolverError> {
let track = self.availability.check_track(id, "", check)?;
if track.deezer && !track.deezer_id.is_empty() {
Ok(track.deezer_id)
} else {
Err(ResolverError::Failed("track not found on Deezer".into()))
}
}
pub fn get_track_platform_links_json(
&self,
id: &str,
isrc: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
encode(&json!({"platforms":self.availability.track_platform_links(id, isrc, check)?}))
})
}
pub fn check_track_availability_json(
&self,
id: &str,
isrc: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
encode(&self.availability.check_track(id, isrc, check)?)
})
}
pub fn check_album_availability_json(
&self,
id: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
encode(&self.availability.check_album(id, check)?)
})
}
pub fn check_availability_from_deezer_json(
&self,
id: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
encode(&self.availability.check_from_deezer(id, check)?)
})
}
pub fn check_availability_by_platform_json(
&self,
platform: &str,
kind: &str,
id: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
encode(
&self
.availability
.check_by_platform(platform, kind, id, check)?,
)
})
}
pub fn check_availability_from_url_json(
&self,
url: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
encode(&self.availability.check_from_url(url, check)?)
})
}
pub fn get_streaming_urls_json(&self, id: &str, check: &Check<'_>) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
encode(&self.availability.streaming_urls(id, check)?)
})
}
pub fn get_deezer_id_from_spotify(
&self,
id: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| self.deezer_id_from_spotify(id, check))
}
pub fn get_deezer_album_id_from_spotify(
&self,
id: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
self.availability.deezer_album_id(id, check)
})
}
pub fn get_youtube_url_from_spotify(
&self,
id: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
self.availability.youtube_url_from_spotify(id, check)
})
}
pub fn get_spotify_id_from_deezer_track(
&self,
id: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
self.availability.platform_from_deezer(id, "spotify", check)
})
}
pub fn get_tidal_url_from_deezer_track(
&self,
id: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
self.availability.platform_from_deezer(id, "tidal", check)
})
}
pub fn get_amazon_url_from_deezer_track(
&self,
id: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
self.availability.platform_from_deezer(id, "amazon", check)
})
}
pub fn get_youtube_url_from_deezer_track(
&self,
id: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
self.availability.platform_from_deezer(id, "youtube", check)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::RuntimeLimits;
use crate::environment::ExtensionEnvironment;
use spotiflac_network::{NetworkOptions, NetworkService};
use std::io::{BufRead, BufReader, ErrorKind, Write};
use std::net::TcpListener;
use std::sync::{Arc, mpsc};
use std::thread;
#[test]
fn isrc_http_cancellation_does_not_cancel_another_item() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let certificate = rcgen::generate_simple_self_signed(vec!["127.0.0.1".into()]).unwrap();
let config = Arc::new(
rustls::ServerConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.unwrap()
.with_no_client_auth()
.with_single_cert(
vec![certificate.cert.der().clone()],
rustls::pki_types::PrivatePkcs8KeyDer::from(
certificate.signing_key.serialize_der(),
)
.into(),
)
.unwrap(),
);
let network = NetworkService::with_options(NetworkOptions {
extra_root_pem: certificate.cert.pem().into_bytes(),
doh_upstreams: vec![],
..Default::default()
})
.unwrap();
network.set_allow_private_network(true);
let root = tempfile::tempdir().unwrap();
let environment = ExtensionEnvironment::with_network(
&root.path().join("data"),
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"1",
network,
)
.unwrap();
let backend = Arc::new(
Backend::with_environment(
&root.path().join("sources"),
environment,
RuntimeLimits::default(),
MetadataOptions {
deezer_endpoint: format!("https://{}", listener.local_addr().unwrap()),
..Default::default()
},
)
.unwrap(),
);
let start = |isrc: &'static str, item: &'static str| {
let owner = backend.clone();
let (finished, completion) = mpsc::channel();
let worker = thread::spawn(move || {
finished
.send(owner.search_deezer_by_isrc_for_item_id(isrc, item, &|| Ok(())))
.unwrap();
});
(worker, completion)
};
let accept = |isrc: &str| {
let deadline = Instant::now() + Duration::from_secs(3);
let stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(error)
if error.kind() == ErrorKind::WouldBlock && Instant::now() < deadline =>
{
thread::sleep(Duration::from_millis(1));
}
Err(error) => panic!("ISRC request did not arrive: {error}"),
}
};
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(3)))
.unwrap();
let mut stream = rustls::StreamOwned::new(
rustls::ServerConnection::new(config.clone()).unwrap(),
stream,
);
let mut reader = BufReader::new(&mut stream);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert!(line.starts_with(&format!("GET /2.0/track/isrc:{isrc} ")));
loop {
line.clear();
assert!(reader.read_line(&mut line).unwrap() > 0);
if line == "\r\n" {
break;
}
}
stream
};
let (cancelled_worker, cancelled) = start("USAAA2600001", "item-a");
let cancelled_stream = accept("USAAA2600001");
let (surviving_worker, surviving) = start("USAAA2600002", "item-b");
let mut surviving_stream = accept("USAAA2600002");
backend.release_memory(true).unwrap();
assert!(cancelled.try_recv().is_err());
assert!(surviving.try_recv().is_err());
backend
.environment()
.download_state()
.cancel("item-a")
.unwrap();
assert_eq!(
cancelled.recv_timeout(Duration::from_secs(3)).unwrap(),
Err("download cancelled".into())
);
assert!(surviving.try_recv().is_err());
// Neither response has been sent: cancellation must interrupt active IO.
drop(cancelled_stream);
let body = r#"{"id":7,"title":"Survivor","duration":3,"isrc":"USAAA2600002","artist":{"id":2,"name":"Artist"},"album":{"id":3,"title":"Album"}}"#;
write!(
surviving_stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.unwrap();
surviving_stream.flush().unwrap();
drop(surviving_stream);
let result = surviving
.recv_timeout(Duration::from_secs(3))
.unwrap()
.unwrap();
let result: Value = serde_json::from_str(&result).unwrap();
assert_eq!(result["success"], true);
assert_eq!(result["track_id"], "7");
assert_eq!(result["isrc"], "USAAA2600002");
cancelled_worker.join().unwrap();
surviving_worker.join().unwrap();
backend.shutdown_checked().unwrap();
}
#[test]
fn shutdown_cancels_metadata_and_keeps_directory_ownership_until_work_releases() {
let root = tempfile::tempdir().unwrap();
let create = || {
Backend::new(
&root.path().join("sources"),
&root.path().join("data"),
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"1",
RuntimeLimits::default(),
)
.unwrap()
};
let backend = Arc::new(create());
let availability = backend.availability();
let (entered, started) = mpsc::channel();
let (cancelled, cancellation) = mpsc::channel();
let (release, released) = mpsc::channel();
let (finished, completion) = mpsc::channel();
let owner = backend.clone();
let worker = thread::spawn(move || {
owner.metadata_operation(30, &|| Ok(()), |check| -> Result<(), ResolverError> {
entered.send(()).unwrap();
loop {
if let Err(error) = check() {
cancelled.send(()).unwrap();
released.recv_timeout(Duration::from_secs(3)).unwrap();
return Err(ResolverError::Cancelled(error));
}
thread::sleep(Duration::from_millis(1));
}
})
});
started.recv_timeout(Duration::from_secs(3)).unwrap();
let owner = backend.clone();
let shutdown = thread::spawn(move || {
owner.shutdown_checked().unwrap();
finished.send(()).unwrap();
});
cancellation.recv_timeout(Duration::from_secs(3)).unwrap();
assert!(completion.try_recv().is_err());
assert!(
Backend::new(
&root.path().join("sources"),
&root.path().join("data"),
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"1",
RuntimeLimits::default()
)
.is_err()
);
release.send(()).unwrap();
assert_eq!(worker.join().unwrap(), Err("backend is closed".into()));
shutdown.join().unwrap();
completion.recv_timeout(Duration::from_secs(3)).unwrap();
assert!(availability.region().is_err());
assert_eq!(
backend.get_track_cache_size(),
Err("backend is closed".into())
);
assert_eq!(create().get_track_cache_size().unwrap(), 0);
}
}
@@ -0,0 +1,25 @@
use super::Backend;
use spotiflac_providers::resolver::Check;
impl Backend {
pub fn fetch_music_brainz_genre_by_isrc(
&self,
isrc: &str,
check: &Check<'_>,
) -> Result<String, String> {
// Three 10-second HTTP attempts plus two 2-second retry waits fit
// within this root deadline. Cancellation still applies per caller.
self.metadata_operation(35, check, |check| self.musicbrainz.genre(isrc, check))
}
pub fn fetch_music_brainz_album_artist_by_isrc(
&self,
isrc: &str,
album_name: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(35, check, |check| {
self.musicbrainz.album_artist(isrc, album_name, check)
})
}
}
@@ -0,0 +1,536 @@
use super::{Backend, ManagerError};
use serde_json::{Map, Value, json};
use spotiflac_core::cancellation::{CancellationDomain, CancellationRegistry, RequestLease};
use spotiflac_core::matching::lowercase;
use spotiflac_providers::resolver::{Check, ResolverError};
use std::sync::{Arc, mpsc};
use std::time::Duration;
impl Backend {
pub fn enrich_track_json(&self, id: &str, track_json: &str) -> Result<String, String> {
let _operation = self.enter()?;
self.manager
.enrich_track_export(id, track_json)
.map_err(|error| error.to_string())
}
pub fn handle_url_json(&self, url: &str) -> Result<String, String> {
let _operation = self.enter()?;
let id = self
.manager
.find_url_handler(url)
.map_err(|error| error.to_string())?
.ok_or_else(|| format!("no extension found to handle URL: {url}"))?;
let raw = self
.manager
.provider_call(&id, "handleUrl", &json!([url]).to_string(), None, 30_000)
.map_err(|error| error.to_string())?;
let value: Value = serde_json::from_str(&raw).map_err(|error| error.to_string())?;
let mut result = strings(
&value,
&[
"type",
"id",
"name",
"cover_url",
"header_image",
"header_video",
],
);
result.insert("extension_id".into(), id.into());
if let Some(value) = value.get("track").filter(|value| !value.is_null()) {
result.insert("track".into(), track(value, "", 0));
}
let values = array(&value, "tracks");
if !values.is_empty() {
result.insert("tracks".into(), url_tracks(values));
}
if let Some(value) = value.get("album").filter(|value| !value.is_null()) {
let mut info = strings(
value,
&[
"id",
"name",
"artists",
"cover_url",
"header_image",
"header_video",
"release_date",
"album_type",
"provider_id",
],
);
info.insert("audio_traits".into(), value["audio_traits"].clone());
info.insert(
"total_tracks".into(),
json!(value["total_tracks"].as_i64().unwrap_or_default()),
);
result.insert("album".into(), info.into());
}
if let Some(value) = value.get("artist").filter(|value| !value.is_null()) {
let mut info = strings(
value,
&[
"id",
"name",
"image_url",
"header_image",
"header_video",
"provider_id",
],
);
info.insert(
"listeners".into(),
json!(value["listeners"].as_i64().unwrap_or_default()),
);
for key in ["albums", "releases"] {
let values = array(value, key);
if !values.is_empty() {
info.insert(
key.into(),
values
.iter()
.map(|value| {
let mut value = album(value, false);
if value["album_type"] == "" {
value["album_type"] = "album".into();
}
value
})
.collect::<Vec<_>>()
.into(),
);
}
}
let values = array(value, "top_tracks");
if !values.is_empty() {
info.insert("top_tracks".into(), url_tracks(values));
}
result.insert("artist".into(), info.into());
}
self.check()?;
serde_json::to_string(&result).map_err(|error| error.to_string())
}
pub fn custom_search_json(
&self,
id: &str,
query: &str,
options_json: &str,
lease: Option<Arc<RequestLease>>,
) -> Result<String, String> {
let _operation = self.enter()?;
let options = serde_json::from_str::<Map<String, Value>>(options_json).unwrap_or_default();
let raw = self
.manager
.provider_call(
id,
"customSearch",
&json!([query, options]).to_string(),
lease.clone(),
30_000,
)
.map_err(|error| error.to_string())?;
let values: Vec<Value> = serde_json::from_str(&raw).map_err(|error| error.to_string())?;
let normalized = values
.iter()
.map(|value| {
self.check()?;
if let Some(lease) = &lease {
lease.check_active().map_err(|error| error.to_string())?;
}
Ok(track(value, "", 0))
})
.collect::<Result<Vec<_>, String>>()?;
serde_json::to_string(&normalized).map_err(|error| error.to_string())
}
pub fn get_extension_home_feed_json(
&self,
id: &str,
lease: Option<Arc<RequestLease>>,
) -> Result<String, String> {
let _operation = self.enter()?;
self.manager
.provider_call(id, "getHomeFeed", "[]", lease, 60_000)
.map_err(|error| error.to_string())
}
pub fn get_provider_metadata_json(
&self,
provider_id: &str,
kind: &str,
resource_id: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(30, check, |check| {
let id = provider_id.trim();
if id.is_empty() {
return Err(ResolverError::Failed("empty provider ID".into()));
}
let available = self.manager.require_metadata_provider(id);
// Only built-in dispatch folds case; installed IDs retain their case.
if lowercase(id) == "deezer" && available.is_err() {
return self.deezer_metadata(kind, resource_id, check);
}
available.map_err(|error| ResolverError::Failed(error.to_string()))?;
let method = match kind {
"track" => "getTrack",
"album" => "getAlbum",
"playlist" => "getPlaylist",
"artist" => "getArtist",
_ => {
return Err(ResolverError::Failed(format!(
"unsupported provider resource type: {kind}"
)));
}
};
let arguments = json!([resource_id]).to_string();
let result = self.provider_metadata_call(id, method, &arguments, check)?;
let result = response(kind, &result, check)?;
serde_json::to_string(&result).map_err(|error| ResolverError::Failed(error.to_string()))
})
}
pub(super) fn provider_metadata_call(
&self,
id: &str,
method: &str,
arguments: &str,
check: &Check<'_>,
) -> Result<Value, ResolverError> {
self.metadata_provider_work(check, |lease| {
self.manager
.provider_call(id, method, arguments, Some(lease), 30_000)
})
}
pub(super) fn metadata_provider_work(
&self,
check: &Check<'_>,
work: impl FnOnce(Arc<RequestLease>) -> Result<String, ManagerError> + Send,
) -> Result<Value, ResolverError> {
let cancellation = CancellationRegistry::new(CancellationDomain::ExtensionRequest);
let lease = Arc::new(
cancellation
.acquire("")
.expect("new metadata cancellation lease"),
);
std::thread::scope(|scope| {
let (done, completion) = mpsc::channel::<()>();
let worker_lease = &lease;
let worker = std::thread::Builder::new()
.name("provider-metadata".into())
.spawn_scoped(scope, move || {
let _done = done;
work(worker_lease.clone())
})
.map_err(|error| ResolverError::Failed(error.to_string()))?;
let mut cancelled = None;
loop {
if let Err(message) = check() {
cancelled = Some(message);
lease.release();
break;
}
if !matches!(
completion.recv_timeout(Duration::from_millis(5)),
Err(mpsc::RecvTimeoutError::Timeout)
) {
break;
}
}
let result = worker
.join()
.map_err(|_| ResolverError::Failed("metadata provider panicked".into()))?;
lease.release();
if let Some(message) = cancelled {
return Err(ResolverError::Cancelled(message));
}
check().map_err(ResolverError::Cancelled)?;
let result = result.map_err(|error| ResolverError::Failed(error.to_string()))?;
serde_json::from_str(&result).map_err(|error| ResolverError::Failed(error.to_string()))
})
}
}
// Inputs have already passed the installed provider's typed decoder. Fill the
// fields that Go's internal omitempty serializer drops before the public map.
fn text<'a>(value: &'a Value, key: &str) -> &'a str {
value[key].as_str().unwrap_or_default()
}
fn strings(value: &Value, fields: &[&str]) -> Map<String, Value> {
fields
.iter()
.map(|key| ((*key).into(), json!(text(value, key))))
.collect()
}
fn array<'a>(value: &'a Value, key: &str) -> &'a [Value] {
value[key].as_array().map(Vec::as_slice).unwrap_or_default()
}
fn url_tracks(values: &[Value]) -> Value {
values.iter().map(|value| track(value, "", 0)).collect()
}
fn track(value: &Value, cover: &str, index: usize) -> Value {
let mut result = strings(
value,
&[
"id",
"name",
"artists",
"album_name",
"album_artist",
"album_id",
"album_url",
"artist_id",
"artist_url",
"external_urls",
"preview_url",
"release_date",
"isrc",
"provider_id",
"item_type",
"album_type",
"spotify_id",
"genre",
"label",
"copyright",
"composer",
"comment",
"audio_quality",
"audio_modes",
"upc",
],
);
let cover = [text(value, "cover_url"), text(value, "images"), cover]
.into_iter()
.find(|value| !value.is_empty())
.unwrap_or_default();
result.insert("images".into(), json!(cover));
result.insert("cover_url".into(), json!(cover));
for key in [
"duration_ms",
"track_number",
"total_tracks",
"disc_number",
"total_discs",
] {
result.insert(key.into(), json!(value[key].as_i64().unwrap_or_default()));
}
if result["track_number"] == 0 && index > 0 {
result.insert("track_number".into(), json!(index));
}
result.insert(
"explicit".into(),
json!(value["explicit"].as_bool().unwrap_or(false)),
);
result.insert("external_links".into(), value["external_links"].clone());
result.into()
}
fn tracks(values: &[Value], cover: &str, check: &Check<'_>) -> Result<Value, ResolverError> {
values
.iter()
.enumerate()
.map(|(index, value)| {
check().map_err(ResolverError::Cancelled)?;
Ok(track(value, cover, index + 1))
})
.collect::<Result<Vec<_>, _>>()
.map(Value::Array)
}
fn album(value: &Value, full: bool) -> Value {
let mut result = strings(
value,
&[
"id",
"name",
"artists",
"cover_url",
"release_date",
"album_type",
"provider_id",
],
);
result.insert("images".into(), json!(text(value, "cover_url")));
result.insert(
"total_tracks".into(),
json!(value["total_tracks"].as_i64().unwrap_or_default()),
);
if full {
result.extend(strings(
value,
&["artist_id", "header_image", "header_video"],
));
result.insert("audio_traits".into(), json!(array(value, "audio_traits")));
}
result.into()
}
fn response(kind: &str, value: &Value, check: &Check<'_>) -> Result<Value, ResolverError> {
check().map_err(ResolverError::Cancelled)?;
let cover = text(value, "cover_url");
Ok(match kind {
"track" => json!({"track":track(value, "", 0)}),
"album" => {
json!({"album_info":album(value, true),"track_list":tracks(array(value,"tracks"),cover,check)?})
}
"playlist" => {
let mut info = strings(
value,
&[
"id",
"name",
"cover_url",
"header_image",
"header_video",
"provider_id",
],
);
info.insert("images".into(), json!(cover));
info.insert(
"owner".into(),
json!({"name":text(value,"artists"),"images":cover}),
);
json!({"playlist_info":info,"track_list":tracks(array(value,"tracks"),cover,check)?})
}
"artist" => {
let cover = text(value, "image_url");
let image = [text(value, "header_image"), cover]
.into_iter()
.map(str::trim)
.find(|value| !value.is_empty())
.unwrap_or_default();
let mut info = strings(
value,
&["id", "name", "header_image", "header_video", "provider_id"],
);
info.insert("images".into(), json!(image));
info.insert("cover_url".into(), json!(cover));
if value["listeners"].as_i64().unwrap_or_default() > 0 {
info.insert("listeners".into(), value["listeners"].clone());
}
let mut result = json!({"artist_info":info});
for key in ["albums", "releases"] {
let values = array(value, key);
if key == "albums" || !values.is_empty() {
result[key] = values
.iter()
.map(|value| {
check().map_err(ResolverError::Cancelled)?;
Ok(album(value, false))
})
.collect::<Result<Vec<_>, ResolverError>>()?
.into();
}
}
let top = array(value, "top_tracks");
if !top.is_empty() {
result["top_tracks"] = tracks(top, cover, check)?;
}
result
}
_ => unreachable!("validated provider resource type"),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::RuntimeLimits;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Barrier};
use std::thread;
fn backend(root: &Path) -> Backend {
Backend::new(
&root.join("sources"),
&root.join("data"),
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"1",
RuntimeLimits::default(),
)
.unwrap()
}
#[test]
fn metadata_provider_work_returns_immediate_json() {
let root = tempfile::tempdir().unwrap();
let backend = backend(root.path());
let result = backend
.metadata_provider_work(&|| Ok(()), |_| Ok(r#"{"immediate":true}"#.into()))
.unwrap();
assert_eq!(result["immediate"], true);
}
#[test]
fn metadata_provider_work_waits_for_delayed_worker_completion() {
let root = tempfile::tempdir().unwrap();
let backend = Arc::new(backend(root.path()));
let started = Arc::new(Barrier::new(2));
let release = Arc::new(Barrier::new(2));
let worker_backend = Arc::clone(&backend);
let worker_started = Arc::clone(&started);
let worker_release = Arc::clone(&release);
let worker = thread::spawn(move || {
worker_backend.metadata_provider_work(&|| Ok(()), move |_| {
worker_started.wait();
worker_release.wait();
Ok(r#"{"delayed":true}"#.into())
})
});
started.wait();
release.wait();
assert_eq!(worker.join().unwrap().unwrap()["delayed"], true);
}
#[test]
fn metadata_provider_work_reports_worker_panic() {
let root = tempfile::tempdir().unwrap();
let backend = backend(root.path());
let result = backend
.metadata_provider_work(&|| Ok(()), |_| -> Result<String, ManagerError> {
panic!("metadata worker failed")
});
assert_eq!(
result,
Err(ResolverError::Failed("metadata provider panicked".into()))
);
}
#[test]
fn metadata_provider_work_releases_lease_and_joins_cancelled_worker() {
let root = tempfile::tempdir().unwrap();
let backend = backend(root.path());
let started = Arc::new(AtomicBool::new(false));
let joined = Arc::new(AtomicBool::new(false));
let worker_started = Arc::clone(&started);
let worker_joined = Arc::clone(&joined);
let result = backend.metadata_provider_work(
&|| {
if started.load(Ordering::Acquire) {
Err("cancelled by test".into())
} else {
Ok(())
}
},
move |lease| {
worker_started.store(true, Ordering::Release);
assert!(lease.wait_cancelled(60_000).is_err());
worker_joined.store(true, Ordering::Release);
Ok("{}".into())
},
);
assert_eq!(
result,
Err(ResolverError::Cancelled("cancelled by test".into()))
);
assert!(joined.load(Ordering::Acquire));
}
}
@@ -0,0 +1,359 @@
use super::{Backend, LyricsRequest};
use cap_std::fs::OpenOptions;
use serde_json::json;
use spotiflac_core::lyrics::{file, lrc};
use spotiflac_core::metadata::TrackMetadata;
use spotiflac_core::metadata::reenrich::{self, Request};
use spotiflac_core::tags::{embed_flac_metadata, extract_cover};
use spotiflac_providers::deezer::MetadataLookup;
use spotiflac_providers::resolver::{Check, availability};
use std::io::Write;
use std::sync::Mutex;
impl Backend {
/// Resolve proposed tags without reading audio, downloading covers or writing files.
pub fn preview_reenrich_file(
&self,
request_json: &str,
check: &Check<'_>,
) -> Result<String, String> {
let _operation = self.enter()?;
let check = || {
self.check()?;
check()
};
check()?;
let request = self.resolve_reenrich_request(request_json, &check)?;
serde_json::to_string(&json!({"method":"preview","success":true,
"enriched_metadata":request.result_metadata()}))
.map_err(|error| error.to_string())
}
/// Execute FLAC enrichment or return the existing native FFmpeg plan.
/// Returned cover files belong to the caller, which removes them after use.
pub fn reenrich_file(&self, request_json: &str, check: &Check<'_>) -> Result<String, String> {
let _operation = self.enter()?;
let failure = Mutex::new(None::<String>);
let check = || {
if let Some(error) = failure.lock().expect("reenrich cancellation lock").as_ref() {
return Err(error.clone());
}
self.check().and_then(|()| check()).inspect_err(|error| {
*failure.lock().expect("reenrich cancellation lock") = Some(error.clone());
})
};
check()?;
let request = self.resolve_reenrich_request(request_json, &check)?;
let enriched = request.result_metadata();
if request.preview_only {
return Ok(json!({"method":"preview","success":true,
"enriched_metadata":enriched})
.to_string());
}
let files = self.environment().native_files()?;
files.resolve_legacy(&request.file_path)?.native_display()?;
let is_flac = request.file_path.to_ascii_lowercase().ends_with(".flac");
let wants_cover = !request.cover_url.is_empty() && request.selected("cover", "cover");
let fetch_cover = || {
if wants_cover {
self.cover
.download(
&request.cover_url,
request.cover_max_dimension as i64,
&check,
)
.ok()
} else {
None
}
};
let fetch_lyrics = || -> Result<String, String> {
// Fetches and existing lyrics are best-effort; cancellation is not.
check()?;
let mut lyrics = String::new();
if request.selected("lyrics", "lyrics") {
if let Ok(existing) = file::extract(
&request.file_path,
&|path| {
files
.resolve_legacy(path)?
.open(OpenOptions::new().read(true))
.map_err(|error| error.to_string())
},
&check,
) {
lyrics = existing;
}
check()?;
if request.embed_lyrics {
let query = LyricsRequest {
spotify_id: request.spotify_id.clone(),
track: request.track_name.clone(),
artist: request.artist_name.clone(),
duration_ms: request.duration_ms,
..LyricsRequest::default()
};
if let Ok(response) = self.fetch_lyrics(&query, &check)
&& !response.instrumental
{
lyrics = lrc::with_metadata(&response, &query.track, &query.artist);
}
check()?;
}
}
Ok(lyrics)
};
let (cover, lyrics) = std::thread::scope(|scope| {
let worker = (wants_cover && request.selected("lyrics", "lyrics")).then(|| {
std::thread::Builder::new()
.name("reenrich-cover".into())
.spawn_scoped(scope, fetch_cover)
});
let lyrics = fetch_lyrics();
let cover = match worker {
Some(Ok(worker)) => worker
.join()
.map_err(|_| "cover worker panicked".to_owned())?,
_ => fetch_cover(),
};
check()?;
Ok::<_, String>((cover, lyrics?))
})?;
let metadata = request.write_metadata(&lyrics);
let external = request.write_external_lrc(&lyrics);
if is_flac {
let cover = cover.as_deref().filter(|data| !data.is_empty());
let prefix = if cover.is_some() {
"failed to embed metadata with cover"
} else {
"failed to embed metadata"
};
self.rewrite_tag_file(&request.file_path, &check, |source, output, _| {
embed_flac_metadata(
source,
output,
&metadata,
&request.artist_tag_mode,
cover,
&check,
)
.map_err(|error| format!("{prefix}: {error}"))?;
// Verify the staged result before replacing the user's audio.
if cover.is_some() {
let picture = extract_cover(output, "flac", &check).map_err(|error| {
format!("metadata embedded but cover verification failed: {error}")
})?;
if picture.data.is_empty() {
return Err(
"metadata embedded but cover verification failed: empty embedded cover"
.into(),
);
}
}
Ok(true)
})
.map_err(|error| {
if error.starts_with("failed to embed metadata")
|| error.starts_with("metadata embedded")
{
error
} else {
format!("{prefix}: {error}")
}
})?;
return Ok(
json!({"method":"native","success":true,"enriched_metadata":enriched,
"lyrics":lyrics,"write_external_lrc":external})
.to_string(),
);
}
let temporary = cover.as_deref().and_then(|data| {
let write = || -> Result<tempfile::NamedTempFile, String> {
let mut file = tempfile::Builder::new()
.prefix("reenrich_cover_")
.suffix(".jpg")
.tempfile_in(self.environment().data_directory())
.map_err(|error| error.to_string())?;
for chunk in data.chunks(65536) {
check()?;
file.write_all(chunk).map_err(|error| error.to_string())?;
}
file.as_file()
.sync_all()
.map_err(|error| error.to_string())?;
Ok(file)
};
write().ok()
});
check()?;
let cover_path = if let Some(file) = &temporary {
files
.resolve_legacy(&file.path().to_string_lossy())?
.native_display()?
} else {
String::new()
};
let result = json!({"method":"ffmpeg","cover_path":cover_path,"lyrics":lyrics,
"enriched_metadata":enriched,"metadata":metadata,"write_external_lrc":external})
.to_string();
check()?;
if let Some(file) = temporary {
file.keep().map_err(|error| error.to_string())?;
}
Ok(result)
}
fn resolve_reenrich_request(
&self,
request_json: &str,
check: &Check<'_>,
) -> Result<Request, String> {
let mut request = Request::parse(request_json)
.map_err(|error| format!("failed to parse request: {error}"))?;
if request.file_path.is_empty() {
return Err("file_path is required".into());
}
if request.search_online {
let mut found = false;
let catalog_track = self.reenrich_identifiers(&request, &check);
if let Some(track) = &catalog_track {
request.apply(&reenrich::from_catalog(track));
found = true;
}
check()?;
let query = request.query();
if !query.is_empty() {
let tracks = self.metadata_provider_work(&check, |lease| {
self.manager.search_metadata_providers_with_lease(
&query,
5,
true,
"",
30_000,
Some(lease),
)
});
check()?;
if let Ok(tracks) = tracks
&& let Some(tracks) = tracks.as_array()
&& let Some(track) = request.select(tracks)
{
request.apply(track);
found = true;
}
}
if request.selected("basic_tags", "album_artist")
&& request.album_artist.is_empty()
&& !request.isrc.is_empty()
{
let artist = self.fetch_music_brainz_album_artist_by_isrc(
&request.isrc,
&request.album_name,
&check,
);
check()?;
if let Ok(artist) = artist
&& !artist.trim().is_empty()
{
request.album_artist = artist.trim().into();
found = true;
}
}
if found
&& !request.isrc.is_empty()
&& request.any_selected("extra", &["genre", "label", "copyright"])
&& (request.genre.is_empty()
|| request.label.is_empty()
|| request.copyright.is_empty())
{
let isrc = request.isrc.trim();
if !isrc.is_empty() {
let extended = self.metadata_operation(10, &check, |check| {
if let Some(track) = catalog_track
.as_ref()
.filter(|track| track.isrc.trim().eq_ignore_ascii_case(isrc))
{
return self.deezer.get_extended_metadata_for_track(track, check);
}
self.deezer.get_extended_metadata_by_isrc(isrc, check)
});
check()?;
if let Ok(extended) = extended {
for (target, value) in [
(&mut request.genre, &extended.genre),
(&mut request.label, &extended.label),
(&mut request.copyright, &extended.copyright),
] {
if target.is_empty() && !value.is_empty() {
*target = value.clone();
}
}
}
if request.genre.is_empty() {
let genre = self.fetch_music_brainz_genre_by_isrc(isrc, &check);
check()?;
if let Ok(genre) = genre
&& !genre.is_empty()
{
request.genre = genre;
}
}
}
}
}
check()?;
Ok(request)
}
fn reenrich_identifiers(&self, request: &Request, check: &Check<'_>) -> Option<TrackMetadata> {
let isrc = request.isrc.trim();
if !isrc.is_empty() {
let track =
self.metadata_operation(10, check, |check| self.deezer.search_by_isrc(isrc, check));
if check().is_err() {
return None;
}
if let Ok(track) = track
&& request.verified(&reenrich::from_catalog(&track))
{
return Some(track);
}
}
let source = request.spotify_id.trim();
if source.is_empty() {
return None;
}
let mut deezer_id = source
.strip_prefix("deezer:")
.unwrap_or(source)
.trim()
.to_owned();
if deezer_id == source {
deezer_id = availability::deezer_id_from_url(source);
}
if deezer_id.is_empty() {
let mut spotify_id = availability::spotify_id_from_url(source);
if spotify_id.is_empty() && source.len() == 22 && !source.contains([':', '/']) {
spotify_id = source.into();
}
if !spotify_id.is_empty() {
if let Ok(id) = self.get_deezer_id_from_spotify(&spotify_id, check) {
deezer_id = id.trim().into();
}
if check().is_err() {
return None;
}
}
}
if deezer_id.is_empty() {
return None;
}
let track = self
.metadata_operation(15, check, |check| self.deezer.get_track(&deezer_id, check))
.ok()?;
request
.verified(&reenrich::from_catalog(&track))
.then_some(track)
}
}
@@ -0,0 +1,156 @@
use super::Backend;
use serde_json::Value;
use spotiflac_core::metadata::share::{self, Provider, Request, ShareResult};
use spotiflac_providers::resolver::{Check, ResolverError};
use std::collections::{BTreeMap, VecDeque};
#[derive(Default)]
pub(super) struct Cache {
values: BTreeMap<(u64, String), String>,
order: VecDeque<(u64, String)>,
}
impl Cache {
pub fn clear(&mut self) {
self.values.clear();
self.order.clear();
}
fn put(&mut self, key: (u64, String), value: String) {
if !self.values.contains_key(&key) {
self.order.push_back(key.clone());
}
self.values.insert(key, value);
while self.order.len() > 128 {
if let Some(oldest) = self.order.pop_front() {
self.values.remove(&oldest);
}
}
}
}
impl Backend {
pub fn find_collection_across_extensions_json(
&self,
request_json: &str,
check: &Check<'_>,
) -> Result<String, String> {
self.metadata_operation(60, check, |check| {
let request = Request::parse(request_json)
.map_err(|error| ResolverError::Failed(error.to_string()))?;
if request.name.is_empty() {
return Ok("[]".into());
}
let revision = self.manager.metadata_revision();
let providers = self
.manager
.share_providers(&request.source_extension_id)
.map_err(|error| ResolverError::Failed(error.to_string()))?;
let key = (revision, request.cache_key(&providers));
if let Some(cached) = self
.share_cache
.lock()
.expect("share cache lock")
.values
.get(&key)
{
return Ok(cached.clone());
}
let query = request.query();
// Go searches all providers concurrently; retain snapshot order on join.
let results = std::thread::scope(|scope| {
let workers: Vec<_> = providers
.iter()
.map(|provider| {
let request = &request;
let query = &query;
scope
.spawn(move || self.share_for_provider(provider, request, query, check))
})
.collect();
workers
.into_iter()
.map(|worker| {
worker
.join()
.map_err(|_| ResolverError::Failed("collection search panicked".into()))
})
.collect::<Result<Vec<_>, _>>()
})?;
check().map_err(ResolverError::Cancelled)?;
let response = serde_json::to_string(&results)
.map_err(|error| ResolverError::Failed(error.to_string()))?;
if revision == self.manager.metadata_revision()
&& results.iter().all(ShareResult::cacheable)
{
self.share_cache
.lock()
.expect("share cache lock")
.put(key, response.clone());
}
Ok(response)
})
}
fn share_for_provider(
&self,
provider: &Provider,
request: &Request,
query: &str,
check: &Check<'_>,
) -> ShareResult {
let fetch = || -> Result<Vec<Value>, ResolverError> {
let filter = match request.kind.as_str() {
"album" => "albums",
"artist" => "artists",
_ => "",
};
if !filter.is_empty() {
let arguments =
serde_json::json!([query, {"filter":filter,"limit":10}]).to_string();
let custom =
self.provider_metadata_call(&provider.id, "customSearch", &arguments, check);
check().map_err(ResolverError::Cancelled)?;
if let Ok(Value::Array(tracks)) = custom
&& !tracks.is_empty()
{
return Ok(tracks);
}
}
let arguments = serde_json::json!([query, 10]).to_string();
let result =
self.provider_metadata_call(&provider.id, "searchTracks", &arguments, check)?;
Ok(result["tracks"].as_array().cloned().unwrap_or_default())
};
match fetch() {
Ok(tracks) => share::select(provider, request, &tracks),
Err(error) => {
let mut result = ShareResult::new(provider);
result.error = error.to_string();
result
}
}
}
}
#[cfg(test)]
mod tests {
use super::Cache;
#[test]
fn collection_share_cache_is_bounded_fifo_and_revisions_are_distinct() {
let mut cache = Cache::default();
for index in 0..128 {
cache.put((0, index.to_string()), "original".into());
}
assert!(cache.values.contains_key(&(0, "0".into())));
cache.put((0, "0".into()), "updated".into());
cache.put((1, "0".into()), "new revision".into());
assert_eq!(cache.values.len(), 128);
assert!(!cache.values.contains_key(&(0, "0".into())));
assert!(cache.values.contains_key(&(0, "1".into())));
assert_eq!(cache.values[&(1, "0".into())], "new revision");
cache.clear();
assert!(cache.values.is_empty() && cache.order.is_empty());
}
}
@@ -0,0 +1,370 @@
use super::Backend;
use crate::files::ExtensionFiles;
use cap_std::fs::{File, OpenOptions};
use serde_json::{Value, json};
use spotiflac_core::tags::{
embed_flac_metadata, rewrite_ac4_config, rewrite_ac4_metadata, rewrite_audio_tags,
rewrite_m4a_freeform,
};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::io::Read;
impl Backend {
pub fn rewrite_split_artist_tags(
&self,
path: &str,
artist: &str,
album_artist: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<String, String> {
let fields = BTreeMap::from([
("ARTIST".into(), artist.into()),
("ALBUMARTIST".into(), album_artist.into()),
]);
self.embed_flac_fields_response(
path,
&fields,
"split_vorbis",
(
"Failed to rewrite artist tags",
"Split artist tags written successfully",
),
check,
)
}
pub(super) fn embed_flac_fields_response(
&self,
path: &str,
fields: &BTreeMap<String, String>,
artist_mode: &str,
messages: (&str, &str),
check: &dyn Fn() -> Result<(), String>,
) -> Result<String, String> {
let _operation = self.enter()?;
let failure = RefCell::new(None::<String>);
let check = || {
if let Some(error) = failure.borrow().as_ref() {
return Err(error.clone());
}
self.check().and_then(|()| check()).inspect_err(|error| {
*failure.borrow_mut() = Some(error.clone());
})
};
check()?;
self.environment()
.native_files()?
.resolve_legacy(path)?
.native_display()?;
if let Err(error) = self.rewrite_tag_file(path, &check, |source, output, _| {
embed_flac_metadata(source, output, fields, artist_mode, None, &check)?;
Ok(true)
}) {
check()?;
return Ok(
crate::download::native_error_response(&format!("{}: {error}", messages.0))
.to_string(),
);
}
Ok(json!({"success":true,"message":messages.1}).to_string())
}
/// Preserve the application facade, including native-editor fallback to
/// FFmpeg. Permission and lifecycle failures must never become success.
pub fn edit_file_metadata(
&self,
path: &str,
metadata_json: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Value, String> {
let _operation = self.enter()?;
let failure = RefCell::new(None::<String>);
let check = || {
if let Some(error) = failure.borrow().as_ref() {
return Err(error.clone());
}
self.check().and_then(|()| check()).inspect_err(|error| {
*failure.borrow_mut() = Some(error.clone());
})
};
check()?;
let decoded: Option<BTreeMap<String, Option<String>>> = serde_json::from_str(metadata_json)
.map_err(|error| format!("invalid metadata JSON: {error}"))?;
let null_fields = decoded.is_none();
let fields: BTreeMap<String, String> = decoded
.unwrap_or_default()
.into_iter()
.map(|(key, value)| (key, value.unwrap_or_default()))
.collect();
let files = self.manager.environment().native_files()?;
let target = files.resolve_legacy(path)?;
target.native_display()?;
if let Some(cover) = fields.get("cover_path").filter(|p| !p.trim().is_empty()) {
files.resolve_legacy(cover.trim())?.native_display()?;
}
let lower = path.to_lowercase();
let suffix = |extensions: &[&str]| extensions.iter().any(|ext| lower.ends_with(ext));
let m4a = suffix(&[".m4a", ".mp4", ".m4b"]);
let mp4 = target
.open(OpenOptions::new().read(true))
.ok()
.is_some_and(|mut file| {
let mut header = [0; 12];
file.read(&mut header)
.is_ok_and(|count| count >= 8 && &header[4..8] == b"ftyp")
});
check()?;
let mut replay_gain = false;
let only_replay_gain = fields
.iter()
.filter(|(_, value)| !value.trim().is_empty())
.all(|(key, _)| {
let allowed = matches!(
key.trim().to_lowercase().as_str(),
"replaygain_track_gain"
| "replaygain_track_peak"
| "replaygain_album_gain"
| "replaygain_album_peak"
);
replay_gain |= allowed;
allowed
});
let success = |method: &str| json!({"success":true,"method":method});
if only_replay_gain && replay_gain && (m4a || mp4) {
self.edit_m4a_freeform(path, &fields, true, &check)
.map_err(|error| format!("failed to write M4A metadata: {error}"))?;
return Ok(success("native_m4a_replaygain"));
}
let required = if suffix(&[".flac"]) {
if mp4 {
return Err("failed to write FLAC metadata: file is an MP4/M4A stream under a .flac name; rename it to .m4a".into());
}
Some(("flac", "native", "failed to write FLAC metadata"))
} else if suffix(&[".wav"]) {
Some(("wav", "native_wav", "failed to write WAV metadata"))
} else if suffix(&[".aiff", ".aif", ".aifc"]) {
Some(("aiff", "native_aiff", "failed to write AIFF metadata"))
} else if suffix(&[".ape", ".wv", ".mpc"]) {
Some(("ape", "native_ape", "failed to write APE tags"))
} else {
None
};
if let Some((format, method, prefix)) = required {
self.edit_audio_tags(path, format, &fields, &check)
.map_err(|error| format!("{prefix}: {error}"))?;
return Ok(success(method));
}
for (applicable, format, method) in [
(suffix(&[".mp3"]), "mp3", "native_mp3"),
(suffix(&[".ogg", ".opus"]), "ogg", "native_ogg"),
(m4a || mp4, "m4a", "native_m4a"),
] {
if applicable {
if self.edit_audio_tags(path, format, &fields, &check).is_ok() {
return Ok(success(method));
}
// Recheck capabilities after a failed attempt too: a revoked
// grant or replaced symlink must not be handed to FFmpeg.
let files = self.manager.environment().native_files()?;
files.resolve_legacy(path)?.native_display()?;
if let Some(cover) = fields.get("cover_path").filter(|p| !p.trim().is_empty()) {
files.resolve_legacy(cover.trim())?.native_display()?;
}
}
check()?;
}
Ok(
json!({"success":true,"method":"ffmpeg","fields":if null_fields { Value::Null } else { json!(fields) }}),
)
}
/// Edit supported tag containers through the same file grants and output
/// locks as downloads/lyrics. Platform descriptors are staged by the native
/// adapter before calling this path-based, atomic publication operation.
pub fn edit_audio_tags(
&self,
path: &str,
format: &str,
fields: &BTreeMap<String, String>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<(), String> {
let _operation = self.enter()?;
let check = || self.check().and_then(|()| check());
check()?;
if !matches!(
format,
"flac"
| "mp3"
| "m4a"
| "mp4"
| "m4b"
| "ogg"
| "opus"
| "wav"
| "aiff"
| "aif"
| "aifc"
| "ape"
| "wv"
| "mpc"
) {
return Err(format!("unsupported tag writer: {format}"));
}
self.rewrite_tag_file(path, &check, |source, output, files| {
let cover = read_cover(
files,
fields.get("cover_path").map(|p| p.trim()),
matches!(format, "wav" | "aiff" | "aif" | "aifc"),
&check,
)?;
rewrite_audio_tags(source, output, format, fields, cover.as_deref(), &check)?;
Ok(true)
})
.map(|_| ())
}
pub fn edit_m4a_freeform(
&self,
path: &str,
fields: &BTreeMap<String, String>,
replay_gain_only: bool,
check: &dyn Fn() -> Result<(), String>,
) -> Result<bool, String> {
let _operation = self.enter()?;
let check = || self.check().and_then(|()| check());
check()?;
let applicable = if replay_gain_only {
[
"replaygain_track_gain",
"replaygain_track_peak",
"replaygain_album_gain",
"replaygain_album_peak",
]
.iter()
.any(|key| fields.get(*key).is_some_and(|v| !v.trim().is_empty()))
} else {
fields.contains_key("isrc") || fields.contains_key("label")
};
if !applicable {
return Ok(false);
}
self.rewrite_tag_file(path, &check, |source, output, _| {
rewrite_m4a_freeform(source, output, fields, replay_gain_only, &check)
})
}
pub fn ensure_ac4_config(
&self,
path: &str,
reference: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<(), String> {
let _operation = self.enter()?;
let check = || self.check().and_then(|()| check());
check()?;
self.rewrite_tag_file(path, &check, |source, output, files| {
rewrite_ac4_config(
source,
output,
|| {
files
.resolve_legacy(reference)?
.open(OpenOptions::new().read(true))
.map_err(|error| error.to_string())
},
&check,
)
})
.map(|_| ())
}
pub fn write_ac4_metadata(
&self,
path: &str,
metadata_json: &str,
cover_path: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<bool, String> {
let _operation = self.enter()?;
let check = || self.check().and_then(|()| check());
check()?;
self.rewrite_tag_file(path, &check, |source, output, files| {
rewrite_ac4_metadata(
source,
output,
metadata_json,
|| read_cover(files, Some(cover_path), false, &check),
&check,
)
})
}
// Callers hold the root operation guard and supply the combined root/lease check.
pub(super) fn rewrite_tag_file(
&self,
path: &str,
check: &dyn Fn() -> Result<(), String>,
edit: impl FnOnce(&mut File, &mut File, &ExtensionFiles) -> Result<bool, String>,
) -> Result<bool, String> {
let files = self.manager.environment().native_files()?;
let target = files.resolve_legacy(path)?;
let _lock = files.lock(&target, check)?;
target.native_display()?;
target.require_parent()?;
let mut source = target
.open(OpenOptions::new().read(true).write(true))
.map_err(|e| e.to_string())?;
let permissions = source.metadata().map_err(|e| e.to_string())?.permissions();
let mut stage = target.stage_existing_parent().map_err(|e| e.to_string())?;
stage
.file
.set_permissions(permissions)
.map_err(|e| e.to_string())?;
let changed = edit(&mut source, &mut stage.file, &files)?;
check()?;
drop(source);
if changed {
stage.publish(check)?;
}
Ok(changed)
}
}
fn read_cover(
files: &ExtensionFiles,
path: Option<&str>,
required: bool,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Option<Vec<u8>>, String> {
let Some(path) = path.filter(|path| !path.trim().is_empty()) else {
return Ok(None);
};
let path = files.resolve_legacy(path)?;
// Go ignores a missing/unreadable cover while applying tags.
let mut file = match path.open(OpenOptions::new().read(true)) {
Ok(file) => file,
Err(error) if required => return Err(format!("read cover art: {error}")),
Err(_) => return Ok(None),
};
if file.metadata().map_err(|e| e.to_string())?.len() > 64 * 1024 * 1024 {
return Err("cover exceeds 64 MiB".into());
}
let mut data = Vec::new();
let mut buffer = [0; 65536];
loop {
check()?;
let count = file.read(&mut buffer).map_err(|e| e.to_string())?;
if count == 0 {
break;
}
if data.len() + count > 64 * 1024 * 1024 {
return Err("cover exceeds 64 MiB".into());
}
data.extend_from_slice(&buffer[..count]);
}
if required && data.is_empty() {
return Err("cover art is empty".into());
}
Ok(Some(data))
}
@@ -0,0 +1,227 @@
use crate::host::decode_go_utf8;
use base64::{
Engine, alphabet,
engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD},
};
use rquickjs::{Object, TypedArray, Value};
pub(crate) fn option_string(
options: Option<&Object<'_>>,
key: &str,
fallback: &str,
) -> Result<String, String> {
let Some(options) = options else {
return Ok(fallback.into());
};
let value: Value = options.get(key).map_err(|error| error.to_string())?;
if let Some(value) = value.as_string() {
let value = value.to_string().map_err(|error| error.to_string())?;
if !value.trim().is_empty() {
return Ok(value.trim().into());
}
} else if let Ok(value) = TypedArray::<u8>::from_value(value) {
let bytes = copy_typed(&value);
if !bytes.is_empty() {
return Ok(decode_go_utf8(&bytes));
}
}
Ok(fallback.into())
}
pub(crate) fn option_i64(options: &Object<'_>, key: &str, fallback: i64) -> Result<i64, String> {
let value: Value = options.get(key).map_err(|error| error.to_string())?;
if let Some(number) = value.as_number() {
return Ok(go_float_i64(number));
}
if let Some(value) = value.as_string() {
let value = value.to_string().map_err(|error| error.to_string())?;
let value = value.trim();
let prefix: String = value
.chars()
.enumerate()
.take_while(|(index, ch)| {
ch.is_ascii_digit() || (*index == 0 && (*ch == '+' || *ch == '-'))
})
.map(|(_, ch)| ch)
.collect();
return Ok(prefix.parse().unwrap_or(fallback));
}
Ok(fallback)
}
pub(crate) fn option_bool(options: &Object<'_>, key: &str, fallback: bool) -> Result<bool, String> {
let value: Value = options.get(key).map_err(|error| error.to_string())?;
if let Some(value) = value.as_bool() {
return Ok(value);
}
if let Some(value) = value.as_number() {
return Ok(value != 0.0);
}
if let Some(value) = value.as_string() {
return Ok(
match value
.to_string()
.map_err(|error| error.to_string())?
.trim()
.to_lowercase()
.as_str()
{
"1" | "true" | "yes" | "on" => true,
"0" | "false" | "no" | "off" => false,
_ => fallback,
},
);
}
Ok(fallback)
}
pub(crate) fn decode_string(value: &str, encoding: &str) -> Result<Vec<u8>, String> {
match encoding.trim().to_lowercase().as_str() {
"" | "utf8" | "utf-8" | "text" => Ok(value.as_bytes().to_vec()),
"base64" => {
decode_base64(value.trim()).map_err(|error| format!("invalid base64 data: {error}"))
}
"hex" => {
let value = value.trim().as_bytes();
let mut bytes = Vec::with_capacity(value.len() / 2);
let digit = |byte: u8| {
(byte as char)
.to_digit(16)
.map(|value| value as u8)
.ok_or_else(|| format!("invalid hex data: invalid byte 0x{byte:02x}"))
};
for pair in value.chunks(2) {
let first = digit(pair[0])?;
if pair.len() < 2 {
return Err("invalid hex data: encoding/hex: odd length hex string".into());
}
bytes.push(first * 16 + digit(pair[1])?);
}
Ok(bytes)
}
_ => Err(format!("unsupported byte encoding: {encoding}")),
}
}
pub(crate) fn decode_base64(value: &str) -> Result<Vec<u8>, base64::DecodeError> {
GeneralPurpose::new(
&alphabet::STANDARD,
GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
)
.decode(value.replace(['\r', '\n'], ""))
}
pub(crate) fn decode_option_bytes(
options: Option<&Object<'_>>,
key: &str,
encoding: &str,
) -> Result<Vec<u8>, String> {
if let Some(options) = options {
let value: Value = options.get(key).map_err(|error| error.to_string())?;
if let Ok(value) = TypedArray::<u8>::from_value(value) {
// runtimeOptionString converts []byte to a Go string without UTF-8
// replacement or trimming. Preserve arbitrary binary keys and IVs.
if matches!(
encoding.trim().to_lowercase().as_str(),
"" | "utf8" | "utf-8" | "text"
) {
return Ok(copy_typed(&value));
}
}
}
decode_string(&option_string(options, key, "")?, encoding)
}
pub(crate) fn encode(bytes: &[u8], encoding: &str) -> Result<String, String> {
match encoding.trim().to_lowercase().as_str() {
"" | "base64" => Ok(STANDARD.encode(bytes)),
"hex" => {
use std::fmt::Write;
let mut value = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(value, "{byte:02x}");
}
Ok(value)
}
"utf8" | "utf-8" | "text" => Ok(decode_go_utf8(bytes)),
_ => Err(format!("unsupported byte encoding: {encoding}")),
}
}
pub(crate) fn decode_value(value: Value<'_>, encoding: &str) -> Result<Vec<u8>, String> {
if let Some(text) = value.as_string() {
return decode_string(
&text.to_string().map_err(|error| error.to_string())?,
encoding,
);
}
if let Ok(bytes) = TypedArray::<u8>::from_value(value.clone()) {
return Ok(copy_typed(&bytes));
}
if let Some(array) = value.as_array() {
return array
.iter::<Value>()
.enumerate()
.map(|(index, item)| {
let value = item.map_err(|error| error.to_string())?;
let number = value
.as_number()
.ok_or_else(|| format!("unsupported byte array item at index {index}"))?;
// Goja exports integral values within int64 as int64. Other
// numbers use Go's float64 -> native int conversion first.
if number.fract() == 0.0
&& (-9_223_372_036_854_775_808.0..9_223_372_036_854_775_808.0).contains(&number)
{
return Ok(number as i64 as u8);
}
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
return Ok(number as isize as u8);
#[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))]
Ok(go_float_i64(number) as u8)
})
.collect();
}
Err("unsupported byte payload type".into())
}
#[allow(unsafe_code)]
fn copy_typed(bytes: &TypedArray<'_, u8>) -> Vec<u8> {
// SAFETY: only this worker can access the VM. Finish the owned copy before
// any engine call, getter, callback, or operation that can detach a buffer.
unsafe { bytes.as_bytes() }.unwrap_or_default().to_vec()
}
fn go_float_i64(number: f64) -> i64 {
#[cfg(target_arch = "aarch64")]
return number as i64;
#[cfg(target_arch = "arm")]
{
// Go ARM32 uses runtime._d2v for int64 conversions, while byte-array
// conversion above uses the hardware's native int32 instruction.
let bits = number.to_bits();
let shift = ((bits >> 52) & 0x7ff) as i32 - 1075;
let mantissa = (bits & ((1_u64 << 52) - 1)) | (1_u64 << 52);
let magnitude = if shift < -63 {
0
} else if shift < 0 {
mantissa >> -shift
} else if shift <= 11 {
mantissa << shift
} else {
u64::from(number as u32) << 32
};
return if number.is_sign_negative() {
magnitude.wrapping_neg() as i64
} else {
magnitude as i64
};
}
#[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))]
if number.is_finite()
&& (-9_223_372_036_854_775_808.0..9_223_372_036_854_775_808.0).contains(&number)
{
number as i64
} else {
i64::MIN
}
}
@@ -0,0 +1,150 @@
//! Block modes retained for the existing extension API and media transforms.
use aes::cipher::{Block, BlockDecrypt, BlockEncrypt, KeyInit};
use zeroize::Zeroizing;
pub enum BlockCipher {
Aes128(Box<aes::Aes128>),
Aes192(Box<aes::Aes192>),
Aes256(Box<aes::Aes256>),
Blowfish(Box<blowfish::Blowfish>),
}
impl BlockCipher {
pub fn new(algorithm: &str, key: &[u8]) -> Result<Self, String> {
Ok(match algorithm {
"aes" => match key.len() {
16 => Self::Aes128(Box::new(
aes::Aes128::new_from_slice(key).expect("AES-128 key"),
)),
24 => Self::Aes192(Box::new(
aes::Aes192::new_from_slice(key).expect("AES-192 key"),
)),
32 => Self::Aes256(Box::new(
aes::Aes256::new_from_slice(key).expect("AES-256 key"),
)),
size => return Err(format!("crypto/aes: invalid key size {size}")),
},
"blowfish" => {
if key.is_empty() || key.len() > 56 {
return Err(format!("crypto/blowfish: invalid key size {}", key.len()));
}
// Go accepts 1–56 bytes; RustCrypto starts at four. Repeating a
// short key preserves Blowfish's cyclic key expansion exactly.
let key = Zeroizing::new(if key.len() < 4 {
key.repeat(4_usize.div_ceil(key.len()))
} else {
key.to_vec()
});
Self::Blowfish(Box::new(
blowfish::Blowfish::new_from_slice(&key).expect("Blowfish key"),
))
}
_ => return Err(format!("unsupported block cipher algorithm: {algorithm}")),
})
}
pub fn block_size(&self) -> usize {
if matches!(self, Self::Blowfish(_)) {
8
} else {
16
}
}
/// CBC requires aligned input; CTR increments the entire big-endian counter
/// and can transform a partial final block. Input is an owned Rust snapshot.
pub fn transform(
&self,
data: &mut [u8],
iv: &[u8],
mode: &str,
decrypt: bool,
check: &dyn Fn() -> Result<(), String>,
) -> Result<(), String> {
let size = self.block_size();
if iv.len() != size {
return Err(format!("iv must be {size} bytes"));
}
if mode != "cbc" && mode != "ctr" {
return Err(format!("unsupported block cipher mode: {mode}"));
}
if mode == "cbc" && !data.len().is_multiple_of(size) {
return Err(format!("input length must be a multiple of {size} bytes"));
}
match self {
Self::Aes128(cipher) => transform(cipher.as_ref(), data, iv, mode, decrypt, check),
Self::Aes192(cipher) => transform(cipher.as_ref(), data, iv, mode, decrypt, check),
Self::Aes256(cipher) => transform(cipher.as_ref(), data, iv, mode, decrypt, check),
Self::Blowfish(cipher) => transform(cipher.as_ref(), data, iv, mode, decrypt, check),
}
}
}
fn transform<C: BlockEncrypt + BlockDecrypt>(
cipher: &C,
data: &mut [u8],
iv: &[u8],
mode: &str,
decrypt: bool,
check: &dyn Fn() -> Result<(), String>,
) -> Result<(), String> {
let mut state = Block::<C>::clone_from_slice(iv);
for (index, chunk) in data.chunks_mut(iv.len()).enumerate() {
if index % 1024 == 0 {
check()?;
}
if mode == "ctr" {
let mut block = state.clone();
cipher.encrypt_block(&mut block);
for (byte, mask) in chunk.iter_mut().zip(block.iter()) {
*byte ^= mask;
}
for byte in state.iter_mut().rev() {
*byte = byte.wrapping_add(1);
if *byte != 0 {
break;
}
}
} else if decrypt {
let encrypted = Block::<C>::clone_from_slice(chunk);
let mut block = encrypted.clone();
cipher.decrypt_block(&mut block);
for ((byte, plain), previous) in chunk.iter_mut().zip(block.iter()).zip(state.iter()) {
*byte = plain ^ previous;
}
state = encrypted;
} else {
for (byte, previous) in chunk.iter_mut().zip(state.iter()) {
*byte ^= previous;
}
let block = Block::<C>::from_mut_slice(chunk);
cipher.encrypt_block(block);
state.clone_from(block);
}
}
check()
}
pub fn pad(data: &mut Vec<u8>, block_size: usize) {
let count = block_size - data.len() % block_size;
data.resize(data.len() + count, count as u8);
}
pub fn unpad(data: &mut Vec<u8>, block_size: usize) -> Result<(), String> {
if data.is_empty() || !data.len().is_multiple_of(block_size) {
return Err("invalid padded payload length".into());
}
let count = usize::from(*data.last().expect("nonempty padded data"));
if count == 0
|| count > block_size
|| count > data.len()
|| data[data.len() - count..]
.iter()
.any(|byte| usize::from(*byte) != count)
{
return Err("invalid PKCS7 padding".into());
}
data.truncate(data.len() - count);
Ok(())
}
@@ -0,0 +1,272 @@
use crate::{
binary,
crypto::{self, BlockCipher},
host::decode_go_utf8,
runtime::Control,
storage,
};
use aes_gcm::aead::{OsRng, rand_core::RngCore};
use base64::{Engine, engine::general_purpose::STANDARD};
use rquickjs::{ArrayBuffer, Ctx, Function, Object, Value};
use sha2::{Digest, Sha256};
use std::sync::Arc;
use zeroize::Zeroizing;
pub(crate) fn register<'js>(
ctx: &Ctx<'js>,
host: &Object<'js>,
control: Arc<Control>,
) -> rquickjs::Result<()> {
let text_control = Arc::clone(&control);
host.set(
"cryptoText",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>, decrypt: bool, data: String, key: String| {
let data = Zeroizing::new(data);
let key = Zeroizing::new(key);
let key = Zeroizing::new(<[u8; 32]>::from(Sha256::digest(key.as_bytes())));
let result = (|| {
text_control.check().map_err(|error| error.to_string())?;
let output = if decrypt {
let bytes = Zeroizing::new(
binary::decode_base64(&data)
.map_err(|_| "invalid base64 ciphertext".to_owned())?,
);
let plain = storage::decrypt(&bytes, &key)
.map_err(|_| "invalid base64 ciphertext".to_owned())?;
decode_go_utf8(&plain)
} else {
STANDARD.encode(
storage::encrypt(data.as_bytes(), &key)
.map_err(|error| error.to_string())?,
)
};
text_control.check().map_err(|error| error.to_string())?;
Ok(output)
})();
let object = result_object(&ctx, result.as_ref().err())?;
if let Ok(data) = result {
object.set("data", data)?;
}
Ok::<_, rquickjs::Error>(object)
},
)?,
)?;
host.set(
"generateKey",
Function::new(ctx.clone(), |ctx: Ctx<'js>, length: u32| {
let result = (|| {
if !(1..=4096).contains(&length) {
return Err("key length must be an integer between 1 and 4096 bytes".to_owned());
}
let mut bytes = Zeroizing::new(vec![0; length as usize]);
OsRng
.try_fill_bytes(&mut bytes)
.map_err(|error| error.to_string())?;
Ok(bytes)
})();
let object = result_object(&ctx, result.as_ref().err())?;
if let Ok(bytes) = result {
object.set("key", STANDARD.encode(&*bytes))?;
object.set("hex", binary::encode(&bytes, "hex").expect("hex encoding"))?;
}
Ok::<_, rquickjs::Error>(object)
})?,
)?;
host.set(
"blockTransform",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>, operation: String, data: Value<'js>, options: Value<'js>| {
let check = || control.check().map_err(|error| error.to_string());
let result = if operation == "segments" {
segments(data, options, &check)
} else {
block(data, options, operation == "decrypt", &check)
};
let object = result_object(&ctx, result.as_ref().err())?;
if let Ok(result) = result {
object.set(result.count_name, result.count)?;
if result.raw {
// Charge retained output to QuickJS's heap; external Rust-owned
// ArrayBuffers would bypass the runtime's memory limit.
object.set("data", ArrayBuffer::new_copy(ctx.clone(), &*result.bytes)?)?;
} else {
match binary::encode(&result.bytes, &result.encoding) {
Ok(data) => object.set("data", data)?,
Err(error) => return result_object(&ctx, Some(&error)),
}
}
}
Ok(object)
},
)?,
)?;
Ok(())
}
fn result_object<'js>(ctx: &Ctx<'js>, error: Option<&String>) -> rquickjs::Result<Object<'js>> {
let object = Object::new(ctx.clone())?;
object.set("success", error.is_none())?;
if let Some(error) = error {
object.set("error", error.as_str())?;
}
Ok(object)
}
struct Output {
bytes: Zeroizing<Vec<u8>>,
encoding: String,
raw: bool,
count_name: &'static str,
count: usize,
}
fn block(
data: Value<'_>,
options: Value<'_>,
decrypt: bool,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Output, String> {
check()?;
let options = options.as_object();
let text = |key, fallback| binary::option_string(options, key, fallback);
let algorithm = text("algorithm", "")?.to_lowercase();
let mode = text("mode", "cbc")?.to_lowercase();
let input_encoding = text("inputEncoding", "base64")?.to_lowercase();
let encoding = text("outputEncoding", "base64")?.to_lowercase();
let padding = text("padding", "none")?.to_lowercase();
if algorithm.is_empty() {
return Err("algorithm is required".into());
}
let key = Zeroizing::new(
binary::decode_option_bytes(options, "key", &text("keyEncoding", "utf8")?)
.map_err(|error| format!("invalid key: {error}"))?,
);
if key.is_empty() {
return Err("key is required".into());
}
let iv = binary::decode_option_bytes(options, "iv", &text("ivEncoding", "utf8")?)
.map_err(|error| format!("invalid iv: {error}"))?;
if mode != "cbc" && mode != "ctr" {
return Err(format!("unsupported block cipher mode: {mode}"));
}
let mut bytes = Zeroizing::new(binary::decode_value(data, &input_encoding)?);
let cipher = BlockCipher::new(&algorithm, &key)?;
let size = cipher.block_size();
if iv.len() != size {
return Err(format!(
"{} must be {size} bytes for {algorithm}",
if mode == "ctr" { "iv (counter)" } else { "iv" }
));
}
if mode == "cbc" && !decrypt && padding == "pkcs7" {
crypto::pad(&mut bytes, size);
}
cipher.transform(&mut bytes, &iv, &mode, decrypt, check)?;
if mode == "cbc" && decrypt && padding == "pkcs7" {
crypto::unpad(&mut bytes, size)?;
}
Ok(Output {
bytes,
encoding,
raw: false,
count_name: "block_size",
count: size,
})
}
fn segments(
data: Value<'_>,
options: Value<'_>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Output, String> {
check()?;
let options = options.as_object().ok_or("options object is required")?;
let text = |key, fallback| binary::option_string(Some(options), key, fallback);
let algorithm = text("algorithm", "aes")?.to_lowercase();
let input_encoding = text("inputEncoding", "base64")?.to_lowercase();
let encoding = text("outputEncoding", "base64")?.to_lowercase();
let iv_encoding = text("ivEncoding", "base64")?.to_lowercase();
let key = Zeroizing::new(
binary::decode_option_bytes(Some(options), "key", &text("keyEncoding", "hex")?)
.map_err(|error| format!("invalid key: {error}"))?,
);
if key.is_empty() {
return Err("key is required".into());
}
if algorithm != "aes" && algorithm != "blowfish" {
return Err(format!("unsupported algorithm: {algorithm}"));
}
let cipher = BlockCipher::new(&algorithm, &key)?;
let size = cipher.block_size();
let raw_input = input_encoding == "bytes" || input_encoding == "raw";
let mut bytes = Zeroizing::new(
binary::decode_value(data, if raw_input { "" } else { &input_encoding }).map_err(
|error| {
if raw_input {
format!("invalid byte payload: {error}")
} else {
error
}
},
)?,
);
let segments: Value = options.get("segments").map_err(|error| error.to_string())?;
if segments.is_null() || segments.is_undefined() {
return Err("segments array is required".into());
}
let segments = segments.as_array().ok_or("segments must be an array")?;
let mut count = 0;
for (index, segment) in segments.iter::<Value>().enumerate() {
check()?;
let segment = segment.map_err(|error| error.to_string())?;
let segment = segment
.as_object()
.ok_or_else(|| format!("segment {index} is not an object"))?;
let offset = binary::option_i64(segment, "offset", -1)?;
let length = binary::option_i64(segment, "size", -1)?;
if offset < 0 || length < 0 {
return Err(format!("segment {index} has invalid offset/size"));
}
if length == 0 {
continue;
}
let offset = offset as u64;
let length = length as u64;
if offset
.checked_add(length)
.is_none_or(|end| end > bytes.len() as u64)
{
return Err(format!(
"segment {index} out of bounds (offset={offset} size={length} len={})",
bytes.len()
));
}
let mut iv = binary::decode_option_bytes(Some(segment), "iv", &iv_encoding)
.map_err(|error| format!("segment {index} has invalid iv: {error}"))?;
if iv.len() > size {
return Err(format!(
"segment {index} iv longer than block size ({} > {size})",
iv.len()
));
}
iv.resize(size, 0);
cipher.transform(
&mut bytes[offset as usize..(offset + length) as usize],
&iv,
"ctr",
true,
check,
)?;
count += 1;
}
Ok(Output {
bytes,
raw: encoding == "bytes" || encoding == "raw",
encoding,
count_name: "segments_processed",
count,
})
}
@@ -0,0 +1,361 @@
use super::store::DownloadFile;
use super::{
DownloadOptions, Failure, fingerprint, header, next_delay, retryable, set_header,
status_failure, validator,
};
use crate::files::FilePath;
use spotiflac_network::{HttpRequest, HttpResponse, HttpStream, NetworkSession};
use std::io::Write;
use std::time::Duration;
#[allow(clippy::too_many_arguments)]
pub(super) fn download(
path: &FilePath,
network: &NetworkSession,
url: &str,
options: DownloadOptions,
chunk_size: u64,
check: &dyn Fn() -> Result<(), String>,
wait: &dyn Fn(Duration) -> Result<(), String>,
progress: &mut dyn FnMut(u64, i64),
) -> Result<serde_json::Value, Failure> {
options.item.start();
let mut attempts = 0;
let mut delay = Duration::from_millis(options.policy.initial_retry_delay_ms as u64);
let probe = loop {
attempts += 1;
check().map_err(|error| Failure::new("cancelled", error, attempts))?;
let response = open(network, url, &options, "bytes=0-1", "", false, check);
let failure = match response {
Ok(stream) if matches!(stream.response.status, 200 | 206) => break stream.response,
Ok(stream) => {
let mut failure = status_failure(&stream.response, &options.policy, attempts);
failure.error = format!("chunked probe HTTP {}", stream.response.status);
failure
}
Err(error) => Failure::new(
"transient_network",
format!("chunked probe failed: {error}"),
attempts,
),
};
check().map_err(|error| Failure::new("cancelled", error, attempts))?;
if attempts >= options.policy.max_attempts
|| (failure.http_status > 0 && !retryable(failure.http_status))
{
return Err(failure);
}
wait(retry_delay(&failure, delay))
.map_err(|error| Failure::new("cancelled", error, attempts))?;
delay = next_delay(delay, &options.policy);
};
let total = total_length(&probe);
let validator = validator(&probe);
let fingerprint = fingerprint(url);
let mut output = DownloadFile::open(
path,
&fingerprint,
options.persistent && !validator.is_empty() && !fingerprint.is_empty(),
)
.map_err(|error| {
Failure::new(
"storage_error",
format!("failed to create chunked staged file: {error}"),
0,
)
})?;
if output.restored
&& (output.state.validator != validator
|| (output.state.total > 0 && total > 0 && output.state.total != total))
{
output
.restart()
.map_err(|error| Failure::new("storage_error", error, 0))?;
}
output.state.validator = validator.clone();
output.state.total = total;
options.item.initial(output.state.bytes, total);
let reporter = options.item.reporter(output.state.bytes, total);
let mut notified = output.state.bytes;
let mut completed_chunk = false;
let mut buffer = [0; 64 << 10];
while output.state.total <= 0 || output.state.bytes < output.state.total as u64 {
let mut start = output.state.bytes;
let mut end = start.saturating_add(chunk_size - 1);
if output.state.total > 0 {
end = end.min(output.state.total as u64 - 1);
}
let mut delay = Duration::from_millis(options.policy.initial_retry_delay_ms as u64);
let mut full_response = false;
let mut chunk_complete = false;
let mut failure = Failure::new("transient_network", "chunked transfer failed", attempts);
for attempt in 1..=options.policy.max_attempts {
attempts += 1;
check().map_err(|error| Failure::new("cancelled", error, attempts))?;
let continuation = attempt == 1 && completed_chunk;
let response = open(
network,
url,
&options,
&format!("bytes={start}-{end}"),
&validator,
continuation,
check,
);
let mut stream = match response {
Ok(stream) => stream,
Err(error) => {
check().map_err(|error| Failure::new("cancelled", error, attempts))?;
failure = Failure::new(
"transient_network",
format!("chunked request at {start} failed: {error}"),
attempts,
);
if attempt == options.policy.max_attempts {
break;
}
wait(delay).map_err(|error| Failure::new("cancelled", error, attempts))?;
delay = next_delay(delay, &options.policy);
continue;
}
};
let status = stream.response.status;
if !matches!(status, 200 | 206) {
failure = status_failure(&stream.response, &options.policy, attempts);
failure.error = format!("chunked HTTP {status} at offset {start}");
drop(stream);
if !retryable(status) || attempt == options.policy.max_attempts {
break;
}
wait(retry_delay(&failure, delay))
.map_err(|error| Failure::new("cancelled", error, attempts))?;
delay = next_delay(delay, &options.policy);
continue;
}
let range = header(&stream.response, "Content-Range");
if status == 206 && !range.starts_with(&format!("bytes {start}-")) {
let mut failure = Failure::new(
"integrity_failed",
format!("chunked response has unexpected Content-Range {range:?} at {start}"),
attempts,
);
failure.http_status = status;
return Err(failure);
}
let next_validator = super::validator(&stream.response);
if !next_validator.is_empty() && !validator.is_empty() && next_validator != validator {
return Err(Failure::new(
"integrity_failed",
"chunked response validator changed during transfer",
attempts,
));
}
let length = header(&stream.response, "Content-Length")
.parse::<i64>()
.unwrap_or(-1);
if status == 200 && start > 0 {
output.restart().map_err(|error| {
Failure::new(
"storage_error",
format!("failed to restart ignored range: {error}"),
attempts,
)
})?;
output.state.validator = validator.clone();
start = 0;
if length > 0 {
output.state.total = length;
}
}
let mut received = continuation;
let body_result = loop {
let guard = options
.budget
.as_ref()
.map(|budget| budget.enter(!received));
let read = stream.read(&mut buffer, check);
drop(guard);
match read {
Ok(0) => break Ok(()),
Ok(count) => {
received = true;
output.file.write_all(&buffer[..count]).map_err(|error| {
Failure::new(
"storage_error",
format!("failed to write chunked output: {error}"),
attempts,
)
})?;
output.state.bytes += count as u64;
reporter.report(output.state.bytes as i64, output.state.total);
if output.state.total > 0
&& (output.state.bytes.saturating_sub(notified) >= 128 << 10
|| output.state.bytes >= output.state.total as u64)
{
notified = output.state.bytes;
let _charge = options.budget.as_ref().map(|budget| budget.enter(true));
progress(output.state.bytes, output.state.total);
}
}
Err(error) => break Err(error),
}
};
drop(stream);
let written = output.state.bytes - start;
let expected = if status == 206 && length <= 0 {
end.saturating_sub(start).saturating_add(1) as i64
} else {
length
};
let body_result = body_result.and_then(|()| {
if expected > 0 && written != expected as u64 {
Err("unexpected EOF".into())
} else {
Ok(())
}
});
if body_result.is_ok() && written > 0 {
full_response = status == 200;
chunk_complete = true;
completed_chunk = true;
break;
}
output.rewind(start).map_err(|error| {
Failure::new(
"storage_error",
format!("failed to roll back incomplete chunk: {error}"),
attempts,
)
})?;
if output.state.total > 0 {
options.item.set(
start as f64 / output.state.total as f64,
start,
output.state.total,
);
}
failure = Failure::new(
"transient_network",
body_result.err().map_or_else(
|| format!("chunk at {start} was empty"),
|error| format!("failed to read chunk at {start}: {error}"),
),
attempts,
);
if let Err(error) = check() {
output.checkpoint(true);
return Err(Failure::new("cancelled", error, attempts));
}
if attempt == options.policy.max_attempts {
break;
}
wait(delay).map_err(|error| Failure::new("cancelled", error, attempts))?;
delay = next_delay(delay, &options.policy);
}
if !chunk_complete {
output.checkpoint(true);
return Err(failure);
}
output.checkpoint(false);
if full_response || (output.state.total <= 0 && output.state.bytes - start < chunk_size) {
break;
}
}
if output.state.bytes == 0
|| (output.state.total > 0 && output.state.bytes != output.state.total as u64)
{
return Err(Failure::new(
"integrity_failed",
format!(
"chunked transfer size mismatch: expected {} bytes, wrote {}",
output.state.total, output.state.bytes
),
attempts,
));
}
output.publish(check).map_err(|error| {
Failure::new(
"storage_error",
format!("failed to publish file: {error}"),
attempts,
)
})?;
options
.item
.set(1.0, output.state.bytes, output.state.bytes as i64);
Ok(
serde_json::json!({"success":true,"path":path.display(),"size":output.state.bytes,"attempts":attempts}),
)
}
#[allow(clippy::too_many_arguments)]
fn open(
network: &NetworkSession,
url: &str,
options: &DownloadOptions,
range: &str,
validator: &str,
continuation: bool,
check: &dyn Fn() -> Result<(), String>,
) -> Result<HttpStream, String> {
let mut headers = options.headers.clone();
set_header(&mut headers, "Range", range.to_owned());
if !validator.is_empty() {
set_header(&mut headers, "If-Range", validator.to_owned());
}
let user_agent = options
.headers
.get("User-Agent")
.filter(|value| !value.is_empty())
.cloned()
.unwrap_or_else(|| options.app_version.user_agent());
set_header(&mut headers, "User-Agent", user_agent.clone());
let _guard = options
.budget
.as_ref()
.map(|budget| budget.enter(!continuation));
network.open_stream(
HttpRequest {
url: url.to_owned(),
method: "GET".into(),
body: String::new(),
headers,
default_json: false,
user_agent,
},
Duration::from_secs(24 * 60 * 60),
Duration::from_secs(60),
check,
)
}
fn total_length(response: &HttpResponse) -> i64 {
if let Some((_, total)) = header(response, "Content-Range").rsplit_once('/') {
// Go's chunk probe uses Sscanf rather than the ordinary strict parser.
let prefix: String = total
.trim_start()
.chars()
.enumerate()
.take_while(|(index, ch)| {
ch.is_ascii_digit() || (*index == 0 && (*ch == '-' || *ch == '+'))
})
.map(|(_, ch)| ch)
.collect();
if let Ok(total) = prefix.parse() {
return total;
}
}
if response.status == 200 {
header(response, "Content-Length").parse().unwrap_or(-1)
} else {
0
}
}
fn retry_delay(failure: &Failure, fallback: Duration) -> Duration {
if failure.retry_after_seconds > 0 {
Duration::from_secs(failure.retry_after_seconds.into())
} else {
fallback
}
}
@@ -0,0 +1,494 @@
mod chunked;
pub(crate) mod progress;
mod segment_store;
pub(crate) mod segments;
mod store;
use crate::files::{ExtensionFiles, FilePath};
use crate::transfer_policy::DownloadTransferPolicy;
use aes_gcm::aead::{OsRng, rand_core::RngCore};
use serde::Serialize;
use sha2::{Digest, Sha256};
use spotiflac_core::app_version::AppVersion;
use spotiflac_network::{HttpRequest, HttpResponse, NetworkSession, url::UrlParts};
use std::collections::BTreeMap;
use std::io::Write;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use store::DownloadFile;
#[derive(Serialize)]
pub(crate) struct Failure {
success: bool,
error: String,
error_type: &'static str,
attempts: i64,
#[serde(skip_serializing_if = "is_zero")]
http_status: u16,
#[serde(skip_serializing_if = "is_zero")]
retry_after_seconds: u16,
}
fn is_zero(value: &u16) -> bool {
*value == 0
}
/// Go's native exports share this envelope, including file-only operations.
/// It is distinct from the JS SDK transfer failure payload above.
pub(crate) fn native_error_response(message: &str) -> serde_json::Value {
let lower = message.to_lowercase();
let kind = [
(
"isp_blocked",
&["isp blocking", "try using vpn", "change dns"][..],
),
("cancelled", &["cancel"][..]),
(
"verification_required",
&[
"verification_required",
"session is not authenticated",
"signed session is not authenticated",
"signed session expired",
][..],
),
(
"provider_reauth_required",
&["byoa_provider_reauth_required", "reauth_provider"][..],
),
("request_auth_invalid", &["request_auth_invalid"][..]),
("provider_auth_failed", &["provider_auth_failed"][..]),
("provider_unavailable", &["provider_unavailable"][..]),
(
"rate_limit",
&[
"rate limit",
"http 429",
"http status 429",
"status 429",
"429 for ",
"429:",
"429;",
"too many requests",
][..],
),
(
"permission",
&[
"permission",
"operation not permitted",
"access denied",
"failed to create file",
"failed to create directory",
][..],
),
(
"not_found",
&[
"not found",
"not available",
"no results",
"track not found",
"all services failed",
][..],
),
("network", &["network", "connection", "timeout", "dial"][..]),
]
.into_iter()
.find(|(_, patterns)| patterns.iter().any(|pattern| lower.contains(pattern)))
.map_or("unknown", |(kind, _)| kind);
serde_json::json!({"success":false,"message":"","error":message,"error_type":kind})
}
impl Failure {
pub(crate) fn new(kind: &'static str, error: impl ToString, attempts: i64) -> Self {
Self {
success: false,
error: error.to_string(),
error_type: kind,
attempts,
http_status: 0,
retry_after_seconds: 0,
}
}
}
pub(crate) struct DownloadOptions {
pub item: progress::ItemProgressTarget,
pub headers: BTreeMap<String, String>,
pub app_version: AppVersion,
pub resume: bool,
pub persistent: bool,
pub policy: DownloadTransferPolicy,
pub budget: Option<Arc<crate::resolution::ResolutionBudget>>,
pub chunk_size: Option<u64>,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn download(
files: &ExtensionFiles,
path: &FilePath,
network: &NetworkSession,
url: &str,
mut options: DownloadOptions,
check: &dyn Fn() -> Result<(), String>,
wait: &dyn Fn(Duration) -> Result<(), String>,
progress: &mut dyn FnMut(u64, i64),
) -> Result<serde_json::Value, Failure> {
let _guard = files
.lock(path, check)
.map_err(|error| Failure::new("cancelled", error, 0))?;
if let Some(chunk_size) = options.chunk_size {
return chunked::download(
path, network, url, options, chunk_size, check, wait, progress,
);
}
let caller_range = options
.headers
.keys()
.any(|name| name.eq_ignore_ascii_case("Range"));
if caller_range {
options.resume = false;
options.persistent = false;
}
let fingerprint = fingerprint(url);
let mut output = DownloadFile::open(
path,
&fingerprint,
options.resume && options.persistent && !fingerprint.is_empty(),
)
.map_err(|error| {
Failure::new(
"storage_error",
format!("failed to create staged file: {error}"),
0,
)
})?;
options.item.start();
options.item.initial(output.state.bytes, output.state.total);
let reporter = options
.item
.reporter(output.state.bytes, output.state.total);
if output.restored
&& output.state.bytes > 0
&& output.state.total > 0
&& output.state.bytes == output.state.total as u64
{
output.publish(check).map_err(|error| {
Failure::new(
"storage_error",
format!("failed to publish restored download: {error}"),
0,
)
})?;
options
.item
.set(1.0, output.state.bytes, output.state.total);
return Ok(
serde_json::json!({"success":true,"path":path.display(),"size":output.state.bytes,"attempts":0,"resumed":true}),
);
}
let mut retry_delay = Duration::from_millis(options.policy.initial_retry_delay_ms as u64);
let mut notified = 0_u64;
for attempt in 1..=options.policy.max_attempts {
check().map_err(|error| Failure::new("cancelled", error, attempt))?;
let mut range_from = if options.resume && !output.state.validator.is_empty() {
output.state.bytes
} else {
0
};
let mut headers = options.headers.clone();
if range_from > 0 {
set_header(&mut headers, "Range", format!("bytes={range_from}-"));
set_header(&mut headers, "If-Range", output.state.validator.clone());
}
let request = HttpRequest {
url: url.to_owned(),
method: "GET".into(),
body: String::new(),
headers,
default_json: false,
user_agent: options.app_version.user_agent(),
};
let charge = options.budget.as_ref().map(|budget| budget.enter(true));
let stream = network.open_stream(
request,
Duration::from_secs(24 * 60 * 60),
Duration::from_secs(60),
check,
);
drop(charge);
let mut stream = match stream {
Ok(stream) => stream,
Err(error) => {
check().map_err(|error| Failure::new("cancelled", error, attempt))?;
if attempt == options.policy.max_attempts {
return Err(Failure::new("transient_network", error, attempt));
}
if output.state.bytes > 0 && (!options.resume || output.state.validator.is_empty())
{
output
.restart()
.map_err(|error| Failure::new("storage_error", error, attempt))?;
}
wait(retry_delay).map_err(|error| Failure::new("cancelled", error, attempt))?;
retry_delay = next_delay(retry_delay, &options.policy);
continue;
}
};
let response = &stream.response;
let status = response.status;
if !(200..300).contains(&status) {
let failure = status_failure(response, &options.policy, attempt);
let delay = if failure.retry_after_seconds > 0 {
Duration::from_secs(failure.retry_after_seconds.into())
} else {
retry_delay
};
drop(stream);
if !retryable(status) || attempt == options.policy.max_attempts {
return Err(failure);
}
wait(delay).map_err(|error| Failure::new("cancelled", error, attempt))?;
retry_delay = next_delay(retry_delay, &options.policy);
continue;
}
if range_from > 0 && status == 206 {
let range = header(response, "Content-Range");
if !range.starts_with(&format!("bytes {range_from}-")) {
let mut failure = Failure::new(
"integrity_failed",
format!(
"resume failed: unexpected Content-Range {range:?} at {range_from} bytes"
),
attempt,
);
failure.http_status = status;
return Err(failure);
}
let validator = validator(response);
if !validator.is_empty() && validator != output.state.validator {
let mut failure = Failure::new(
"integrity_failed",
"resume failed: response validator changed",
attempt,
);
failure.http_status = status;
return Err(failure);
}
}
if range_from > 0 && status == 200 {
output
.rewind(0)
.map_err(|error| Failure::new("storage_error", error, attempt))?;
range_from = 0;
}
let validator = validator(response);
if !validator.is_empty() {
output.state.validator = validator;
}
let length = header(response, "Content-Length")
.parse::<i64>()
.unwrap_or(-1);
output.state.total = if status == 206 && !caller_range {
header(response, "Content-Range")
.rsplit_once('/')
.and_then(|(_, total)| total.parse().ok())
.unwrap_or(if length > 0 {
(range_from as i64).saturating_add(length)
} else {
0
})
} else {
length
};
if output.state.total > 0 {
reporter.report(output.state.bytes as i64, output.state.total);
}
let mut buffer = [0; 64 << 10];
let mut received_bytes = false;
let body_result = loop {
let budget = options
.budget
.as_ref()
.map(|budget| budget.enter(!received_bytes || !matches!(status, 200 | 206)));
let read = stream.read(&mut buffer, check);
drop(budget);
match read {
Ok(0) => break Ok(()),
Ok(count) => {
received_bytes = true;
if let Err(error) = output.file.write_all(&buffer[..count]) {
return Err(Failure::new(
"storage_error",
format!("failed to write staged file: {error}"),
attempt,
));
}
output.state.bytes += count as u64;
reporter.report(output.state.bytes as i64, output.state.total);
if output.state.total > 0
&& (output.state.bytes.saturating_sub(notified) >= 128 << 10
|| output.state.bytes >= output.state.total as u64)
{
notified = output.state.bytes;
let _charge = options.budget.as_ref().map(|budget| budget.enter(true));
progress(output.state.bytes, output.state.total);
}
output.checkpoint(false);
}
Err(error) => break Err(error),
}
};
drop(stream);
let body_result = body_result.and_then(|()| {
if output.state.total > 0 && output.state.bytes != output.state.total as u64 {
Err("unexpected EOF".into())
} else {
Ok(())
}
});
if let Err(error) = body_result {
output.checkpoint(true);
check().map_err(|error| Failure::new("cancelled", error, attempt))?;
if attempt == options.policy.max_attempts {
return Err(Failure::new(
"transient_network",
format!("failed to read response: {error}"),
attempt,
));
}
if !options.resume || output.state.validator.is_empty() {
output
.restart()
.map_err(|error| Failure::new("storage_error", error, attempt))?;
}
wait(retry_delay).map_err(|error| Failure::new("cancelled", error, attempt))?;
retry_delay = next_delay(retry_delay, &options.policy);
continue;
}
if output.state.bytes == 0 {
return Err(Failure::new(
"integrity_failed",
"download response was empty",
attempt,
));
}
output.publish(check).map_err(|error| {
Failure::new(
"storage_error",
format!("failed to publish file: {error}"),
0,
)
})?;
if output.state.total > 0 {
options
.item
.set(1.0, output.state.bytes, output.state.total);
} else {
options.item.received(output.state.bytes);
}
return Ok(
serde_json::json!({"success":true,"path":path.display(),"size":output.state.bytes,"attempts":attempt}),
);
}
unreachable!("policy has at least one attempt")
}
fn set_header(headers: &mut BTreeMap<String, String>, name: &str, value: String) {
headers.retain(|key, _| !key.eq_ignore_ascii_case(name));
headers.insert(name.to_owned(), value);
}
fn header<'a>(response: &'a HttpResponse, name: &str) -> &'a str {
response
.headers
.get(name)
.and_then(|values| values.first())
.map_or("", String::as_str)
}
fn validator(response: &HttpResponse) -> String {
let etag = header(response, "Etag").trim();
if !etag.is_empty() && !etag.to_uppercase().starts_with("W/") {
etag.to_owned()
} else {
header(response, "Last-Modified").trim().to_owned()
}
}
fn fingerprint(url: &str) -> String {
UrlParts::parse(url).map_or(String::new(), |url| {
format!(
"{:x}",
Sha256::digest(format!(
"{}://{}{}",
url.scheme.to_lowercase(),
url.authority().to_lowercase(),
url.escaped_path()
))
)
})
}
fn retryable(status: u16) -> bool {
matches!(status, 408 | 425 | 429 | 500..=u16::MAX)
}
fn status_failure(
response: &HttpResponse,
policy: &DownloadTransferPolicy,
attempt: i64,
) -> Failure {
let status = response.status;
let kind = if status == 429 {
"rate_limited"
} else if policy.refresh_stream_on_status.contains(&i64::from(status)) {
"expired_stream"
} else if retryable(status) {
"transient_network"
} else {
"http_error"
};
let mut failure = Failure::new(kind, format!("HTTP error: {status}"), attempt);
failure.http_status = status;
let value = header(response, "Retry-After");
let nanos = if let Ok(seconds) = value.parse::<isize>() {
i128::from((seconds as i64).wrapping_mul(1_000_000_000))
} else {
httpdate::parse_http_date(value)
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map_or(0, |time| {
time.as_nanos() as i128
- SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as i128
})
};
if nanos > 0 {
failure.retry_after_seconds =
((nanos.min(120_000_000_000) + 500_000_000) / 1_000_000_000).max(1) as u16;
}
failure
}
fn next_delay(current: Duration, policy: &DownloadTransferPolicy) -> Duration {
let ceiling = current
.saturating_mul(2)
.min(Duration::from_millis(policy.max_retry_delay_ms as u64));
let floor = Duration::from_millis(policy.initial_retry_delay_ms as u64);
if ceiling <= floor {
return ceiling;
}
let mut random = [0; 8];
if OsRng.try_fill_bytes(&mut random).is_err() {
return ceiling;
}
floor
+ Duration::from_nanos(
(u64::from_ne_bytes(random) as f64 / u64::MAX as f64
* (ceiling - floor).as_nanos() as f64) as u64,
)
}
@@ -0,0 +1,55 @@
use spotiflac_core::progress::{ItemTransferProgressReporter, ProgressRegistry};
use std::sync::Arc;
pub(crate) struct ItemProgressTarget {
registry: Arc<ProgressRegistry>,
id: String,
track: bool,
}
impl ItemProgressTarget {
pub fn new(registry: Arc<ProgressRegistry>, id: String, track: bool) -> Self {
Self {
registry,
id,
track,
}
}
pub fn start(&self) {
if !self.id.is_empty() {
let _ = self.registry.downloading(&self.id);
}
}
pub fn set(&self, progress: f64, bytes: u64, total: i64) {
if self.track && !self.id.is_empty() {
let _ = self
.registry
.set_progress(&self.id, progress, bytes as i64, total);
}
}
pub fn initial(&self, bytes: u64, total: i64) {
if total > 0 {
self.set(bytes as f64 / total as f64, bytes, total);
} else {
self.received(bytes);
}
}
pub fn received(&self, bytes: u64) {
if self.track && !self.id.is_empty() && bytes > 0 {
let _ = self.registry.set_received(&self.id, bytes as i64);
}
}
pub fn reporter(&self, bytes: u64, total: i64) -> ItemTransferProgressReporter {
ItemTransferProgressReporter::new(
Arc::clone(&self.registry),
if self.track { &self.id } else { "" },
bytes as i64,
total,
)
}
}
@@ -0,0 +1,274 @@
use super::store::{private_options, remove_if_present};
use crate::files::FilePath;
use cap_std::fs::{Dir, File};
use serde::{Deserialize, Serialize};
use std::ffi::OsString;
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::sync::Arc;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
#[derive(Default, Deserialize, Serialize)]
#[serde(default)]
struct Checkpoint {
version: i64,
fingerprint: String,
next_index: usize,
bytes: u64,
updated_at: u64,
}
pub(super) struct SegmentOutput {
pub parent: Arc<Dir>,
target: OsString,
pub staged: OsString,
checkpoint: OsString,
file: File,
state: Checkpoint,
keep: bool,
promoted: bool,
saved_bytes: u64,
saved_at: Instant,
segments: usize,
}
impl SegmentOutput {
pub fn open(
path: &FilePath,
fingerprint: String,
segments: usize,
keep: bool,
) -> io::Result<Self> {
let (parent, target) = path.open_parent()?;
let mut staged = target.clone();
staged.push(".partial");
let mut checkpoint = staged.clone();
checkpoint.push(".checkpoint.json.segments");
for name in [&staged, &checkpoint] {
match parent.symlink_metadata(name) {
Ok(info) if !info.is_file() => {
return Err(io::Error::other(
"download staging path is not a regular file",
));
}
Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error),
_ => {}
}
}
let state = keep
.then(|| {
let file = parent
.open_with(&checkpoint, private_options().read(true))
.ok()?;
let mut bytes = Vec::new();
file.take((1 << 20) + 1).read_to_end(&mut bytes).ok()?;
if bytes.len() > 1 << 20 {
return None;
}
let state: Checkpoint = serde_json::from_slice(&bytes).ok()?;
(state.version == 1
&& state.fingerprint == fingerprint
&& state.next_index <= segments
&& (state.next_index == 0) == (state.bytes == 0))
.then_some(state)
})
.flatten();
if state.is_none() {
remove_if_present(&parent, &staged)?;
remove_if_present(&parent, &checkpoint)?;
}
let mut file = parent.open_with(
&staged,
private_options().read(true).write(true).create(true),
)?;
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(io::Error::other(
"download staging path is not a regular file",
));
}
// A short file loses segment boundaries; the whole assembled prefix
// must be discarded instead of clamping its byte count.
let state = state
.filter(|state| metadata.len() >= state.bytes)
.unwrap_or_else(|| Checkpoint {
fingerprint,
..Checkpoint::default()
});
if state.next_index == 0 {
remove_if_present(&parent, &checkpoint)?;
}
file.set_len(state.bytes)?;
file.seek(SeekFrom::Start(state.bytes))?;
Ok(Self {
parent: Arc::new(parent),
target,
staged,
checkpoint,
file,
saved_bytes: state.bytes,
state,
keep,
promoted: false,
saved_at: Instant::now(),
segments,
})
}
pub fn next_index(&self) -> usize {
self.state.next_index
}
pub fn bytes(&self) -> u64 {
self.state.bytes
}
pub fn append(
&mut self,
segment: &mut SegmentFile,
size: u64,
check: &dyn Fn() -> Result<(), String>,
) -> Result<(), String> {
segment
.file
.seek(SeekFrom::Start(0))
.map_err(|error| error.to_string())?;
let mut buffer = [0; 128 << 10];
let mut copied = 0;
let result = (|| {
loop {
check()?;
let count = segment
.file
.read(&mut buffer)
.map_err(|error| error.to_string())?;
if count == 0 {
break;
}
self.file
.write_all(&buffer[..count])
.map_err(|error| error.to_string())?;
copied += count as u64;
}
if copied != size {
return Err("short segment copy".into());
}
Ok(())
})();
if result.is_ok() {
self.state.bytes += copied;
self.state.next_index += 1;
} else {
// Do not checkpoint a half-appended segment on cancellation or I/O failure.
let _ = self.file.set_len(self.state.bytes);
let _ = self.file.seek(SeekFrom::Start(self.state.bytes));
}
result
}
pub fn checkpoint(&mut self, force: bool) {
if !self.keep
|| self.state.next_index == 0
|| self.state.bytes == 0
|| (!force
&& self.state.bytes.saturating_sub(self.saved_bytes) < 8 << 20
&& self.saved_at.elapsed().as_secs() < 5)
{
return;
}
if self.save().is_ok() {
self.saved_bytes = self.state.bytes;
self.saved_at = Instant::now();
}
}
fn save(&mut self) -> io::Result<()> {
self.file.sync_all()?;
self.state.version = 1;
self.state.updated_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let bytes = serde_json::to_vec(&self.state)?;
let mut temporary = self.checkpoint.clone();
temporary.push(".tmp");
remove_if_present(&self.parent, &temporary)?;
let result = (|| {
let mut file = self
.parent
.open_with(&temporary, private_options().write(true).create_new(true))?;
file.write_all(&bytes)?;
file.sync_all()?;
drop(file);
self.parent
.rename(&temporary, &self.parent, &self.checkpoint)
})();
if result.is_err() {
let _ = self.parent.remove_file(&temporary);
}
result
}
pub fn publish(&mut self, check: &dyn Fn() -> Result<(), String>) -> Result<(), String> {
self.file.sync_all().map_err(|error| error.to_string())?;
check()?;
self.parent
.rename(&self.staged, &self.parent, &self.target)
.map_err(|error| error.to_string())?;
self.promoted = true;
let _ = self.parent.remove_file(&self.checkpoint);
let _ = self
.parent
.try_clone()
.and_then(|parent| parent.into_std_file().sync_all());
Ok(())
}
}
impl Drop for SegmentOutput {
fn drop(&mut self) {
// Also clean leftovers from a process that stopped before these workers started.
for index in 0..self.segments {
let _ = self.parent.remove_file(segment_name(&self.staged, index));
}
if self.promoted || !self.keep {
let _ = self.parent.remove_file(&self.checkpoint);
if !self.promoted {
let _ = self.parent.remove_file(&self.staged);
}
}
}
}
pub(super) struct SegmentFile {
parent: Arc<Dir>,
name: OsString,
pub file: File,
}
impl SegmentFile {
pub fn open(parent: &Arc<Dir>, staged: &OsString, index: usize) -> io::Result<Self> {
let name = segment_name(staged, index);
remove_if_present(parent, &name)?;
let file = parent.open_with(
&name,
private_options().read(true).write(true).create_new(true),
)?;
Ok(Self {
parent: Arc::clone(parent),
name,
file,
})
}
}
impl Drop for SegmentFile {
fn drop(&mut self) {
let _ = self.parent.remove_file(&self.name);
}
}
fn segment_name(staged: &OsString, index: usize) -> OsString {
let mut name = staged.clone();
name.push(format!(".segment.{index:06}"));
name
}
@@ -0,0 +1,381 @@
use super::segment_store::{SegmentFile, SegmentOutput};
use super::{DownloadOptions, Failure, header, next_delay, retryable, status_failure};
use crate::files::{ExtensionFiles, FilePath};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use spotiflac_network::{HttpRequest, NetworkSession};
use std::collections::BTreeMap;
use std::io::Write;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, mpsc};
use std::thread;
use std::time::{Duration, Instant};
#[derive(Deserialize)]
pub(crate) struct Segment {
pub url: String,
pub headers: BTreeMap<String, String>,
}
struct Completed {
file: SegmentFile,
size: u64,
attempts: i64,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn download(
files: &ExtensionFiles,
path: &FilePath,
network: &NetworkSession,
segments: &[Segment],
options: DownloadOptions,
check: &(dyn Fn() -> Result<(), String> + Sync),
progress: &mut dyn FnMut(u64, usize, usize),
) -> Result<serde_json::Value, Failure> {
let _guard = files
.lock(path, check)
.map_err(|error| Failure::new("cancelled", error, 0))?;
let mut hash = Sha256::new();
for segment in segments {
hash.update(segment.url.as_bytes());
hash.update([0]);
}
let mut output = SegmentOutput::open(
path,
format!("{:x}", hash.finalize()),
segments.len(),
options.persistent,
)
.map_err(|error| {
Failure::new(
"storage_error",
format!("failed to create segmented output: {error}"),
0,
)
})?;
let restored = output.next_index() == segments.len() && output.bytes() > 0;
options.item.start();
options.item.set(
output.next_index() as f64 / segments.len() as f64,
output.bytes(),
0,
);
let reporter = options.item.reporter(output.bytes(), 0);
if !restored {
let mut next_job = output.next_index();
let stop = AtomicBool::new(false);
let completed = AtomicBool::new(false);
let received = AtomicU64::new(output.bytes());
let worker_count = (options.policy.max_parallel_segments as usize)
.min(segments.len() - output.next_index());
let mut first_failure = None;
let mut pending = BTreeMap::new();
let parent = std::sync::Arc::clone(&output.parent);
let staged = output.staged.clone();
let (jobs, job_receiver) = mpsc::sync_channel(worker_count);
let job_receiver = Mutex::new(job_receiver);
// Scoped workers cannot outlive the JS call, its cancellation lease, or
// its directory capability. Only this VM thread invokes JS callbacks.
thread::scope(|scope| {
let (sender, receiver) = mpsc::sync_channel(worker_count);
for _ in 0..worker_count {
jobs.send(next_job).expect("segment workers not started");
next_job += 1;
}
let mut jobs = (next_job < segments.len()).then_some(jobs);
for _ in 0..worker_count {
let sender = sender.clone();
let parent = &parent;
let staged = &staged;
let job_receiver = &job_receiver;
let stop = &stop;
let received = &received;
let completed = &completed;
let options = &options;
let reporter = &reporter;
let spawned = thread::Builder::new()
.name("extension-segment".into())
.stack_size(2 << 20)
.spawn_scoped(scope, move || {
let check_worker = || {
check()?;
if stop.load(Ordering::Acquire) {
return Err("download cancelled".into());
}
Ok(())
};
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
loop {
if check_worker().is_err() {
break;
}
let Ok(index) = job_receiver
.lock()
.unwrap_or_else(|error| error.into_inner())
.recv()
else {
break;
};
let segment = &segments[index];
let result = fetch(
segment,
index,
parent,
staged,
network,
options,
received,
completed,
reporter,
&check_worker,
);
let failed = result.is_err();
if sender.send((index, result)).is_err() || failed {
break;
}
}
}));
if let Err(panic) = result {
let _ = sender.send((
usize::MAX,
Err(Failure::new("storage_error", "segment worker panicked", 0)),
));
std::panic::resume_unwind(panic);
}
});
if let Err(error) = spawned {
first_failure = Some(Failure::new(
"storage_error",
format!("failed to start segment worker: {error}"),
0,
));
stop.store(true, Ordering::Release);
jobs.take();
break;
}
}
drop(sender);
loop {
if let Err(error) = check() {
first_failure.get_or_insert_with(|| Failure::new("cancelled", error, 0));
stop.store(true, Ordering::Release);
jobs.take();
}
let (index, result) = match receiver.recv_timeout(Duration::from_millis(10)) {
Ok(result) => result,
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
};
match result {
Err(error) => {
first_failure.get_or_insert(error);
stop.store(true, Ordering::Release);
jobs.take();
}
Ok(result) => {
pending.insert(index, result);
}
}
while let Some(mut ready) = pending.remove(&output.next_index()) {
let index = output.next_index();
if let Err(error) = output.append(&mut ready.file, ready.size, check) {
first_failure.get_or_insert_with(|| {
Failure::new(
"storage_error",
format!("failed to append segment {index}: {error}"),
ready.attempts,
)
});
stop.store(true, Ordering::Release);
jobs.take();
break;
}
output.checkpoint(false);
drop(ready);
options.item.set(
output.next_index() as f64 / segments.len() as f64,
received.load(Ordering::Acquire),
0,
);
let _charge = options.budget.as_ref().map(|budget| budget.enter(true));
progress(
received.load(Ordering::Acquire),
output.next_index(),
segments.len(),
);
// Only assembly releases a slot: completed files and active
// requests together never exceed the worker count.
if let Some(sender) = &jobs {
if sender.send(next_job).is_err() {
jobs.take();
} else {
next_job += 1;
if next_job == segments.len() {
jobs.take();
}
}
}
}
}
});
if let Some(error) = first_failure {
output.checkpoint(true);
return Err(error);
}
}
if output.next_index() != segments.len() || output.bytes() == 0 {
return Err(Failure::new(
"integrity_failed",
format!(
"segmented transfer incomplete: assembled {} of {} segments",
output.next_index(),
segments.len()
),
0,
));
}
output.publish(check).map_err(|error| {
Failure::new(
"storage_error",
format!("failed to publish segmented output: {error}"),
0,
)
})?;
options.item.set(1.0, output.bytes(), output.bytes() as i64);
let mut result = serde_json::json!({"success":true,"path":path.display(),"size":output.bytes(),"segments":segments.len()});
if restored {
result["resumed"] = true.into();
}
Ok(result)
}
#[allow(clippy::too_many_arguments)]
fn fetch(
segment: &Segment,
index: usize,
parent: &std::sync::Arc<cap_std::fs::Dir>,
staged: &std::ffi::OsString,
network: &NetworkSession,
options: &DownloadOptions,
received: &AtomicU64,
completed: &AtomicBool,
reporter: &spotiflac_core::progress::ItemTransferProgressReporter,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Completed, Failure> {
let mut delay = Duration::from_millis(options.policy.initial_retry_delay_ms as u64);
for attempt in 1..=options.policy.max_attempts {
check().map_err(|error| Failure::new("cancelled", error, attempt))?;
let mut file = SegmentFile::open(parent, staged, index).map_err(|error| {
Failure::new(
"storage_error",
format!("failed to create segment file: {error}"),
attempt,
)
})?;
let continuation = attempt == 1 && completed.load(Ordering::Acquire);
let charge = options
.budget
.as_ref()
.map(|budget| budget.enter(!continuation));
let stream = network.open_stream(
HttpRequest {
url: segment.url.clone(),
method: "GET".into(),
body: String::new(),
headers: segment.headers.clone(),
default_json: false,
user_agent: options.app_version.user_agent(),
},
Duration::from_secs(24 * 60 * 60),
Duration::from_secs(60),
check,
);
drop(charge);
let mut failure = match stream {
Err(error) => Failure::new("transient_network", error, attempt),
Ok(mut stream) => {
let status = stream.response.status;
if !(200..300).contains(&status) {
let mut failure = status_failure(&stream.response, &options.policy, attempt);
failure.error = format!("segment {index} HTTP error: {status}");
if !retryable(status) || attempt == options.policy.max_attempts {
return Err(failure);
}
failure
} else {
let expected = header(&stream.response, "Content-Length")
.parse::<i64>()
.unwrap_or(-1);
let mut size = 0;
let mut buffer = [0; 64 << 10];
let result = loop {
let charge = options.budget.as_ref().map(|budget| {
budget
.enter(!matches!(status, 200 | 206) || (size == 0 && !continuation))
});
let read = stream.read(&mut buffer, check);
drop(charge);
match read {
Ok(0) => break Ok(()),
Ok(count) => {
if let Err(error) = file.file.write_all(&buffer[..count]) {
break Err(error.to_string());
}
size += count as u64;
received.fetch_add(count as u64, Ordering::AcqRel);
reporter.report(received.load(Ordering::Acquire) as i64, 0);
}
Err(error) => break Err(error),
}
};
let result = result.and_then(|()| {
if expected > 0 && size != expected as u64 {
Err("unexpected EOF".into())
} else {
Ok(())
}
});
if result.is_ok() && size > 0 {
completed.store(true, Ordering::Release);
return Ok(Completed {
file,
size,
attempts: attempt,
});
}
received.fetch_sub(size, Ordering::AcqRel);
let message = result.err().map_or_else(
|| format!("segment {index} response was empty"),
|error| format!("failed to read segment {index}: {error}"),
);
Failure::new("transient_network", message, attempt)
}
}
};
drop(file);
if let Err(error) = check() {
failure = Failure::new("cancelled", error, attempt);
}
if attempt == options.policy.max_attempts || failure.error_type == "cancelled" {
return Err(failure);
}
let retry = if failure.retry_after_seconds > 0 {
Duration::from_secs(failure.retry_after_seconds.into())
} else {
delay
};
let _charge = options.budget.as_ref().map(|budget| budget.enter(true));
let began = Instant::now();
while began.elapsed() < retry {
check().map_err(|error| Failure::new("cancelled", error, attempt))?;
thread::sleep(
retry
.saturating_sub(began.elapsed())
.min(Duration::from_millis(10)),
);
}
delay = next_delay(delay, &options.policy);
}
unreachable!("policy has at least one attempt")
}
@@ -0,0 +1,230 @@
use crate::files::FilePath;
use cap_std::fs::{Dir, File, OpenOptions};
use serde::{Deserialize, Serialize};
use std::ffi::OsString;
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
#[derive(Default, Deserialize, Serialize)]
#[serde(default)]
pub(super) struct Checkpoint {
version: i64,
fingerprint: String,
pub validator: String,
pub bytes: u64,
pub total: i64,
updated_at: u64,
}
pub(super) struct DownloadFile {
parent: Dir,
target: OsString,
staged: OsString,
checkpoint_name: OsString,
pub file: File,
pub state: Checkpoint,
pub restored: bool,
keep_partial: bool,
promoted: bool,
saved_bytes: u64,
saved_at: Instant,
}
impl DownloadFile {
pub fn open(path: &FilePath, fingerprint: &str, keep_partial: bool) -> Result<Self, String> {
let (parent, target) = path.open_parent().map_err(|error| error.to_string())?;
let mut staged = target.clone();
staged.push(".partial");
let mut checkpoint_name = staged.clone();
checkpoint_name.push(".checkpoint.json");
for name in [&staged, &checkpoint_name] {
match parent.symlink_metadata(name) {
Ok(info) if !info.is_file() => {
return Err("download staging path is not a regular file".into());
}
Err(error) if error.kind() != io::ErrorKind::NotFound => {
return Err(error.to_string());
}
_ => {}
}
}
let state = keep_partial
.then(|| {
let mut file = parent
.open_with(&checkpoint_name, private_options().read(true))
.ok()?;
let mut data = Vec::new();
(&mut file)
.take((1 << 20) + 1)
.read_to_end(&mut data)
.ok()?;
if data.len() > 1 << 20 {
return None;
}
let state: Checkpoint = serde_json::from_slice(&data).ok()?;
(state.version == 1
&& !state.fingerprint.is_empty()
&& state.fingerprint == fingerprint
&& !state.validator.is_empty()
&& state.bytes > 0)
.then_some(state)
})
.flatten();
let restored = state.is_some();
if !restored {
remove_if_present(&parent, &staged).map_err(|error| error.to_string())?;
remove_if_present(&parent, &checkpoint_name).map_err(|error| error.to_string())?;
}
let file = parent
.open_with(
&staged,
private_options().read(true).write(true).create(true),
)
.map_err(|error| error.to_string())?;
if !file
.metadata()
.map_err(|error| error.to_string())?
.is_file()
{
return Err("download staging path is not a regular file".into());
}
let mut result = Self {
parent,
target,
staged,
checkpoint_name,
file,
state: state.unwrap_or_default(),
restored,
keep_partial,
promoted: false,
saved_bytes: 0,
saved_at: Instant::now(),
};
result.state.fingerprint = fingerprint.to_owned();
result.state.bytes = result.state.bytes.min(
result
.file
.metadata()
.map_err(|error| error.to_string())?
.len(),
);
result
.file
.set_len(result.state.bytes)
.map_err(|error| error.to_string())?;
result
.file
.seek(SeekFrom::Start(result.state.bytes))
.map_err(|error| error.to_string())?;
result.saved_bytes = result.state.bytes;
Ok(result)
}
pub fn restart(&mut self) -> Result<(), String> {
self.rewind(0)?;
self.state.validator.clear();
remove_if_present(&self.parent, &self.checkpoint_name).map_err(|error| error.to_string())
}
pub fn rewind(&mut self, bytes: u64) -> Result<(), String> {
self.file
.set_len(bytes)
.map_err(|error| error.to_string())?;
self.file
.seek(SeekFrom::Start(bytes))
.map_err(|error| error.to_string())?;
self.state.bytes = bytes;
Ok(())
}
pub fn checkpoint(&mut self, force: bool) {
if !self.keep_partial || self.state.validator.is_empty() || self.state.bytes == 0 {
return;
}
if !force
&& self.state.bytes.saturating_sub(self.saved_bytes) < 8 << 20
&& self.saved_at.elapsed().as_secs() < 5
{
return;
}
self.saved_bytes = self.state.bytes;
self.saved_at = Instant::now();
// Data reaches stable storage before publishing its checkpoint pointer.
// A failed save is retried on the next interval, not on every read.
let _ = self.save();
}
fn save(&mut self) -> io::Result<()> {
self.file.sync_all()?;
self.state.version = 1;
self.state.updated_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let bytes = serde_json::to_vec(&self.state)?;
let mut temporary = self.checkpoint_name.clone();
temporary.push(".tmp");
// Remove the directory entry itself; an old symlink is never followed.
remove_if_present(&self.parent, &temporary)?;
let result = (|| {
let mut file = self
.parent
.open_with(&temporary, private_options().write(true).create_new(true))?;
file.write_all(&bytes)?;
file.sync_all()?;
drop(file);
self.parent
.rename(&temporary, &self.parent, &self.checkpoint_name)
})();
if result.is_err() {
let _ = self.parent.remove_file(&temporary);
}
result
}
pub fn publish(&mut self, check: &dyn Fn() -> Result<(), String>) -> Result<(), String> {
self.file.sync_all().map_err(|error| error.to_string())?;
check()?;
self.parent
.rename(&self.staged, &self.parent, &self.target)
.map_err(|error| error.to_string())?;
self.promoted = true;
let _ = self.parent.remove_file(&self.checkpoint_name);
let _ = self
.parent
.try_clone()
.and_then(|parent| parent.into_std_file().sync_all());
Ok(())
}
}
impl Drop for DownloadFile {
fn drop(&mut self) {
if self.promoted || !self.keep_partial {
let _ = self.parent.remove_file(&self.checkpoint_name);
if !self.promoted {
let _ = self.parent.remove_file(&self.staged);
}
}
}
}
pub(super) fn private_options() -> OpenOptions {
let mut options = OpenOptions::new();
#[cfg(unix)]
{
use cap_std::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(
(rustix::fs::OFlags::NONBLOCK | rustix::fs::OFlags::NOFOLLOW).bits() as i32,
);
}
options
}
pub(super) fn remove_if_present(parent: &Dir, name: &OsString) -> io::Result<()> {
match parent.remove_file(name) {
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
result => result,
}
}
@@ -0,0 +1,257 @@
use crate::download::{self, DownloadOptions};
use crate::runtime::{Control, ExtensionServices};
use rquickjs::{Ctx, Function, Object, Value};
use std::sync::Arc;
pub(crate) fn register<'js>(
ctx: &Ctx<'js>,
host: &Object<'js>,
control: Arc<Control>,
services: &ExtensionServices,
) -> rquickjs::Result<()> {
register_segments(ctx, host, Arc::clone(&control), services)?;
let Some(files) = &services.files else {
return Ok(());
};
let files = Arc::clone(files);
let downloads = Arc::clone(&services.downloads);
let network = services.network.clone();
let policy = services.transfer_policy.clone();
let app_version = services.app_version.clone();
host.set(
"downloadCall",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>,
url: String,
path: String,
options: Value<'js>,
headers: String| {
let result: Result<serde_json::Value, String> = (|| {
let check = || control.check().map_err(|error| error.to_string());
check()?;
let network = network.as_ref().ok_or("network access unavailable")?;
network.validate_url(&url)?;
let path = files.resolve(&path)?;
let options = options.as_object();
let value = |key: &str| -> Result<Value<'js>, String> {
options.map_or_else(
|| Ok(Value::new_undefined(ctx.clone())),
|object| object.get(key).map_err(|error| error.to_string()),
)
};
let chunked = value("chunked")?;
let chunk_size = if chunked.as_bool() == Some(true) {
Some(1 << 20)
} else {
chunked
.as_number()
.filter(|value| *value > 0.0)
.map(|value| {
let size = value as i64;
if size <= 0 { 1 << 20 } else { size as u64 }
})
};
let mut policy = policy.clone();
if let Some(attempts) = value("maxAttempts")?
.as_number()
.filter(|number| *number > 0.0)
{
policy.max_attempts = (attempts as i64).clamp(1, 8);
}
let resume = value("resume")?
.as_bool()
.unwrap_or(policy.resume_policy == "validated");
let has_track = options
.map(|object| object.contains_key("trackItemBytes"))
.transpose()
.map_err(|error| error.to_string())?
.unwrap_or(false);
let track = value(if has_track {
"trackItemBytes"
} else {
"track_item_bytes"
})?
.as_bool()
.unwrap_or(true);
let persistent = value("persistentCheckpoint")?
.as_bool()
.map_or(policy.persistent_checkpoint, |enabled| enabled && resume);
let callback = value("onProgress")?;
let callback = callback.as_function();
let mut progress = |bytes, total| {
if let Some(callback) = callback
&& callback.call::<_, ()>((bytes, total)).is_err()
{
// Go ignores download progress callback errors. Clear the
// pending JS exception; cancellation is checked by the host.
ctx.catch();
}
};
let headers =
serde_json::from_str(&headers).map_err(|error| error.to_string())?;
Ok(
match download::download(
&files,
&path,
network,
&url,
DownloadOptions {
item: download::progress::ItemProgressTarget::new(
Arc::clone(&downloads.progress),
control.item_id(),
track,
),
headers,
// Go snapshots the UA for a chunked transfer;
// ordinary retries read the current app version.
app_version: if chunk_size.is_some() {
app_version.get().into()
} else {
app_version.clone()
},
resume,
persistent,
policy,
budget: control.resolution(),
chunk_size,
},
&check,
&|duration| {
let _charge = control.resolution().map(|budget| budget.enter(true));
control.sleep(duration);
check()
},
&mut progress,
) {
Ok(result) => result,
Err(error) => serde_json::to_value(error).expect("transfer error JSON"),
},
)
})();
match result {
Ok(result) => result,
Err(error) => serde_json::json!({"success":false,"error":error}),
}
.to_string()
},
)?,
)?;
Ok(())
}
fn register_segments<'js>(
ctx: &Ctx<'js>,
host: &Object<'js>,
control: Arc<Control>,
services: &ExtensionServices,
) -> rquickjs::Result<()> {
let Some(files) = &services.files else {
return Ok(());
};
let files = Arc::clone(files);
let downloads = Arc::clone(&services.downloads);
let network = services.network.clone();
let policy = services.transfer_policy.clone();
let app_version = services.app_version.clone();
host.set(
"downloadSegmentsCall",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>, segments: String, path: String, options: Value<'js>| {
let result = (|| {
let check = || control.check().map_err(|error| error.to_string());
check().map_err(|error| download::Failure::new("cancelled", error, 0))?;
let network = network.as_ref().ok_or_else(|| {
download::Failure::new("permission", "network access unavailable", 0)
})?;
let mut segments: Vec<download::segments::Segment> =
serde_json::from_str(&segments)
.map_err(|error| download::Failure::new("invalid_request", error, 0))?;
for (index, segment) in segments.iter_mut().enumerate() {
segment.url = segment.url.trim().to_owned();
if segment.url.is_empty() {
return Err(download::Failure::new(
"invalid_request",
format!("segment {index} URL is empty"),
0,
));
}
}
for segment in &segments {
network
.validate_url(&segment.url)
.map_err(|error| download::Failure::new("permission", error, 0))?;
}
let path = files
.resolve(&path)
.map_err(|error| download::Failure::new("permission", error, 0))?;
let options = options.as_object();
let value = |key: &str| -> Result<Value<'js>, download::Failure> {
options.map_or_else(
|| Ok(Value::new_undefined(ctx.clone())),
|object| {
object.get(key).map_err(|error| {
download::Failure::new("invalid_request", error, 0)
})
},
)
};
let mut policy = policy.clone();
for (name, target) in [
("maxAttempts", &mut policy.max_attempts),
("maxParallel", &mut policy.max_parallel_segments),
] {
if let Some(number) = value(name)?.as_number() {
let rounded = crate::transfer_policy::rounded_int(number);
if rounded > 0 {
*target = rounded.clamp(1, 8);
}
}
}
let persistent = value("persistentCheckpoint")?
.as_bool()
.unwrap_or(policy.persistent_checkpoint);
let callback = value("onProgress")?;
let callback = callback.as_function();
let mut progress = |bytes, completed, count| {
if let Some(callback) = callback
&& callback
.call::<_, ()>((bytes, 0, completed, count))
.is_err()
{
ctx.catch();
}
};
download::segments::download(
&files,
&path,
network,
&segments,
DownloadOptions {
item: download::progress::ItemProgressTarget::new(
Arc::clone(&downloads.progress),
control.item_id(),
true,
),
headers: Default::default(),
app_version: app_version.clone(),
resume: false,
persistent,
policy,
budget: control.resolution(),
chunk_size: None,
},
&check,
&mut progress,
)
})();
match result {
Ok(result) => result,
Err(error) => serde_json::to_value(error).expect("transfer error JSON"),
}
.to_string()
},
)?,
)
}
@@ -0,0 +1,770 @@
//! Platform-owned storage and lifecycle context for managed extension runtimes.
mod index;
use crate::auth::{AuthRegistry, ExtensionAuth, PendingAuthRequest};
use crate::manifest::{ExtensionManifest, ManifestError};
use crate::runtime::LoadMode;
use crate::signed_session::SignedRegistry;
use crate::storage::{
ExtensionStore, StorageError, StorageMasterKey, StoreKind, valid_extension_id,
};
use crate::{ExtensionError, ExtensionRuntime, ExtensionServices, RuntimeLimits};
use serde_json::{Map, Value};
use spotiflac_core::app_version::AppVersion;
use spotiflac_core::cancellation::{CancellationDomain, CancellationRegistry};
use spotiflac_core::downloads::DownloadState;
use spotiflac_core::progress::ProgressRegistry;
use spotiflac_network::{NetworkService, policy::NetworkPermissions};
use spotiflac_providers::lyrics::{CallGraph, CallNode, LyricsService};
use std::cmp::Ordering as Comparison;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, LazyLock, Mutex, OnceLock, Weak};
use std::time::Duration;
// Keep these contract versions aligned with Go's supportedRuntimeFeatures.
pub(crate) static SUPPORTED_RUNTIME_FEATURES: LazyLock<BTreeMap<String, isize>> =
LazyLock::new(|| {
[
("signedSession", 3),
("sessionRefresh", 1),
("sessionGrant", 1),
("globalAction", 1),
("webviewAuth", 1),
("downloadSegments", 1),
("patternedFileTransform", 1),
("preparedContext", 1),
]
.into_iter()
.map(|(name, version)| (name.to_owned(), version))
.collect()
});
#[derive(Debug, thiserror::Error)]
pub enum EnvironmentError {
#[error("extension environment closed")]
Closed,
#[error("{0}")]
Storage(#[from] StorageError),
#[error("{0}")]
Manifest(#[from] ManifestError),
#[error("{0}")]
Runtime(#[from] ExtensionError),
#[error("{0}")]
Gate(String),
#[error("network initialization failed: {0}")]
Network(#[from] std::io::Error),
#[error("{0}")]
Auth(String),
#[error("{0}")]
Index(String),
}
#[derive(Default)]
struct State {
pending: usize,
runtimes: Vec<Weak<ExtensionRuntime>>,
stores: BTreeMap<String, Arc<ExtensionStore>>,
lyrics_nodes: BTreeMap<String, CallNode>,
}
/// The master key comes from the native Keychain/Keystore adapter. The directory
/// must belong to this backend; Go and Rust must not concurrently own live data.
pub struct ExtensionEnvironment {
logs: Arc<crate::logging::LogBuffer>,
ffmpeg: Arc<crate::ffmpeg::CommandRegistry>,
downloads: Arc<spotiflac_core::downloads::DownloadState>,
data_directory: PathBuf,
data_directory_alias: PathBuf,
master_key: Arc<StorageMasterKey>,
app_version: AppVersion,
network: Arc<NetworkService>,
auth: Arc<AuthRegistry>,
sessions: Arc<SignedRegistry>,
files: Arc<crate::files::FileRegistry>,
isrc: Arc<spotiflac_core::isrc::IndexCache>,
closed: Arc<AtomicBool>,
state: Mutex<State>,
idle: Condvar,
shutdown_lock: Mutex<()>,
manager_owned: AtomicBool,
calls: CallGraph,
lyrics: OnceLock<Weak<LyricsService>>,
}
impl ExtensionEnvironment {
pub fn new(
data_directory: &Path,
master_key: &str,
app_version: &str,
) -> Result<Self, EnvironmentError> {
// Validate the key before initializing any platform services or files.
StorageMasterKey::from_base64(master_key)?;
Self::with_network(
data_directory,
master_key,
app_version,
NetworkService::new()?,
)
}
pub fn with_network(
data_directory: &Path,
master_key: &str,
app_version: &str,
network: Arc<NetworkService>,
) -> Result<Self, EnvironmentError> {
let master_key = Arc::new(StorageMasterKey::from_base64(master_key)?);
if data_directory.as_os_str().is_empty() {
return Err(StorageError::InvalidPath.into());
}
let mut builder = fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder.create(data_directory).map_err(StorageError::from)?;
let data_directory_alias =
crate::files::clean(&std::path::absolute(data_directory).map_err(StorageError::from)?);
let data_directory = fs::canonicalize(data_directory).map_err(StorageError::from)?;
let auth = Arc::new(AuthRegistry::default());
let sessions = SignedRegistry::new(&data_directory, Arc::clone(&auth));
Ok(Self {
logs: Arc::new(crate::logging::LogBuffer::default()),
ffmpeg: Arc::new(crate::ffmpeg::CommandRegistry::default()),
downloads: Arc::new(DownloadState {
progress: Arc::new(ProgressRegistry::new()),
cancellation: Arc::new(CancellationRegistry::with_waker(
CancellationDomain::Download,
network.cancellation_waker(),
)),
}),
data_directory,
data_directory_alias,
master_key,
app_version: app_version.into(),
network,
auth,
sessions,
files: Arc::new(crate::files::FileRegistry::default()),
isrc: Arc::new(spotiflac_core::isrc::IndexCache::default()),
closed: Arc::new(AtomicBool::new(false)),
state: Mutex::default(),
idle: Condvar::new(),
shutdown_lock: Mutex::new(()),
manager_owned: AtomicBool::new(false),
calls: CallGraph::default(),
lyrics: OnceLock::new(),
})
}
fn enter(&self) -> Result<Operation<'_>, EnvironmentError> {
let mut state = self.state.lock().expect("extension environment lock");
if self.closed.load(Ordering::Acquire) {
return Err(EnvironmentError::Closed);
}
state.pending += 1;
Ok(Operation(self))
}
fn store(&self, id: &str) -> Result<Arc<ExtensionStore>, EnvironmentError> {
if !valid_extension_id(id) {
return Err(StorageError::InvalidExtensionId.into());
}
let path = self.data_directory.join(id);
let mut state = self.state.lock().expect("extension environment lock");
match fs::symlink_metadata(&path) {
Ok(metadata) if !metadata.is_dir() => return Err(StorageError::InvalidPath.into()),
Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
return Err(StorageError::from(error).into());
}
_ => {}
}
if let Some(store) = state.stores.get(id) {
return Ok(Arc::clone(store));
}
let store = Arc::new(ExtensionStore::open(
&path,
id,
Some(Arc::clone(&self.master_key)),
)?);
state.stores.insert(id.to_owned(), Arc::clone(&store));
Ok(store)
}
pub fn settings(&self, id: &str) -> Result<Map<String, Value>, EnvironmentError> {
let _operation = self.enter()?;
Ok(self.store(id)?.all(StoreKind::Settings)?)
}
pub fn set_setting(&self, id: &str, key: &str, value: Value) -> Result<(), EnvironmentError> {
let _operation = self.enter()?;
Ok(self.store(id)?.set(StoreKind::Settings, key, value)?)
}
pub fn remove_setting(&self, id: &str, key: &str) -> Result<(), EnvironmentError> {
let _operation = self.enter()?;
Ok(self.store(id)?.remove(StoreKind::Settings, key)?)
}
pub fn set_allow_private_network(&self, allow: bool) -> Result<(), EnvironmentError> {
let _operation = self.enter()?;
self.network.set_allow_private_network(allow);
Ok(())
}
pub fn set_network_compatibility_options(
&self,
allow_http: bool,
insecure_tls: bool,
) -> Result<(), EnvironmentError> {
let _operation = self.enter()?;
self.network
.set_network_compatibility_options(allow_http, insecure_tls);
Ok(())
}
/// Recycle connections without cancelling active requests or changing policy.
pub fn cleanup_connections(&self) -> Result<(), EnvironmentError> {
let _operation = self.enter()?;
self.network.reset_connections();
Ok(())
}
/// Trusted native composition reuses the same transport and network policy.
pub fn network_service(&self) -> Result<Arc<NetworkService>, EnvironmentError> {
let _operation = self.enter()?;
Ok(Arc::clone(&self.network))
}
pub fn get_app_version(&self) -> Result<String, EnvironmentError> {
let _operation = self.enter()?;
Ok(self.app_version.get())
}
pub fn set_app_version(&self, version: &str) -> Result<(), EnvironmentError> {
let _operation = self.enter()?;
self.app_version
.set(version)
.map_err(|error| EnvironmentError::Gate(error.to_string()))
}
pub(crate) fn shared_app_version(&self) -> AppVersion {
self.app_version.clone()
}
pub fn set_allowed_download_directories(
&self,
directories: &[PathBuf],
) -> Result<(), EnvironmentError> {
let _operation = self.enter()?;
self.files
.set_allowed_directories(directories)
.map_err(StorageError::from)?;
Ok(())
}
pub fn replace_settings(
&self,
id: &str,
values: Map<String, Value>,
) -> Result<(), EnvironmentError> {
let _operation = self.enter()?;
Ok(self.store(id)?.replace(StoreKind::Settings, values)?)
}
pub fn load(
&self,
manifest_json: &str,
source: &str,
limits: RuntimeLimits,
) -> Result<Arc<ExtensionRuntime>, EnvironmentError> {
if self.closed.load(Ordering::Acquire) {
return Err(EnvironmentError::Closed);
}
if self.manager_owned.load(Ordering::Acquire) {
return Err(EnvironmentError::Gate(
"installed runtimes must be loaded through their extension manager".into(),
));
}
self.load_internal(
manifest_json,
source,
limits,
LoadMode::Initialize,
None,
None,
)
}
pub(crate) fn load_registered_with_lease(
&self,
manifest_json: &str,
source: &str,
limits: RuntimeLimits,
mode: LoadMode,
lease: Option<Arc<spotiflac_core::cancellation::RequestLease>>,
) -> Result<Arc<ExtensionRuntime>, EnvironmentError> {
self.load_internal(manifest_json, source, limits, mode, None, lease)
}
pub(crate) fn load_isolated(
&self,
manifest_json: &str,
source: &str,
limits: RuntimeLimits,
primary: &ExtensionRuntime,
) -> Result<Arc<ExtensionRuntime>, EnvironmentError> {
self.load_internal(
manifest_json,
source,
limits,
LoadMode::Register,
Some(primary),
None,
)
}
fn load_internal(
&self,
manifest_json: &str,
source: &str,
limits: RuntimeLimits,
load_mode: LoadMode,
primary: Option<&ExtensionRuntime>,
startup_lease: Option<Arc<spotiflac_core::cancellation::RequestLease>>,
) -> Result<Arc<ExtensionRuntime>, EnvironmentError> {
let _operation = self.enter()?;
if manifest_json.len() > 1024 * 1024 {
return Err(EnvironmentError::Gate(
"invalid extension package: manifest.json is too large".to_owned(),
));
}
let manifest = ExtensionManifest::parse(manifest_json)?;
validate_gates(
&manifest,
&self.app_version.get(),
&SUPPORTED_RUNTIME_FEATURES,
)
.map_err(EnvironmentError::Gate)?;
let store = self.store(&manifest.name)?;
let mut settings = store.all(StoreKind::Settings)?;
settings.retain(|key, _| !key.starts_with('_'));
let isolated = primary.is_some();
let compiled_source = primary.map(|runtime| Arc::clone(&runtime.compiled_source));
let network = primary
.and_then(ExtensionRuntime::network_session)
.unwrap_or_else(|| {
self.network.session(
NetworkPermissions {
domains: manifest.permissions.network.clone().unwrap_or_default(),
allow_http: manifest.permissions.allow_http,
},
network_timeout(manifest.capabilities.get("networkTimeoutSeconds")),
)
});
// Legacy metadata helpers do not require the general file permission.
// They receive read access to this sandbox and native-granted roots.
let legacy_files = self
.files
.extension_with_alias(
&self.data_directory.join(&manifest.name),
&self.data_directory_alias.join(&manifest.name),
)
.map_err(StorageError::from)?;
let services = ExtensionServices {
legacy_backend: true,
lyrics: self.lyrics.get().cloned().unwrap_or_default(),
lyrics_node: self.lyrics_node(&manifest.name, isolated),
lyrics_parent: isolated.then(|| self.lyrics_node(&manifest.name, false)),
legacy_files: Some(Arc::clone(&legacy_files)),
isrc: Arc::clone(&self.isrc),
logs: Arc::clone(&self.logs),
extension_id: manifest.name.clone(),
ffmpeg: Arc::clone(&self.ffmpeg),
raw_ffmpeg_stub: manifest
.capabilities
.get("rawFfmpeg")
.and_then(Value::as_bool)
.unwrap_or(false),
downloads: Arc::clone(&self.downloads),
transfer_policy: crate::transfer_policy::DownloadTransferPolicy::from_capabilities(
&manifest.capabilities,
),
storage: manifest.permissions.storage.then_some(store),
files: manifest.permissions.file.then_some(legacy_files),
auth: manifest.permissions.storage.then(|| {
Arc::new(ExtensionAuth::new(
&manifest.name,
Arc::clone(&self.auth),
Arc::clone(&network),
self.app_version.clone(),
))
}),
auth_registry: Some(Arc::clone(&self.auth)),
session: manifest
.signed_session
.clone()
.map(|config| {
self.sessions
.session(&manifest.name, config, Arc::clone(&network))
})
.transpose()
.map_err(EnvironmentError::Auth)?,
network: Some(network),
app_version: self.app_version.clone(),
parent_closed: Some(Arc::clone(&self.closed)),
startup_lease,
initialize_empty_settings: false,
load_mode,
compiled_source,
};
let runtime = Arc::new(ExtensionRuntime::load_with_services(
source,
&Value::Object(settings).to_string(),
limits,
services,
)?);
let mut state = self.state.lock().expect("extension environment lock");
if self.closed.load(Ordering::Acquire) {
drop(state);
runtime.shutdown();
return Err(EnvironmentError::Closed);
}
state.runtimes.retain(|runtime| runtime.strong_count() > 0);
state.runtimes.push(Arc::downgrade(&runtime));
Ok(runtime)
}
/// Interrupt every child and in-progress initialization, then join workers.
/// Manager-driven normal unloading should run cleanup before this operation.
pub fn shutdown(&self) {
self.closed.store(true, Ordering::Release);
self.app_version.close();
self.logs.shutdown();
self.ffmpeg.shutdown();
self.downloads.shutdown();
self.auth.shutdown();
self.sessions.shutdown();
let _shutdown = self.shutdown_lock.lock().expect("extension shutdown lock");
let runtimes = {
let mut state = self.state.lock().expect("extension environment lock");
std::mem::take(&mut state.runtimes)
};
for runtime in runtimes.into_iter().filter_map(|runtime| runtime.upgrade()) {
runtime.shutdown();
}
let mut state = self.state.lock().expect("extension environment lock");
while state.pending > 0 {
state = self.idle.wait(state).expect("extension environment idle");
}
state.lyrics_nodes.clear();
self.isrc.clear();
}
}
impl ExtensionEnvironment {
pub(crate) fn attach_lyrics(&self, lyrics: &Arc<LyricsService>) {
self.lyrics
.set(Arc::downgrade(lyrics))
.expect("lyrics owner already configured");
}
pub(crate) fn lyrics_calls(&self) -> CallGraph {
self.calls.clone()
}
pub(crate) fn lyrics_node(&self, id: &str, isolated: bool) -> CallNode {
if isolated {
return self.calls.node();
}
self.state
.lock()
.expect("extension environment lock")
.lyrics_nodes
.entry(id.to_owned())
.or_insert_with(|| self.calls.node())
.clone()
}
pub(crate) fn attach_manager(&self) {
self.manager_owned.store(true, Ordering::Release);
}
pub(crate) fn is_closed(&self) -> bool {
self.closed.load(Ordering::Acquire)
}
pub(crate) fn closed_flag(&self) -> Arc<AtomicBool> {
Arc::clone(&self.closed)
}
pub(crate) fn data_directory(&self) -> &Path {
&self.data_directory
}
/// Trusted root operations share download grants without widening any VM's
/// sandbox. Relative native paths resolve inside the application's data root.
pub(crate) fn native_files(&self) -> Result<Arc<crate::files::ExtensionFiles>, String> {
if self.is_closed() {
return Err("extension environment closed".into());
}
self.files
.extension_with_alias(&self.data_directory, &self.data_directory_alias)
.map_err(|error| error.to_string())
}
/// Trusted native callers retain this grant until finalization completes.
pub fn grant_temporary_download_directory(
&self,
path: &Path,
) -> Result<crate::files::TemporaryGrant, String> {
let _operation = self.enter().map_err(|error| error.to_string())?;
self.files
.grant_temporary_directory(path)
.map_err(|error| error.to_string())
}
pub(crate) fn validate_post_process_path(
&self,
id: &str,
input: &str,
path: &str,
) -> Result<(), String> {
let mut bases = vec![self.data_directory.join(id)];
// Native platforms may supply a trusted alias such as /var/... while
// the store owns /private/var/.... Accept that spelling only while it
// still resolves to this extension's actual data directory.
let alias = self.data_directory_alias.join(id);
if alias != bases[0] && fs::canonicalize(&alias).is_ok_and(|path| path == bases[0]) {
bases.push(alias);
}
if !input.is_empty() {
let input = crate::files::clean(Path::new(input));
bases.insert(
0,
input
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or(Path::new("."))
.into(),
);
}
self.files.validate_native_replacement(path, &bases)
}
pub(crate) fn remove_data(&self, id: &str) -> Result<(), EnvironmentError> {
let _operation = self.enter()?;
if !valid_extension_id(id) {
return Err(StorageError::InvalidExtensionId.into());
}
let mut state = self.state.lock().expect("extension environment lock");
if let Some(store) = state.stores.remove(id) {
store.close();
}
self.auth.clear(id);
self.sessions.forget_extension(id);
let path = self.data_directory.join(id);
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.is_dir() => {
fs::remove_dir_all(path).map_err(StorageError::from)?
}
Ok(_) => fs::remove_file(path).map_err(StorageError::from)?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(StorageError::from(error).into()),
}
Ok(())
}
pub fn log_buffer(&self) -> Arc<crate::logging::LogBuffer> {
Arc::clone(&self.logs)
}
pub fn repository(
&self,
directory: &Path,
) -> Result<crate::repository::ExtensionRepository, crate::repository::RepositoryError> {
let _operation = self
.enter()
.map_err(|e| crate::repository::RepositoryError(e.to_string()))?;
crate::repository::ExtensionRepository::with_network(directory, Arc::clone(&self.network))
.map(|repository| repository.attach_environment(Arc::clone(&self.closed)))
}
pub fn ffmpeg_commands(&self) -> Arc<crate::ffmpeg::CommandRegistry> {
Arc::clone(&self.ffmpeg)
}
pub fn download_state(&self) -> Arc<spotiflac_core::downloads::DownloadState> {
Arc::clone(&self.downloads)
}
pub fn set_runtime_state(&self, raw: &str) -> Result<(), EnvironmentError> {
let _operation = self.enter()?;
self.sessions
.set_runtime_state(raw)
.map_err(EnvironmentError::Auth)
}
pub fn set_session_grant(&self, id: &str, grant: &str) -> Result<(), EnvironmentError> {
let _operation = self.enter()?;
self.sessions
.set_grant(id, grant)
.map_err(EnvironmentError::Auth)
}
pub fn pending_auth(&self, id: &str) -> Result<Option<PendingAuthRequest>, EnvironmentError> {
let _operation = self.enter()?;
Ok(self.auth.pending(id.trim()))
}
pub fn all_pending_auth(&self) -> Result<Vec<PendingAuthRequest>, EnvironmentError> {
let _operation = self.enter()?;
Ok(self.auth.all_pending())
}
pub fn clear_pending_auth(&self, id: &str) -> Result<(), EnvironmentError> {
let _operation = self.enter()?;
self.auth.clear_pending(id);
Ok(())
}
pub fn resolve_callback_state(
&self,
state: &str,
consume: bool,
) -> Result<String, EnvironmentError> {
let _operation = self.enter()?;
self.auth
.resolve_callback(state, consume)
.map_err(EnvironmentError::Auth)
}
pub fn set_auth_code(&self, id: &str, code: &str) -> Result<(), EnvironmentError> {
let _operation = self.enter()?;
self.auth.set_code(id, code).map_err(EnvironmentError::Auth)
}
pub fn set_auth_tokens(
&self,
id: &str,
access: &str,
refresh: &str,
expires_in: i64,
) -> Result<(), EnvironmentError> {
let _operation = self.enter()?;
self.auth
.set_tokens(id, access, refresh, expires_in)
.map_err(EnvironmentError::Auth)
}
pub fn is_authenticated(&self, id: &str) -> Result<bool, EnvironmentError> {
let _operation = self.enter()?;
Ok(self.auth.authenticated(id))
}
}
fn network_timeout(value: Option<&Value>) -> Duration {
let seconds = match value {
Some(Value::String(value)) => value.trim().parse::<i64>().unwrap_or(0),
Some(Value::Number(value)) => value.as_f64().unwrap_or(0.0) as i64,
_ => 0,
};
Duration::from_secs(if seconds <= 0 {
30
} else {
seconds.clamp(5, 300) as u64
})
}
impl Drop for ExtensionEnvironment {
fn drop(&mut self) {
self.shutdown();
}
}
struct Operation<'a>(&'a ExtensionEnvironment);
impl Drop for Operation<'_> {
fn drop(&mut self) {
let mut state = self.0.state.lock().expect("extension environment lock");
state.pending -= 1;
self.0.idle.notify_all();
}
}
pub fn validate_gates(
manifest: &ExtensionManifest,
app_version: &str,
features: &BTreeMap<String, isize>,
) -> Result<(), String> {
let minimum = manifest.min_app_version.trim();
let installed = app_version.trim();
if !minimum.is_empty() && !installed.is_empty() && compare_versions(installed, minimum).is_lt()
{
return Err(format!(
"requires app {minimum} or later (installed: {installed})"
));
}
for raw in &manifest.required_runtime_features {
let mut name = raw.trim();
if name.is_empty() {
continue;
}
let mut wanted = 1;
if let Some((feature, version)) = name.rsplit_once('@')
&& !feature.is_empty()
{
wanted = version
.parse::<isize>()
.ok()
.filter(|version| *version > 0)
.unwrap_or(1);
name = feature;
}
let Some(provided) = features.get(name) else {
return Err(format!(
"requires runtime feature {} this app build does not provide",
serde_json::to_string(name).expect("JSON string")
));
};
if *provided < wanted {
return Err(format!(
"requires runtime feature {name}@{wanted} (app provides @{provided})"
));
}
}
Ok(())
}
pub fn compare_versions(first: &str, second: &str) -> Comparison {
fn parts(version: &str) -> impl Iterator<Item = isize> + '_ {
version
.strip_prefix('v')
.unwrap_or(version)
.split('.')
.map(|part| {
part.parse()
.unwrap_or_else(|error: std::num::ParseIntError| match error.kind() {
std::num::IntErrorKind::PosOverflow => isize::MAX,
std::num::IntErrorKind::NegOverflow => isize::MIN,
_ => 0,
})
})
}
let mut first = parts(first);
let mut second = parts(second);
loop {
let (left, right) = (first.next(), second.next());
if left.is_none() && right.is_none() {
return Comparison::Equal;
}
let result = left.unwrap_or(0).cmp(&right.unwrap_or(0));
if !result.is_eq() {
return result;
}
}
}
@@ -0,0 +1,92 @@
use super::{EnvironmentError, ExtensionEnvironment};
use spotiflac_core::isrc::{IndexCache, IndexFiles, NativeFiles, parse_tracks};
use std::sync::atomic::Ordering;
type Check<'a> = &'a (dyn Fn() -> Result<(), String> + Sync);
impl ExtensionEnvironment {
fn with_index<T>(
&self,
check: Check<'_>,
operation: impl FnOnce(&IndexCache, &NativeFiles, Check<'_>) -> Result<T, String>,
) -> Result<T, EnvironmentError> {
let _operation = self.enter()?;
let guarded = || {
if self.closed.load(Ordering::Acquire) {
Err("extension environment closed".into())
} else {
check()
}
};
guarded().map_err(EnvironmentError::Index)?;
let result =
operation(&self.isrc, &NativeFiles, &guarded).map_err(EnvironmentError::Index)?;
guarded().map_err(EnvironmentError::Index)?;
Ok(result)
}
/// Trusted native entry points share the SDK's index, while JavaScript uses
/// a scoped IndexFiles adapter and revalidates native directory grants.
pub fn prebuild_isrc_index(
&self,
directory: &str,
check: Check<'_>,
) -> Result<(), EnvironmentError> {
self.with_index(check, |cache, files, check| {
cache.prebuild(directory, files, check)
})
}
pub fn check_isrc_exists(
&self,
directory: &str,
isrc: &str,
check: Check<'_>,
) -> Result<String, EnvironmentError> {
self.with_index(check, |cache, files, check| {
cache.check(directory, isrc, files, check)
})
}
pub fn add_to_isrc_index(
&self,
directory: &str,
isrc: &str,
path: &str,
check: Check<'_>,
) -> Result<(), EnvironmentError> {
self.with_index(check, |cache, files, check| {
cache.add(directory, isrc, path, files, check)
})
}
pub fn check_files_exist_parallel(
&self,
directory: &str,
tracks_json: &str,
check: Check<'_>,
) -> Result<String, EnvironmentError> {
self.with_index(check, |cache, files, check| {
if tracks_json.len() > 8 * 1024 * 1024 {
return Err("tracks JSON exceeds 8 MiB limit".into());
}
let tracks = parse_tracks(tracks_json)?;
let results = cache.check_batch(directory, &tracks, files, check)?;
serde_json::to_string(&results).map_err(|error| error.to_string())
})
}
pub fn invalidate_isrc_cache(&self, directory: &str) -> Result<(), EnvironmentError> {
let _operation = self.enter()?;
self.isrc.invalidate(directory);
Ok(())
}
pub fn check_file_exists(&self, path: &str) -> Result<bool, EnvironmentError> {
let _operation = self.enter()?;
NativeFiles
.stat(path)
.map(|stamp| stamp.is_some_and(|stamp| !stamp.directory && stamp.size > 0))
.map_err(EnvironmentError::Index)
}
}
@@ -0,0 +1,237 @@
//! Commands executed by the native FFmpeg pump, scoped to one environment.
use serde::Serialize;
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Condvar, Mutex};
use std::time::{Duration, Instant};
static NEXT_COMMAND: AtomicU64 = AtomicU64::new(0);
const COMMAND_TIMEOUT: Duration = Duration::from_secs(300);
#[derive(Clone, Debug, Serialize)]
pub struct Command {
pub command_id: String,
pub extension_id: String,
pub arguments: Vec<String>,
pub input_path: String,
pub output_path: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct CommandResult {
pub success: bool,
pub output: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub error: String,
}
struct Entry {
command: Command,
claimed: bool,
result: Option<CommandResult>,
waiter: std::thread::Thread,
}
#[derive(Default)]
struct State {
commands: BTreeMap<String, Entry>,
closed: bool,
}
#[derive(Default)]
pub struct CommandRegistry {
state: Mutex<State>,
changed: Condvar,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
#[error("FFmpeg command registry closed")]
pub struct RegistryClosed;
impl CommandRegistry {
pub fn get(&self, id: &str) -> Result<Option<Command>, RegistryClosed> {
let state = self.state.lock().expect("FFmpeg state lock");
state.check()?;
Ok(state.commands.get(id).map(|entry| entry.command.clone()))
}
pub fn pending_json(&self) -> Result<String, RegistryClosed> {
let mut state = self.state.lock().expect("FFmpeg state lock");
state.check()?;
Ok(state.pending(false))
}
/// Claims each command at most once, even with multiple native pumps.
/// A zero/negative timeout performs a nonblocking claim. Shutdown wakes waits.
pub fn wait_pending_json(&self, timeout_ms: i64) -> Result<String, RegistryClosed> {
let started = Instant::now();
let timeout = Duration::from_millis(timeout_ms.clamp(0, 60_000) as u64);
let mut state = self.state.lock().expect("FFmpeg state lock");
loop {
state.check()?;
let commands = state.pending(true);
let remaining = timeout.saturating_sub(started.elapsed());
if commands != "[]" || remaining.is_zero() {
return Ok(commands);
}
state = self
.changed
.wait_timeout(state, remaining)
.expect("FFmpeg pending wait")
.0;
}
}
/// Late or duplicate results cannot complete another command or revive one
/// removed by cancellation. The native pump can use get() to stop such work.
pub fn complete(&self, id: &str, result: CommandResult) -> Result<bool, RegistryClosed> {
let mut state = self.state.lock().expect("FFmpeg state lock");
state.check()?;
let Some(entry) = state.commands.get_mut(id) else {
return Ok(false);
};
if entry.result.is_some() {
return Ok(false);
}
entry.result = Some(result);
entry.waiter.unpark();
self.changed.notify_all();
Ok(true)
}
pub fn shutdown(&self) {
let mut state = self.state.lock().expect("FFmpeg state lock");
state.closed = true;
for entry in state.commands.values() {
entry.waiter.unpark();
}
state.commands.clear();
self.changed.notify_all();
}
pub(crate) fn execute(
&self,
extension_id: &str,
arguments: Vec<String>,
input_path: String,
output_path: String,
control: &crate::runtime::Control,
) -> Result<CommandResult, String> {
let _wait = control.watch_thread();
let check = || control.check().map_err(|error| error.to_string());
check()?;
let sequence = NEXT_COMMAND.fetch_add(1, Ordering::Relaxed).wrapping_add(1);
let id = format!("{extension_id}_{sequence}");
let command = Command {
command_id: id.clone(),
extension_id: extension_id.into(),
arguments,
input_path,
output_path,
};
{
let mut state = self.state.lock().expect("FFmpeg state lock");
state.check().map_err(|e| e.to_string())?;
state.commands.insert(
id.clone(),
Entry {
command,
claimed: false,
result: None,
waiter: std::thread::current(),
},
);
self.changed.notify_all();
}
let _pending = Pending {
registry: self,
id: &id,
};
let started = Instant::now();
let mut state = self.state.lock().expect("FFmpeg state lock");
loop {
check().map_err(|e| format!("FFmpeg command cancelled: {e}"))?;
state.check().map_err(|e| e.to_string())?;
if state
.commands
.get(&id)
.is_some_and(|entry| entry.result.is_some())
{
return Ok(state
.commands
.remove(&id)
.expect("completed FFmpeg command")
.result
.expect("FFmpeg result"));
}
let remaining = COMMAND_TIMEOUT.saturating_sub(started.elapsed());
if remaining.is_zero() {
return Err("FFmpeg command timed out".into());
}
drop(state);
std::thread::park_timeout(remaining.min(control.deadline_remaining()));
state = self.state.lock().expect("FFmpeg state lock");
}
}
}
impl State {
fn check(&self) -> Result<(), RegistryClosed> {
if self.closed {
Err(RegistryClosed)
} else {
Ok(())
}
}
fn pending(&mut self, claim: bool) -> String {
#[derive(Serialize)]
struct PendingCommand<'a> {
command_id: &'a str,
extension_id: &'a str,
arguments: &'a [String],
output_path: &'a str,
}
let pending: Vec<_> = self
.commands
.values_mut()
.filter(|entry| !entry.claimed && entry.result.is_none())
.map(|entry| {
if claim {
entry.claimed = true;
}
PendingCommand {
command_id: &entry.command.command_id,
extension_id: &entry.command.extension_id,
arguments: &entry.command.arguments,
output_path: &entry.command.output_path,
}
})
.collect();
serde_json::to_string(&pending).expect("FFmpeg pending JSON")
}
}
struct Pending<'a> {
registry: &'a CommandRegistry,
id: &'a str,
}
impl Drop for Pending<'_> {
fn drop(&mut self) {
self.registry
.state
.lock()
.expect("FFmpeg state lock")
.commands
.remove(self.id);
self.registry.changed.notify_all();
}
}
impl Drop for CommandRegistry {
fn drop(&mut self) {
self.shutdown();
}
}
@@ -0,0 +1,155 @@
use crate::files::ExtensionFiles;
use crate::runtime::{Control, ExtensionServices};
use cap_std::fs::OpenOptions;
use rquickjs::{Ctx, Function, Object, Value};
use std::sync::Arc;
pub(crate) fn register<'js>(
ctx: &Ctx<'js>,
host: &Object<'js>,
control: Arc<Control>,
services: &ExtensionServices,
) -> rquickjs::Result<()> {
host.set("rawFfmpegStub", services.raw_ffmpeg_stub)?;
let Some(files) = &services.files else {
return Ok(());
};
let info_files = Arc::clone(files);
let info_control = Arc::clone(&control);
host.set(
"mediaInfo",
Function::new(ctx.clone(), move |ctx: Ctx<'js>, path: String| {
let check = || info_control.check().map_err(|e| e.to_string());
let result = media_info(&info_files, &path, &check);
let object = Object::new(ctx.clone())?;
match result {
Ok(quality) => {
object.set("success", true)?;
object.set("bit_depth", quality.bit_depth)?;
object.set("sample_rate", quality.sample_rate)?;
object.set("total_samples", quality.total_samples)?;
object.set(
"duration",
quality.total_samples as f64 / quality.sample_rate as f64,
)?;
object.set("codec", quality.codec)?;
}
Err(error) => {
object.set("success", false)?;
object.set("error", error)?;
}
}
Ok::<_, rquickjs::Error>(object)
})?,
)?;
let files = Arc::clone(files);
let registry = Arc::clone(&services.ffmpeg);
let id = services.extension_id.clone();
host.set(
"mediaConvert",
Function::new(
ctx.clone(),
move |input: String, output: String, options: Value<'js>| {
let result = (|| {
let input = files
.resolve(&input)
.and_then(|path| path.native_display())
.map_err(|e| format!("invalid input path: {e}"))?;
let output = files
.resolve(&output)
.and_then(|path| path.native_display())
.map_err(|e| format!("invalid output path: {e}"))?;
let arguments = conversion_arguments(&input, &output, options.as_object())?;
let budget = control.resolution();
let _pause = budget.as_ref().map(|budget| budget.enter(false));
registry.execute(&id, arguments, input, output, &control)
})();
match result {
Ok(result) => serde_json::to_string(&result).expect("FFmpeg result JSON"),
Err(error) => serde_json::json!({"success":false,"error":error}).to_string(),
}
},
)?,
)?;
Ok(())
}
fn media_info(
files: &ExtensionFiles,
path: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<spotiflac_core::media::AudioQuality, String> {
let path = files.resolve(path)?;
let mut file = path
.open(OpenOptions::new().read(true))
.map_err(|e| format!("failed to open file: {e}"))?;
spotiflac_core::media::probe_quality(&mut file, check)
}
fn conversion_arguments(
input: &str,
output: &str,
options: Option<&Object<'_>>,
) -> Result<Vec<String>, String> {
let mut arguments = vec![
"-hide_banner".into(),
"-nostdin".into(),
"-i".into(),
input.into(),
];
if let Some(options) = options {
for (key, flag) in [("codec", "-c:a"), ("bitrate", "-b:a")] {
let value: Value = options.get(key).map_err(|e| e.to_string())?;
if let Some(value) = value.as_string() {
let value = value.to_string().map_err(|e| e.to_string())?;
if key == "codec" {
if !matches!(
value.as_str(),
"aac"
| "alac"
| "copy"
| "flac"
| "libmp3lame"
| "libopus"
| "opus"
| "pcm_s16le"
| "pcm_s24le"
) {
return Err("unsupported audio codec".into());
}
} else {
let digits = value.strip_suffix(['k', 'K', 'm', 'M']).unwrap_or(&value);
if digits.is_empty()
|| digits.len() > 8
|| digits.starts_with('0')
|| !digits.bytes().all(|byte| byte.is_ascii_digit())
{
return Err("invalid audio bitrate".into());
}
}
arguments.extend([flag.into(), value]);
}
}
for (key, message) in [
("sample_rate", "invalid sample rate"),
("channels", "invalid channel count"),
] {
let value: Value = options.get(key).map_err(|e| e.to_string())?;
if let Some(value) = value.as_number() {
// Goja exports integral JS Numbers as int64; the old host only
// accepts float64 here. Preserve its ignored integer options.
// Every remaining float is fractional, non-finite, or outside
// Goja's integer range, and fails the host's range/integer check.
if !value.is_finite()
|| value.fract() != 0.0
|| (value == 0.0 && value.is_sign_negative())
|| !(-9_007_199_254_740_992.0..=9_007_199_254_740_992.0).contains(&value)
{
return Err(message.into());
}
}
}
}
arguments.extend(["-y".into(), output.into()]);
Ok(arguments)
}
@@ -0,0 +1,311 @@
use crate::binary;
use crate::files::{ExtensionFiles, MAX_READ, READ_LIMIT_ERROR};
use crate::runtime::{Control, ExtensionServices};
use cap_std::fs::OpenOptions;
use rquickjs::{ArrayBuffer, Ctx, Function, Object, Value};
use std::io::{Read, Seek, SeekFrom, Write};
use std::sync::Arc;
pub(crate) fn register<'js>(
ctx: &Ctx<'js>,
host: &Object<'js>,
control: Arc<Control>,
services: &ExtensionServices,
) -> rquickjs::Result<()> {
host.set("filesEnabled", services.files.is_some())?;
let Some(files) = &services.files else {
return Ok(());
};
let files = Arc::clone(files);
host.set(
"fileCall",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>,
method: String,
path: String,
data: Value<'js>,
options: Value<'js>,
callback: Value<'js>| {
let check = || control.check().map_err(|error| error.to_string());
let result = call(
&ctx, &files, &method, &path, data, options, callback, &check,
);
if method == "exists" {
return Ok::<_, rquickjs::Error>(Value::new_bool(ctx, result.is_ok()));
}
match result {
Ok(object) => Ok(object.into_value()),
Err(error) => {
let object = Object::new(ctx)?;
object.set("success", false)?;
object.set("error", error)?;
Ok(object.into_value())
}
}
},
)?,
)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn call<'js>(
ctx: &Ctx<'js>,
files: &ExtensionFiles,
method: &str,
path: &str,
data: Value<'js>,
options: Value<'js>,
callback: Value<'js>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Object<'js>, String> {
check()?;
let path = files.resolve(path)?;
let options = Options(options.as_object());
let result = Object::new(ctx.clone()).map_err(js_error)?;
result.set("success", true).map_err(js_error)?;
match method {
"exists" | "getSize" => {
let info = path.metadata().map_err(io_error)?;
if method == "getSize" {
result.set("size", info.len()).map_err(js_error)?;
}
}
"delete" => {
let _guard = files.lock(&path, check)?;
path.remove().map_err(io_error)?;
}
"read" | "readBytes" => {
let offset = if method == "read" {
0
} else {
options.int("offset", 0)?
};
let requested = options.int("length", -1)?;
let encoding = options.text("encoding", "base64")?;
if offset < 0 {
return Err("offset must be >= 0".into());
}
let mut input = path.open(OpenOptions::new().read(true)).map_err(io_error)?;
let size = input.metadata().map_err(io_error)?.len();
let offset = (offset as u64).min(size);
let mut bytes;
if method == "read" {
bytes = Vec::new();
let mut buffer = [0; 64 << 10];
while bytes.len() <= MAX_READ {
check()?;
let capacity = buffer.len().min(MAX_READ + 1 - bytes.len());
let count = input.read(&mut buffer[..capacity]).map_err(io_error)?;
if count == 0 {
break;
}
bytes.extend_from_slice(&buffer[..count]);
}
if bytes.len() > MAX_READ {
return Err(READ_LIMIT_ERROR.into());
}
result
.set("data", crate::host::decode_go_utf8(&bytes))
.map_err(js_error)?;
} else {
input
.seek(SeekFrom::Start(offset))
.map_err(|error| format!("failed to seek file: {error}"))?;
let length = if requested < 0 {
size - offset
} else {
requested as u64
};
if length > MAX_READ as u64 {
return Err(READ_LIMIT_ERROR.into());
}
bytes = vec![0; length.min(size - offset) as usize];
let count = read_chunk(&mut input, &mut bytes, check)
.map_err(|error| format!("failed to read file: {error}"))?;
bytes.truncate(count);
if matches!(encoding.trim().to_lowercase().as_str(), "bytes" | "raw") {
result
.set(
"data",
ArrayBuffer::new_copy(ctx.clone(), &bytes).map_err(js_error)?,
)
.map_err(js_error)?;
} else {
result
.set("data", binary::encode(&bytes, &encoding)?)
.map_err(js_error)?;
}
result.set("bytes_read", bytes.len()).map_err(js_error)?;
result.set("offset", offset).map_err(js_error)?;
result.set("size", size).map_err(js_error)?;
result
.set("eof", offset + bytes.len() as u64 >= size)
.map_err(js_error)?;
}
}
"write" | "writeBytes" => {
let append = options.boolean("append", false)?;
let truncate = options.boolean("truncate", false)?;
let has_offset = options
.0
.map(|object| object.contains_key("offset"))
.transpose()
.map_err(js_error)?
.unwrap_or(false);
let offset = options.int("offset", 0)?;
if append && has_offset {
return Err("append and offset cannot be used together".into());
}
if offset < 0 {
return Err("offset must be >= 0".into());
}
let encoding = if method == "write" {
"utf8".into()
} else {
options.text("encoding", "base64")?
};
let bytes = binary::decode_value(data, &encoding)?;
let _guard = files.lock(&path, check)?;
path.mkdir_parent()
.map_err(|error| format!("failed to create directory: {error}"))?;
if method == "write" {
let mut output = path.stage().map_err(io_error)?;
write_chunks(&mut output.file, &bytes, check)?;
output.publish(check)?;
} else {
let mut output = path
.open(
OpenOptions::new()
.create(true)
.write(true)
.append(append)
.truncate(truncate),
)
.map_err(io_error)?;
if has_offset {
output
.seek(SeekFrom::Start(offset as u64))
.map_err(|error| format!("failed to seek file: {error}"))?;
}
write_chunks(&mut output, &bytes, check)?;
result.set("bytes_written", bytes.len()).map_err(js_error)?;
result
.set("size", output.metadata().map_err(io_error)?.len())
.map_err(js_error)?;
}
result.set("path", path.display()).map_err(js_error)?;
}
"copy" | "move" | "transformPatternedBlocks" => {
let destination = data
.as_string()
.ok_or("destination path is required")?
.to_string()
.map_err(js_error)?;
let destination = files.resolve(&destination)?;
if method == "transformPatternedBlocks" {
return crate::file_transform::transform(
ctx,
files,
&path,
&destination,
options,
callback,
check,
);
}
let _guard = files.lock(&destination, check)?;
if method == "move" {
destination
.mkdir_parent()
.map_err(|error| format!("failed to create directory: {error}"))?;
check()?;
path.rename_to(&destination)
.map_err(|error| format!("failed to move file: {error}"))?;
} else {
let mut input = path
.open(OpenOptions::new().read(true))
.map_err(|error| format!("failed to read source: {error}"))?;
destination
.mkdir_parent()
.map_err(|error| format!("failed to create directory: {error}"))?;
let mut output = destination
.open(OpenOptions::new().write(true).create(true).truncate(true))
.map_err(|error| format!("failed to open destination: {error}"))?;
let mut buffer = [0; 64 << 10];
loop {
let count = read_chunk(&mut input, &mut buffer, check)
.map_err(|error| format!("failed to copy file: {error}"))?;
if count == 0 {
break;
}
write_chunks(&mut output, &buffer[..count], check)
.map_err(|error| format!("failed to copy file: {error}"))?;
}
}
result
.set("path", destination.display())
.map_err(js_error)?;
}
_ => return Err("unknown file operation".into()),
}
check()?;
Ok(result)
}
pub(crate) struct Options<'a, 'js>(pub Option<&'a Object<'js>>);
impl Options<'_, '_> {
pub fn text(&self, key: &str, fallback: &str) -> Result<String, String> {
binary::option_string(self.0, key, fallback)
}
pub fn int(&self, key: &str, fallback: i64) -> Result<i64, String> {
self.0.map_or(Ok(fallback), |object| {
binary::option_i64(object, key, fallback)
})
}
pub fn boolean(&self, key: &str, fallback: bool) -> Result<bool, String> {
self.0.map_or(Ok(fallback), |object| {
binary::option_bool(object, key, fallback)
})
}
}
pub(crate) fn read_chunk(
input: &mut impl Read,
buffer: &mut [u8],
check: &dyn Fn() -> Result<(), String>,
) -> Result<usize, String> {
let mut total = 0;
while total < buffer.len() {
check()?;
let end = buffer.len().min(total + (64 << 10));
match input.read(&mut buffer[total..end]) {
Ok(0) => break,
Ok(count) => total += count,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error.to_string()),
}
}
Ok(total)
}
pub(crate) fn write_chunks(
output: &mut impl Write,
bytes: &[u8],
check: &dyn Fn() -> Result<(), String>,
) -> Result<(), String> {
for chunk in bytes.chunks(64 << 10) {
check()?;
output.write_all(chunk).map_err(io_error)?;
}
check()
}
pub(crate) fn js_error(error: rquickjs::Error) -> String {
error.to_string()
}
fn io_error(error: std::io::Error) -> String {
error.to_string()
}
@@ -0,0 +1,159 @@
use crate::binary;
use crate::crypto::BlockCipher;
use crate::file_host::{Options, js_error, read_chunk, write_chunks};
use crate::files::{ExtensionFiles, FilePath, MAX_READ};
use cap_std::fs::OpenOptions;
use rquickjs::{Coerced, Ctx, FromJs, Object, Value};
use zeroize::Zeroizing;
#[allow(clippy::too_many_arguments)]
pub(crate) fn transform<'js>(
ctx: &Ctx<'js>,
files: &ExtensionFiles,
input: &FilePath,
output: &FilePath,
options: Options<'_, 'js>,
callback: Value<'js>,
check: &dyn Fn() -> Result<(), String>,
) -> Result<Object<'js>, String> {
let algorithm = options.text("algorithm", "")?.to_lowercase();
let mode = options.text("mode", "cbc")?.to_lowercase();
let padding = options.text("padding", "none")?.to_lowercase();
if algorithm.is_empty() {
return Err("algorithm is required".into());
}
let key = Zeroizing::new(
binary::decode_option_bytes(options.0, "key", &options.text("keyEncoding", "utf8")?)
.map_err(|error| format!("invalid key: {error}"))?,
);
if key.is_empty() {
return Err("key is required".into());
}
let iv = binary::decode_option_bytes(options.0, "iv", &options.text("ivEncoding", "utf8")?)
.map_err(|error| format!("invalid iv: {error}"))?;
if mode != "cbc" && mode != "ctr" {
return Err(format!("unsupported block cipher mode: {mode}"));
}
if padding != "none" {
return Err("patterned file transforms only support padding: none".into());
}
let operation = options.text("operation", "decrypt")?.to_lowercase();
if operation != "decrypt" && operation != "encrypt" {
return Err("operation must be decrypt or encrypt".into());
}
let segment_size = options.int("segmentSize", 0)?;
let every = options.int("transformEvery", 1)?;
let offset = options.int("transformOffset", 0)?;
let buffer_size = options.int("bufferSize", 1 << 20)?;
let partial = options.boolean("transformPartial", false)?;
if !(1..=MAX_READ as i64).contains(&segment_size) {
return Err(format!(
"segmentSize must be between 1 and {MAX_READ} bytes"
));
}
if every <= 0 {
return Err("transformEvery must be greater than zero".into());
}
if offset < 0 || offset >= every {
return Err("transformOffset must be between 0 and transformEvery - 1".into());
}
let buffer_size = buffer_size.clamp(segment_size, MAX_READ as i64);
let buffer_size = (buffer_size - buffer_size % segment_size) as usize;
let cipher = BlockCipher::new(&algorithm, &key)?;
let block_size = cipher.block_size();
if iv.len() != block_size {
return Err(format!("iv must be {block_size} bytes for {algorithm}"));
}
if mode == "cbc" && segment_size % block_size as i64 != 0 {
return Err(format!(
"segmentSize must be a multiple of {block_size} bytes for CBC"
));
}
let callback = if callback.is_null() || callback.is_undefined() {
None
} else {
Some(
callback
.as_function()
.ok_or("progress callback must be a function")?,
)
};
let _guard = files.lock(output, check)?;
let mut source = input
.open(OpenOptions::new().read(true))
.map_err(|error| format!("failed to open input file: {error}"))?;
let total = source
.metadata()
.map_err(|error| format!("failed to stat input file: {error}"))?
.len();
// Keep the legacy collision check although randomized staging no longer
// risks clobbering a caller's existing .transform.partial file.
if input.display() == format!("{}.transform.partial", output.display()) {
return Err("input path conflicts with transform staging path".into());
}
output
.mkdir_parent()
.map_err(|error| format!("failed to create output directory: {error}"))?;
let mut staged = output
.stage()
.map_err(|error| format!("failed to create staged output: {error}"))?;
let mut buffer = Zeroizing::new(vec![0; buffer_size]);
let (mut processed, mut index, mut transformed) = (0_u64, 0_u64, 0_u64);
loop {
let count = read_chunk(&mut source, &mut buffer, check)
.map_err(|error| format!("failed to read input file: {error}"))?;
if count == 0 {
break;
}
for segment in buffer[..count].chunks_mut(segment_size as usize) {
check()?;
if index % every as u64 == offset as u64
&& (segment.len() == segment_size as usize || partial)
{
if mode == "cbc" && segment.len() % block_size != 0 {
return Err(format!(
"selected segment {index} is not a multiple of {block_size} bytes"
));
}
// Each selected segment restarts its IV/counter, matching Go.
cipher.transform(segment, &iv, &mode, operation == "decrypt", check)?;
transformed += 1;
}
index += 1;
}
write_chunks(&mut staged.file, &buffer[..count], check)
.map_err(|error| format!("failed to write transformed file: {error}"))?;
processed += count as u64;
if let Some(callback) = callback
&& let Err(error) = callback.call::<_, ()>((processed, total))
{
let message = if error.is_exception() {
let exception = ctx.catch();
match Coerced::<String>::from_js(ctx, exception) {
Ok(message) => message.0,
Err(_) => {
ctx.catch();
"JavaScript exception".into()
}
}
} else {
error.to_string()
};
return Err(format!("progress callback failed: {message}"));
}
check()?;
}
drop(source);
staged
.publish(check)
.map_err(|error| format!("failed to publish transformed file: {error}"))?;
let result = Object::new(ctx.clone()).map_err(js_error)?;
result.set("success", true).map_err(js_error)?;
result.set("path", output.display()).map_err(js_error)?;
result.set("bytes_processed", processed).map_err(js_error)?;
result.set("segments_processed", index).map_err(js_error)?;
result
.set("segments_transformed", transformed)
.map_err(js_error)?;
Ok(result)
}
+936
View File
@@ -0,0 +1,936 @@
//! File capabilities granted by the native manager, never by JavaScript.
mod index;
use cap_std::ambient_authority;
use cap_std::fs::{Dir, File, OpenOptions};
use std::collections::BTreeMap;
use std::io;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Condvar, Mutex, RwLock, Weak};
use std::thread::{self, ThreadId};
use std::time::Duration;
pub(crate) const MAX_READ: usize = 16 << 20;
pub(crate) const READ_LIMIT_ERROR: &str = "file read exceeds 16 MiB limit; use file.readBytes with offset and length to read it in chunks";
struct Root {
path: PathBuf,
directory: Dir,
}
impl Root {
fn open(path: &Path) -> io::Result<Arc<Self>> {
let path = clean(&std::path::absolute(path)?);
std::fs::create_dir_all(&path)?;
Ok(Arc::new(Self {
directory: Dir::open_ambient_dir(&path, ambient_authority())?,
path,
}))
}
}
#[derive(Default)]
pub struct FileRegistry {
allowed: RwLock<Vec<Arc<Root>>>,
temporary: RwLock<Vec<Weak<Root>>>,
outputs: Mutex<BTreeMap<PathBuf, Weak<OutputLock>>>,
}
/// Retain one host-created download directory while its provider is running.
/// Open file capabilities may outlive the grant; future opens may not.
pub struct TemporaryGrant {
root: Arc<Root>,
registry: Arc<FileRegistry>,
}
impl Drop for TemporaryGrant {
fn drop(&mut self) {
let key = Arc::downgrade(&self.root);
self.registry
.temporary
.write()
.expect("temporary file grants lock")
.retain(|root| !Weak::ptr_eq(root, &key));
}
}
impl FileRegistry {
/// Replace the native download-directory grants for future operations.
/// Open descriptors keep an in-progress operation bound to its original root.
pub fn set_allowed_directories(&self, paths: &[PathBuf]) -> io::Result<()> {
let roots = paths
.iter()
.map(|path| Root::open(path))
.collect::<io::Result<Vec<_>>>()?;
*self.allowed.write().expect("file grants lock") = roots;
Ok(())
}
pub(crate) fn grant_temporary_directory(
self: &Arc<Self>,
path: &Path,
) -> io::Result<TemporaryGrant> {
let root = Root::open(path)?;
let mut temporary = self.temporary.write().expect("temporary file grants lock");
temporary.retain(|root| root.strong_count() != 0);
temporary.push(Arc::downgrade(&root));
Ok(TemporaryGrant {
root,
registry: Arc::clone(self),
})
}
pub fn extension(self: &Arc<Self>, directory: &Path) -> io::Result<Arc<ExtensionFiles>> {
self.extension_with_alias(directory, directory)
}
pub(crate) fn extension_with_alias(
self: &Arc<Self>,
directory: &Path,
alias: &Path,
) -> io::Result<Arc<ExtensionFiles>> {
let root = Root::open(directory)?;
let alias = clean(&std::path::absolute(alias)?);
let alias = if alias == root.path {
None
} else if std::fs::canonicalize(&alias)? == std::fs::canonicalize(&root.path)? {
Some(alias)
} else {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"sandbox alias refers to a different directory",
));
};
Ok(Arc::new(ExtensionFiles {
root,
legacy_alias: alias,
registry: Arc::clone(self),
}))
}
pub(crate) fn validate_native_replacement(
&self,
path: &str,
bases: &[PathBuf],
) -> Result<(), String> {
let target = clean(Path::new(path));
let granted = self.allowed.read().expect("file grants lock");
let temporary: Vec<_> = self
.temporary
.read()
.expect("temporary file grants lock")
.iter()
.filter_map(Weak::upgrade)
.collect();
for base in bases
.iter()
.chain(granted.iter().chain(&temporary).map(|root| &root.path))
{
let base = clean(&std::path::absolute(base).map_err(|error| error.to_string())?);
if let Ok(relative) = target.strip_prefix(&base) {
let directory = match Dir::open_ambient_dir(&base, ambient_authority()) {
Ok(directory) => directory,
// Go accepts a future output path; validation must not create it.
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.to_string()),
};
return FilePath {
root: Arc::new(Root {
path: base.clone(),
directory,
}),
absolute: target.clone(),
relative: relative.into(),
}
.native_display()
.map(|_| ());
}
}
Err("replacement file path is outside allowed directories".into())
}
fn lock(
&self,
path: &Path,
check: &dyn Fn() -> Result<(), String>,
) -> Result<OutputGuard, String> {
let lock = {
let mut outputs = self.outputs.lock().expect("file outputs lock");
outputs.retain(|_, value| value.strong_count() > 0);
let lock = outputs
.get(path)
.and_then(Weak::upgrade)
.unwrap_or_default();
outputs.insert(path.to_owned(), Arc::downgrade(&lock));
lock
};
let mut owner = lock.owner.lock().expect("file owner lock");
while let Some(id) = *owner {
check()?;
if id == thread::current().id() {
return Err("file output is already in use by this callback".into());
}
owner = lock
.idle
.wait_timeout(owner, Duration::from_millis(10))
.expect("file output wait")
.0;
}
check()?;
*owner = Some(thread::current().id());
drop(owner);
Ok(OutputGuard(lock))
}
}
pub struct ExtensionFiles {
root: Arc<Root>,
legacy_alias: Option<PathBuf>,
registry: Arc<FileRegistry>,
}
impl ExtensionFiles {
pub(crate) fn resolve_legacy(&self, path: &str) -> Result<FilePath, String> {
let original = Path::new(path);
let path = clean(original);
if path.is_absolute() {
for root in std::iter::once(&self.root.path).chain(self.legacy_alias.as_ref()) {
if let Ok(relative) = path.strip_prefix(root) {
if root != &self.root.path
&& !std::fs::canonicalize(root).is_ok_and(|alias| {
std::fs::canonicalize(&self.root.path)
.is_ok_and(|canonical| alias == canonical)
})
{
return Err(
"file access denied: sandbox alias refers to a different directory"
.into(),
);
}
let mut resolved =
self.resolve(relative.to_str().ok_or("invalid UTF-8 path")?)?;
// Keep native path spelling in cache keys and results while
// opening through the original sandbox capability.
resolved.absolute = original.to_owned();
return Ok(resolved);
}
}
}
let mut resolved = self.resolve(path.to_str().ok_or("invalid UTF-8 path")?)?;
if original.is_absolute() {
resolved.absolute = original.to_owned();
}
Ok(resolved)
}
pub(crate) fn resolve(&self, path: &str) -> Result<FilePath, String> {
let input = clean(Path::new(path));
let (root, absolute) = if input.is_absolute() {
let root = self
.registry
.allowed
.read()
.expect("file grants lock")
.iter()
.find(|root| input.starts_with(&root.path))
.cloned();
let root = root.or_else(|| self.registry.temporary.read().expect("temporary file grants lock")
.iter().filter_map(Weak::upgrade).find(|root| input.starts_with(&root.path)))
.ok_or("file access denied: absolute paths are not allowed. Use relative paths within extension sandbox")?;
(root, input)
} else {
let absolute = clean(&self.root.path.join(input));
if !absolute.starts_with(&self.root.path) {
return Err(format!(
"file access denied: path '{path}' is outside sandbox"
));
}
(Arc::clone(&self.root), absolute)
};
let relative = absolute
.strip_prefix(&root.path)
.expect("validated file root")
.to_owned();
Ok(FilePath {
root,
absolute,
relative: if relative.as_os_str().is_empty() {
".".into()
} else {
relative
},
})
}
pub(crate) fn lock(
&self,
path: &FilePath,
check: &dyn Fn() -> Result<(), String>,
) -> Result<OutputGuard, String> {
// Go coordinates case-folded paths, including aliases on the default
// case-insensitive mobile/macOS filesystems.
self.registry
.lock(Path::new(&path.display().to_lowercase()), check)
}
}
pub(crate) struct FilePath {
root: Arc<Root>,
pub absolute: PathBuf,
relative: PathBuf,
}
impl FilePath {
/// Native FFmpeg opens a pathname rather than this capability descriptor.
/// Reject existing symlinks and special files before handing it to native.
/// The adapter must retain exclusive ownership of these paths through use.
pub(crate) fn native_display(&self) -> Result<String, String> {
let mut path = PathBuf::new();
for component in self.relative.components() {
path.push(component);
match self.root.directory.symlink_metadata(&path) {
Ok(metadata) => {
if metadata.is_symlink() || (!metadata.is_file() && !metadata.is_dir()) {
return Err(
"native media path must not contain symlinks or special files".into(),
);
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => break,
Err(error) => return Err(error.to_string()),
}
}
Ok(self.display())
}
pub(crate) fn open_parent(&self) -> io::Result<(Dir, std::ffi::OsString)> {
self.mkdir_parent()?;
let name = self.relative.file_name().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "invalid output filename")
})?;
Ok((
self.root.directory.open_dir(self.parent())?,
name.to_owned(),
))
}
pub fn display(&self) -> String {
self.absolute.to_string_lossy().into_owned()
}
pub fn metadata(&self) -> io::Result<cap_std::fs::Metadata> {
self.root.directory.metadata(&self.relative)
}
pub(crate) fn entries(&self) -> io::Result<Vec<(String, bool)>> {
let mut entries = self
.root
.directory
.read_dir(&self.relative)?
.map(|entry| {
let entry = entry?;
Ok((
entry.file_name().to_string_lossy().into_owned(),
entry.file_type()?.is_dir(),
))
})
.collect::<io::Result<Vec<_>>>()?;
entries.sort_by(|a, b| a.0.cmp(&b.0));
Ok(entries)
}
pub fn mkdir_parent(&self) -> io::Result<()> {
self.root.directory.create_dir_all(self.parent())
}
pub(crate) fn require_parent(&self) -> Result<(), String> {
self.root
.directory
.open_dir(self.parent())
.map(|_| ())
.map_err(|error| error.to_string())
}
fn parent(&self) -> &Path {
self.relative
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or(Path::new("."))
}
pub fn open(&self, options: &mut OpenOptions) -> io::Result<File> {
#[cfg(unix)]
{
use cap_std::fs::OpenOptionsExt;
// FIFOs/devices must not block a VM worker before cancellation can
// run. Only regular files are accepted after opening the descriptor.
options
.custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as i32)
.mode(0o644);
}
let file = self.root.directory.open_with(&self.relative, options)?;
if !file.metadata()?.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"not a regular file",
));
}
Ok(file)
}
/// Open a native read through its granted directory, rejecting symlinks at
/// every component without separately walking the same path for validation.
#[cfg(unix)]
pub(crate) fn open_native_read(&self) -> io::Result<File> {
use rustix::fd::{AsFd, OwnedFd};
use rustix::fs::{AtFlags, FileType, Mode, OFlags, openat, statat};
let mut directory: Option<OwnedFd> = None;
let mut components = self.relative.components().peekable();
while let Some(component) = components.next() {
if !matches!(component, Component::Normal(_) | Component::CurDir) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"invalid native path",
));
}
let name = component.as_os_str();
let parent = directory
.as_ref()
.map_or_else(|| self.root.directory.as_fd(), AsFd::as_fd);
let last = components.peek().is_none();
let mut flags = OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK;
if !last {
flags |= OFlags::DIRECTORY;
// Match capability traversal: a known child only needs search
// permission on its parents, not permission to list them.
#[cfg(any(target_os = "linux", target_os = "android"))]
{
flags |= OFlags::PATH;
}
}
let fd = match openat(parent, name, flags, Mode::empty()) {
Ok(fd) => fd,
Err(error) => {
// Preserve the native-path error for links and special
// files even when a platform reports ENOTDIR or ENXIO.
if error != rustix::io::Errno::NOENT
&& let Ok(info) = statat(parent, name, AtFlags::SYMLINK_NOFOLLOW)
{
let kind = FileType::from_raw_mode(info.st_mode);
if !matches!(kind, FileType::RegularFile | FileType::Directory) {
return Err(io::Error::other(
"native media path must not contain symlinks or special files",
));
}
}
return Err(error.into());
}
};
if last {
let file = std::fs::File::from(fd);
let metadata = file.metadata()?;
if metadata.is_file() {
return Ok(File::from_std(file));
}
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
if metadata.is_dir() {
"not a regular file"
} else {
"native media path must not contain symlinks or special files"
},
));
}
directory = Some(fd);
}
Err(io::Error::new(
io::ErrorKind::InvalidInput,
"empty native path",
))
}
#[cfg(not(unix))]
pub(crate) fn open_native_read(&self) -> io::Result<File> {
self.native_display().map_err(io::Error::other)?;
self.open(OpenOptions::new().read(true))
}
pub fn remove(&self) -> io::Result<()> {
let info = self.root.directory.symlink_metadata(&self.relative)?;
if info.is_dir() {
self.root.directory.remove_dir(&self.relative)
} else {
self.root.directory.remove_file(&self.relative)
}
}
pub fn rename_to(&self, target: &Self) -> io::Result<()> {
self.root
.directory
.rename(&self.relative, &target.root.directory, &target.relative)
}
pub fn stage(&self) -> io::Result<StagedFile> {
self.stage_with_parent(true, None).map(|(stage, _)| stage)
}
pub(crate) fn stage_existing_parent(&self) -> io::Result<StagedFile> {
self.stage_with_parent(false, None).map(|(stage, _)| stage)
}
/// Link a completed private provider file into staging without copying its
/// bytes. Verify the link against the caller's open descriptor before use.
pub(crate) fn stage_from(&self, input: &Self, source: &File) -> io::Result<(StagedFile, bool)> {
self.stage_with_parent(true, Some((input, source)))
}
fn stage_with_parent(
&self,
create_parent: bool,
source: Option<(&Self, &File)>,
) -> io::Result<(StagedFile, bool)> {
use aes_gcm::aead::{OsRng, rand_core::RngCore};
if create_parent {
self.mkdir_parent()?;
}
let parent = self.root.directory.open_dir(self.parent())?;
let target = self
.relative
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid output filename"))?
.to_owned();
for _ in 0..8 {
let mut nonce = [0; 16];
OsRng
.try_fill_bytes(&mut nonce)
.map_err(|error| io::Error::other(error.to_string()))?;
let name = format!(
".spotiflac-{}.partial",
crate::binary::encode(&nonce, "hex").expect("hex encoding")
);
#[cfg(unix)]
if let Some((input, source)) = source {
use cap_std::fs::MetadataExt;
match input
.root
.directory
.hard_link(&input.relative, &parent, &name)
{
Ok(()) => {
let linked = rustix::fs::openat(
&parent,
&name,
rustix::fs::OFlags::RDONLY
| rustix::fs::OFlags::NOFOLLOW
| rustix::fs::OFlags::CLOEXEC,
rustix::fs::Mode::empty(),
)
.map(|fd| File::from_std(std::fs::File::from(fd)))
.map_err(io::Error::from);
let linked = linked.and_then(|file| {
let actual = file.metadata()?;
let expected = source.metadata()?;
if actual.is_file()
&& actual.dev() == expected.dev()
&& actual.ino() == expected.ino()
{
Ok(file)
} else {
Err(io::Error::other(
"provider source changed before publication",
))
}
});
if let Ok(file) = linked {
return Ok((
StagedFile {
parent,
name,
target,
file,
published: false,
},
true,
));
}
parent.remove_file(&name)?;
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
// Cross-device and filesystems without hard links retain
// the descriptor-based copy path below.
Err(_) => {}
}
}
#[cfg(not(unix))]
let _ = source;
let mut options = OpenOptions::new();
options.read(true).write(true).create_new(true);
#[cfg(unix)]
{
use cap_std::fs::OpenOptionsExt;
options.mode(0o644);
}
match parent.open_with(&name, &options) {
Ok(file) => {
return Ok((
StagedFile {
parent,
name,
target,
file,
published: false,
},
false,
));
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
}
Err(io::Error::other("unable to allocate staged output"))
}
}
pub(crate) struct StagedFile {
parent: Dir,
name: String,
target: std::ffi::OsString,
pub file: File,
published: bool,
}
impl StagedFile {
/// Album resolution must not replace a file created by another publisher
/// after the planner checked the destination. Both names are in this parent.
pub fn publish_new(mut self, check: &dyn Fn() -> Result<(), String>) -> io::Result<()> {
self.file.sync_all()?;
check().map_err(io::Error::other)?;
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
rustix::fs::renameat_with(
&self.parent,
&self.name,
&self.parent,
&self.target,
rustix::fs::RenameFlags::NOREPLACE,
)?;
#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))]
{
self.parent
.hard_link(&self.name, &self.parent, &self.target)?;
let _ = self.parent.remove_file(&self.name);
}
self.published = true;
let _ = self
.parent
.try_clone()
.and_then(|parent| parent.into_std_file().sync_all());
Ok(())
}
pub fn publish(mut self, check: &dyn Fn() -> Result<(), String>) -> Result<(), String> {
self.file.sync_all().map_err(|error| error.to_string())?;
check()?;
self.parent
.rename(&self.name, &self.parent, &self.target)
.map_err(|error| error.to_string())?;
self.published = true;
// Match native tag/download publication durability where the filesystem
// supports directory fsync. Unsupported directory sync is best-effort.
let _ = self
.parent
.try_clone()
.and_then(|parent| parent.into_std_file().sync_all());
Ok(())
}
}
impl Drop for StagedFile {
fn drop(&mut self) {
if !self.published {
let _ = self.parent.remove_file(&self.name);
}
}
}
#[derive(Default)]
struct OutputLock {
owner: Mutex<Option<ThreadId>>,
idle: Condvar,
}
pub(crate) struct OutputGuard(Arc<OutputLock>);
impl Drop for OutputGuard {
fn drop(&mut self) {
*self.0.owner.lock().expect("file owner lock") = None;
self.0.idle.notify_all();
}
}
pub(crate) fn clean(path: &Path) -> PathBuf {
let mut result = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if result.file_name().is_some_and(|name| name != "..") {
result.pop();
} else if !result.has_root() {
result.push("..");
}
}
_ => result.push(component),
}
}
if result.as_os_str().is_empty() {
".".into()
} else {
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Seek, SeekFrom, Write};
#[cfg(unix)]
#[test]
fn native_reads_reject_links_at_every_component_and_never_write_or_wait_on_special_files() {
use std::io::Read;
use std::os::unix::{fs::symlink, net::UnixListener};
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
std::fs::create_dir_all(root.path().join("album/disc")).unwrap();
std::fs::write(root.path().join("album/disc/song.wav"), b"original").unwrap();
#[cfg(any(target_os = "linux", target_os = "android"))]
{
use std::os::unix::fs::PermissionsExt;
let parent = root.path().join("album");
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o111)).unwrap();
let result = files
.resolve("album/disc/song.wav")
.unwrap()
.open_native_read();
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(result.is_ok(), "execute-only parent: {result:?}");
}
let mut file = files
.resolve("album/disc/song.wav")
.unwrap()
.open_native_read()
.unwrap();
let mut data = Vec::new();
file.read_to_end(&mut data).unwrap();
assert_eq!(data, b"original");
assert!(file.write_all(b"changed").is_err());
for (target, link) in [
("album", "album-link"),
("album/disc/song.wav", "song-link.wav"),
("missing.wav", "dangling.wav"),
] {
symlink(target, root.path().join(link)).unwrap();
}
for path in ["album-link/disc/song.wav", "song-link.wav", "dangling.wav"] {
assert!(
files
.resolve(path)
.unwrap()
.open_native_read()
.unwrap_err()
.to_string()
.contains("symlinks")
);
}
assert_eq!(
files
.resolve("missing.wav")
.unwrap()
.open_native_read()
.unwrap_err()
.kind(),
io::ErrorKind::NotFound
);
assert!(
std::process::Command::new("mkfifo")
.arg(root.path().join("pipe.wav"))
.status()
.unwrap()
.success()
);
let _socket = UnixListener::bind(root.path().join("socket.wav")).unwrap();
for path in ["pipe.wav", "socket.wav"] {
assert!(
files
.resolve(path)
.unwrap()
.open_native_read()
.unwrap_err()
.to_string()
.contains("special files")
);
}
assert_eq!(
std::fs::read(root.path().join("album/disc/song.wav")).unwrap(),
b"original"
);
}
#[test]
fn exclusive_publication_preserves_racing_output_and_cleans_cancelled_stage() {
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
let path = files.resolve("track.flac").unwrap();
let mut stage = path.stage().unwrap();
stage.file.write_all(b"new audio").unwrap();
assert!(!path.absolute.exists());
let error = stage
.publish_new(&|| {
// A different publisher creates the destination after the
// planner's existence check but before the publication syscall.
std::fs::write(&path.absolute, b"other publisher").unwrap();
Ok(())
})
.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read(&path.absolute).unwrap(), b"other publisher");
let cancelled = files.resolve("cancelled.flac").unwrap();
let mut stage = cancelled.stage().unwrap();
stage.file.write_all(b"cancelled audio").unwrap();
assert_eq!(
stage
.publish_new(&|| Err("cancelled".into()))
.unwrap_err()
.to_string(),
"cancelled"
);
assert!(!cancelled.absolute.exists());
path.remove().unwrap();
let mut stage = path.stage().unwrap();
stage.file.write_all(b"complete audio").unwrap();
stage.publish_new(&|| Ok(())).unwrap();
assert_eq!(std::fs::read(&path.absolute).unwrap(), b"complete audio");
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
}
#[cfg(unix)]
#[test]
fn stage_from_links_same_filesystem_source_and_survives_source_cleanup() {
use cap_std::fs::MetadataExt;
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
let source_path = files.resolve("source.bin").unwrap();
std::fs::write(&source_path.absolute, b"source audio").unwrap();
let source = source_path.open(OpenOptions::new().read(true)).unwrap();
let destination = files.resolve("destination.bin").unwrap();
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
assert!(promoted);
let source_metadata = source.metadata().unwrap();
let stage_metadata = stage.file.metadata().unwrap();
assert_eq!(source_metadata.dev(), stage_metadata.dev());
assert_eq!(source_metadata.ino(), stage_metadata.ino());
stage.publish_new(&|| Ok(())).unwrap();
drop(source);
source_path.remove().unwrap();
assert_eq!(
std::fs::read(&destination.absolute).unwrap(),
b"source audio"
);
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
}
#[cfg(unix)]
#[test]
fn stage_from_copies_open_source_after_path_replacement() {
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
let source_path = files.resolve("source.bin").unwrap();
std::fs::write(&source_path.absolute, b"original audio").unwrap();
let mut source = source_path.open(OpenOptions::new().read(true)).unwrap();
std::fs::rename(&source_path.absolute, root.path().join("source.old")).unwrap();
std::fs::write(&source_path.absolute, b"replacement audio").unwrap();
let destination = files.resolve("destination.bin").unwrap();
let (mut stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
assert!(!promoted);
source.seek(SeekFrom::Start(0)).unwrap();
std::io::copy(&mut source, &mut stage.file).unwrap();
stage.publish_new(&|| Ok(())).unwrap();
assert_eq!(
std::fs::read(&destination.absolute).unwrap(),
b"original audio"
);
}
#[cfg(unix)]
#[test]
fn stage_from_copies_open_source_after_path_becomes_symlink() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
let source_path = files.resolve("source.bin").unwrap();
std::fs::write(&source_path.absolute, b"original audio").unwrap();
let mut source = source_path.open(OpenOptions::new().read(true)).unwrap();
std::fs::rename(&source_path.absolute, root.path().join("source.old")).unwrap();
symlink("source.old", &source_path.absolute).unwrap();
let destination = files.resolve("destination.bin").unwrap();
let (mut stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
assert!(!promoted);
source.seek(SeekFrom::Start(0)).unwrap();
std::io::copy(&mut source, &mut stage.file).unwrap();
stage.publish_new(&|| Ok(())).unwrap();
assert_eq!(
std::fs::read(&destination.absolute).unwrap(),
b"original audio"
);
}
#[cfg(unix)]
#[test]
fn stage_from_preserves_existing_destination_and_cleans_cancelled_stage() {
let root = tempfile::tempdir().unwrap();
let files = Arc::new(FileRegistry::default())
.extension(root.path())
.unwrap();
let source_path = files.resolve("source.bin").unwrap();
std::fs::write(&source_path.absolute, b"source audio").unwrap();
let source = source_path.open(OpenOptions::new().read(true)).unwrap();
let destination = files.resolve("destination.bin").unwrap();
std::fs::write(&destination.absolute, b"existing audio").unwrap();
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
assert!(promoted);
let error = stage.publish_new(&|| Ok(())).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
assert_eq!(
std::fs::read(&destination.absolute).unwrap(),
b"existing audio"
);
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 2);
std::fs::remove_file(&destination.absolute).unwrap();
let (stage, promoted) = destination.stage_from(&source_path, &source).unwrap();
assert!(promoted);
let error = stage.publish_new(&|| Err("cancelled".into())).unwrap_err();
assert_eq!(error.to_string(), "cancelled");
assert!(!destination.absolute.exists());
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
}
}
@@ -0,0 +1,91 @@
use super::{ExtensionFiles, clean};
use cap_std::fs::{Metadata, OpenOptions};
use spotiflac_core::isrc::{FileStamp, IndexFiles};
use std::time::UNIX_EPOCH;
impl IndexFiles for ExtensionFiles {
fn list(
&self,
directory: &str,
check: &(dyn Fn() -> Result<(), String> + Sync),
) -> Result<Vec<FileStamp>, String> {
let directory = self.resolve_legacy(directory)?;
let mut pending = vec![(directory.relative.clone(), directory.absolute.clone())];
let mut result = Vec::new();
while let Some((relative, absolute)) = pending.pop() {
check()?;
let Ok(metadata) = directory.root.directory.symlink_metadata(&relative) else {
continue;
};
if !metadata.is_dir() {
let path = absolute.to_str().ok_or("invalid UTF-8 path")?;
result.push(stamp(path, &metadata));
continue;
}
let Ok(opened) = directory.root.directory.open_dir(&relative) else {
continue;
};
let Ok(entries) = opened.entries() else {
continue;
};
let mut names = Vec::new();
for entry in entries {
check()?;
if let Ok(entry) = entry {
names.push(entry.file_name());
}
}
names.sort();
pending.extend(
names
.into_iter()
.rev()
.map(|name| (relative.join(&name), clean(&absolute.join(name)))),
);
}
check()?;
Ok(result)
}
fn read(
&self,
path: &str,
check: &(dyn Fn() -> Result<(), String> + Sync),
) -> Result<String, String> {
check()?;
let resolved = self.resolve_legacy(path)?;
let Ok(mut file) = resolved.open(OpenOptions::new().read(true)) else {
return Ok(String::new());
};
let format = std::path::Path::new(path)
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
spotiflac_core::isrc::read_isrc(&mut file, &format, check)
}
fn stat(&self, path: &str) -> Result<Option<FileStamp>, String> {
let path = self.resolve_legacy(path)?;
Ok(path
.metadata()
.ok()
.map(|metadata| stamp(&path.display(), &metadata)))
}
}
fn stamp(path: &str, metadata: &Metadata) -> FileStamp {
let modified_ns = metadata
.modified()
.map(|time| match time.into_std().duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_nanos() as i128,
Err(error) => -(error.duration().as_nanos() as i128),
})
.unwrap_or_default();
FileStamp {
path: path.into(),
size: metadata.len(),
modified_ns,
directory: metadata.is_dir(),
}
}
+270
View File
@@ -0,0 +1,270 @@
use crate::runtime::{Control, ExtensionServices};
use crate::storage::{StorageError, StoreKind};
use base64::Engine;
use base64::alphabet;
use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD};
use hmac::{Hmac, Mac};
use md5::Md5;
use rquickjs::{Ctx, Function, Object};
use sha1::Sha1;
use sha2::{Digest, Sha256};
use std::sync::Arc;
use std::time::Duration;
pub(crate) fn register<'js>(
ctx: &Ctx<'js>,
control: Arc<Control>,
services: &ExtensionServices,
) -> rquickjs::Result<Object<'js>> {
let host = Object::new(ctx.clone())?;
crate::utility_host::register(ctx, &host, Arc::clone(&control), services)?;
crate::legacy_host::register(ctx, &host, Arc::clone(&control), services)?;
let item_control = Arc::clone(&control);
host.set(
"downloadItemActive",
Function::new(ctx.clone(), move || !item_control.item_id().is_empty())?,
)?;
let status_control = Arc::clone(&control);
let downloads = Arc::clone(&services.downloads);
host.set(
"downloadStatus",
Function::new(ctx.clone(), move |status: String| {
let id = status_control.item_id();
if id.is_empty() {
return;
}
let _ = match status.trim().to_lowercase().as_str() {
"preparing" => downloads.progress.preparing(&id, ""),
"downloading" => downloads.progress.downloading(&id),
"finalizing" => downloads.progress.finalizing(&id),
_ => Ok(()),
};
})?,
)?;
let resolution_control = Arc::clone(&control);
host.set(
"resolutionRemaining",
Function::new(ctx.clone(), move || {
resolution_control
.resolution()
.map_or(60_000, |budget| budget.remaining().as_millis() as u64)
})?,
)?;
crate::crypto_host::register(ctx, &host, Arc::clone(&control))?;
crate::file_host::register(ctx, &host, Arc::clone(&control), services)?;
crate::ffmpeg_host::register(ctx, &host, Arc::clone(&control), services)?;
crate::download_host::register(ctx, &host, Arc::clone(&control), services)?;
crate::network_host::register(ctx, &host, Arc::clone(&control), services)?;
crate::session_host::register(ctx, &host, Arc::clone(&control), services)?;
host.set("authEnabled", services.auth.is_some())?;
if let Some(auth) = &services.auth {
let auth = Arc::clone(auth);
let control = Arc::clone(&control);
host.set(
"authCall",
Function::new(
ctx.clone(),
move |method: String, arguments: String, expires_is_float: bool| {
let arguments = zeroize::Zeroizing::new(arguments);
let result = serde_json::from_str::<Vec<serde_json::Value>>(&arguments)
.map_err(|error| error.to_string())
.and_then(|arguments| {
auth.call(&method, &arguments, expires_is_float, || {
control.check().map_err(|error| error.to_string())
})
});
match result {
Ok(value) => value,
Err(error) if method == "generatePKCE" => {
serde_json::json!({"error":error})
}
Err(error) => serde_json::json!({"success":false,"error":error}),
}
.to_string()
},
)?,
)?;
}
host.set("storageEnabled", services.storage.is_some())?;
if let Some(store) = &services.storage {
let reader = Arc::clone(store);
host.set(
"storageRead",
Function::new(ctx.clone(), move |credentials: bool, key: String| {
let kind = if credentials {
StoreKind::Credentials
} else {
StoreKind::Storage
};
match reader.get(kind, &key) {
Ok(Some(value)) => serde_json::json!({"found":true,"value":value}),
Ok(None) => serde_json::json!({"found":false}),
Err(error) => serde_json::json!({"error":error.to_string()}),
}
.to_string()
})?,
)?;
let writer = Arc::clone(store);
host.set(
"storageWrite",
Function::new(
ctx.clone(),
move |credentials: bool, key: String, value: String| {
let kind = if credentials {
StoreKind::Credentials
} else {
StoreKind::Storage
};
let result = serde_json::from_str(&value)
.map_err(StorageError::from)
.and_then(|value| writer.set(kind, &key, value));
storage_result(result)
},
)?,
)?;
let remover = Arc::clone(store);
host.set(
"storageRemove",
Function::new(ctx.clone(), move |credentials: bool, key: String| {
let kind = if credentials {
StoreKind::Credentials
} else {
StoreKind::Storage
};
storage_result(remover.remove(kind, &key))
})?,
)?;
}
host.set(
"encodeBytes",
Function::new(ctx.clone(), |value: Vec<u8>| STANDARD.encode(value))?,
)?;
host.set(
"base64Encode",
Function::new(ctx.clone(), |value: String| STANDARD.encode(value))?,
)?;
host.set(
"base64Decode",
Function::new(ctx.clone(), |value: String, url_safe: bool| {
// Go accepts nonzero padding bits and ignores CR/LF, but requires padding.
let value = value.replace(['\r', '\n'], "");
let config = GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true);
let decoded = GeneralPurpose::new(&alphabet::STANDARD, config)
.decode(&value)
.or_else(|error| {
if url_safe {
GeneralPurpose::new(&alphabet::URL_SAFE, config).decode(&value)
} else {
Err(error)
}
})
.unwrap_or_default();
decode_go_utf8(&decoded)
})?,
)?;
host.set(
"encode",
Function::new(ctx.clone(), |value: String| value.into_bytes())?,
)?;
host.set(
"decode",
Function::new(ctx.clone(), |value: Vec<u8>| decode_go_utf8(&value))?,
)?;
host.set(
"md5",
Function::new(ctx.clone(), |value: String| {
format!("{:x}", Md5::digest(value))
})?,
)?;
host.set(
"sha256",
Function::new(ctx.clone(), |value: String| {
format!("{:x}", Sha256::digest(value))
})?,
)?;
host.set(
"hmacSHA256",
Function::new(ctx.clone(), |message: String, key: String| {
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())
.expect("HMAC accepts any key length");
mac.update(message.as_bytes());
format!("{:x}", mac.finalize().into_bytes())
})?,
)?;
host.set(
"hmacSHA256Base64",
Function::new(ctx.clone(), |message: String, key: String| {
let mut mac = Hmac::<Sha256>::new_from_slice(key.as_bytes())
.expect("HMAC accepts any key length");
mac.update(message.as_bytes());
STANDARD.encode(mac.finalize().into_bytes())
})?,
)?;
host.set(
"hmacSHA1",
Function::new(ctx.clone(), |key: Vec<u8>, message: Vec<u8>| {
let mut mac = Hmac::<Sha1>::new_from_slice(&key).expect("HMAC accepts any key length");
mac.update(&message);
mac.finalize().into_bytes().to_vec()
})?,
)?;
host.set(
"quoteJSON",
Function::new(ctx.clone(), |value: String| {
// Go encoding/json escapes HTML and JS separators in strings.
serde_json::to_string(&value)
.expect("JSON string encoding")
.replace('&', "\\u0026")
.replace('<', "\\u003c")
.replace('>', "\\u003e")
.replace('\u{2028}', "\\u2028")
.replace('\u{2029}', "\\u2029")
})?,
)?;
host.set(
"sortKeys",
Function::new(ctx.clone(), |mut keys: Vec<String>| {
// UTF-8/code-point ordering, rather than JS sort's UTF-16 ordering.
keys.sort();
keys
})?,
)?;
let sleep_control = Arc::clone(&control);
host.set(
"sleep",
Function::new(ctx.clone(), move |milliseconds: u64| {
sleep_control.sleep(Duration::from_millis(milliseconds.min(300_000)))
})?,
)?;
Ok(host)
}
fn storage_result(result: Result<(), StorageError>) -> String {
match result {
Ok(()) => serde_json::json!({"success":true}),
Err(error) => serde_json::json!({"success":false,"error":error.to_string()}),
}
.to_string()
}
// Go's UTF-8 decoder consumes one byte for every invalid rune. Rust's lossy
// conversion groups some invalid prefixes, which would change extension text.
pub(crate) fn decode_go_utf8(mut bytes: &[u8]) -> String {
let mut decoded = String::with_capacity(bytes.len());
while !bytes.is_empty() {
match std::str::from_utf8(bytes) {
Ok(valid) => {
decoded.push_str(valid);
break;
}
Err(error) => {
let prefix = error.valid_up_to();
decoded
.push_str(std::str::from_utf8(&bytes[..prefix]).expect("valid UTF-8 prefix"));
decoded.push('\u{fffd}');
bytes = &bytes[prefix + 1..];
}
}
}
decoded
}
@@ -0,0 +1,52 @@
use crate::runtime::{Control, ExtensionServices};
use rquickjs::{Ctx, Exception, Function, Object};
use std::sync::Arc;
pub(crate) fn register<'js>(
ctx: &Ctx<'js>,
host: &Object<'js>,
control: Arc<Control>,
services: &ExtensionServices,
) -> rquickjs::Result<()> {
host.set("legacyBackend", services.legacy_backend)?;
if !services.legacy_backend {
return Ok(());
}
files::register(ctx, host, Arc::clone(&control), services)?;
lyrics::register(ctx, host, Arc::clone(&control), services)?;
host.set(
"legacySanitize",
Function::new(ctx.clone(), |value: String| {
spotiflac_core::filename::sanitize_filename(&value)
})?,
)?;
host.set(
"legacyFilename",
Function::new(
ctx.clone(),
move |ctx: Ctx<'js>, template: String, metadata: String| {
let check = || control.check().map_err(|error| error.to_string());
let result = serde_json::from_str(&metadata)
.map_err(|error| error.to_string())
.and_then(|metadata| {
spotiflac_core::filename::build_filename_checked(
&template,
&metadata,
8 * 1024 * 1024,
&check,
)
});
result.map_err(|error| Exception::throw_message(&ctx, &error))
},
)?,
)?;
host.set(
"legacyLocalTime",
Function::new(ctx.clone(), || {
serde_json::to_string(&spotiflac_core::clock::local_time()).expect("local time JSON")
})?,
)?;
Ok(())
}
mod files;
mod lyrics;
@@ -0,0 +1,79 @@
use crate::runtime::{Control, ExtensionServices};
use cap_std::fs::OpenOptions;
use rquickjs::{Ctx, Function, Object};
use serde_json::json;
use std::sync::Arc;
pub(super) fn register<'js>(
ctx: &Ctx<'js>,
host: &Object<'js>,
control: Arc<Control>,
services: &ExtensionServices,
) -> rquickjs::Result<()> {
let files = services
.legacy_files
.clone()
.or_else(|| services.files.clone());
let quality_files = files.clone();
let quality_control = Arc::clone(&control);
host.set(
"legacyQuality",
Function::new(ctx.clone(), move |path: String| {
let check = || quality_control.check().map_err(|error| error.to_string());
let result = (|| {
check()?;
let files = quality_files.as_ref().ok_or("file access unavailable")?;
let path = files.resolve_legacy(&path)?;
let mut file = path
.open(OpenOptions::new().read(true))
.map_err(|error| format!("failed to open file: {error}"))?;
spotiflac_core::media::probe_quality(&mut file, &check)
})();
match result {
Ok(quality) => json!({
"bitDepth":quality.bit_depth,"sampleRate":quality.sample_rate,
"totalSamples":quality.total_samples,"duration":quality.duration,
"codec":quality.codec
}),
Err(error) => json!({"error":error}),
}
.to_string()
})?,
)?;
let cache = Arc::clone(&services.isrc);
host.set(
"legacyIsrc",
Function::new(
ctx.clone(),
move |add: bool, directory: String, isrc: String, path: String| {
let check = || control.check().map_err(|error| error.to_string());
let (directory, isrc, path) = (directory.trim(), isrc.trim(), path.trim());
let result = (|| {
if directory.is_empty() || isrc.is_empty() || (add && path.is_empty()) {
return Err(if add {
"outputDir, isrc, and filePath are required"
} else {
"outputDir and isrc are required"
}
.to_owned());
}
check()?;
let files = files.as_ref().ok_or("file access unavailable")?;
let directory = files.resolve_legacy(directory)?.display();
if add {
let path = files.resolve_legacy(path)?.display();
cache.add(&directory, isrc, &path, files.as_ref(), &check)?;
Ok(json!({"success":true}))
} else {
let path = cache.check(&directory, isrc, files.as_ref(), &check)?;
Ok(json!({"exists":!path.is_empty(),"filePath":path}))
}
})();
result
.unwrap_or_else(|error| json!({"error":error}))
.to_string()
},
)?,
)?;
Ok(())
}
@@ -0,0 +1,131 @@
use crate::runtime::{Control, ExtensionServices};
use cap_std::fs::OpenOptions;
use rquickjs::{Ctx, Function, Object};
use serde_json::json;
use spotiflac_core::lyrics::{file, lrc};
use spotiflac_providers::lyrics::SearchRequest;
use std::sync::Arc;
pub(super) fn register<'js>(
ctx: &Ctx<'js>,
host: &Object<'js>,
control: Arc<Control>,
services: &ExtensionServices,
) -> rquickjs::Result<()> {
let lyrics = services.lyrics.clone();
let node = services.lyrics_node.clone();
let files = services
.legacy_files
.clone()
.or_else(|| services.files.clone());
host.set(
"legacyLyrics",
Function::new(
ctx.clone(),
move |spotify_id: String,
track: String,
artist: String,
path: String,
duration: f64,
duration_text: Option<String>,
number_conversion: bool| {
let check = || control.check().map_err(|error| error.to_string());
let result: Result<String, String> = (|| {
check()?;
let (spotify_id, track, artist, path) =
(spotify_id.trim(), track.trim(), artist.trim(), path.trim());
if !path.is_empty() {
let result = file::extract(
path,
&|path| {
files
.as_ref()
.ok_or("file access unavailable")?
.resolve_legacy(path)?
.open(OpenOptions::new().read(true))
.map_err(|error| error.to_string())
},
&check,
);
check()?;
return Ok(result
.ok()
.filter(|text| lrc::has_usable_content(text))
.unwrap_or_default());
}
let lyrics = lyrics.upgrade().ok_or("lyrics service unavailable")?;
let response = lyrics
.fetch(
SearchRequest {
spotify_id: spotify_id.into(),
track: track.into(),
artist: artist.into(),
duration: duration_ms(
duration,
duration_text.as_deref(),
number_conversion,
) as f64
/ 1000.0,
caller: Some(node.clone()),
..SearchRequest::default()
},
&check,
)
.map_err(|error| error.to_string())?;
check()?;
Ok(if response.instrumental {
"[instrumental:true]".into()
} else {
lrc::with_metadata(&response, track, artist)
})
})();
match result {
Ok(text) => json!({"lyrics":text}),
Err(error) => json!({"error":error}),
}
.to_string()
},
)?,
)?;
Ok(())
}
fn duration_ms(number: f64, text: Option<&str>, number_conversion: bool) -> i64 {
let Some(text) = text else {
return number as i64;
};
// Goja's primitive unicodeString.ToInteger returns zero. Object coercion
// first calls ToNumber, which trims ECMAScript whitespace before Go space.
if !number_conversion && !text.is_ascii() {
return 0;
}
let text = text.trim();
let (digits, radix) = match text.get(..2) {
Some("0x" | "0X") => (&text[2..], 16),
Some("0b" | "0B") => (&text[2..], 2),
Some("0o" | "0O") => (&text[2..], 8),
_ => (text, 10),
};
match i64::from_str_radix(digits, radix) {
Ok(value) => value,
Err(_) if radix != 10 && number_conversion => 0,
Err(error) if radix != 10 => match error.kind() {
std::num::IntErrorKind::PosOverflow => i64::MAX,
std::num::IntErrorKind::NegOverflow => i64::MIN,
_ => 0,
},
_ => {
// Preserve Go's decimal parsing and reject non-ECMAScript inf names.
let parsed = if text.contains('_') {
None
} else {
text.parse::<f64>().ok().filter(|value| {
!value.is_infinite()
|| matches!(text, "Infinity" | "+Infinity" | "-Infinity")
|| text.contains(['e', 'E'])
})
};
parsed.unwrap_or(0.0) as i64
}
}
}
+35
View File
@@ -0,0 +1,35 @@
//! Extension JavaScript execution and host services, isolated from mobile UI code.
pub mod auth;
pub mod backend;
mod binary;
pub mod crypto;
mod crypto_host;
mod download;
mod download_host;
pub mod environment;
pub mod ffmpeg;
mod ffmpeg_host;
mod file_host;
mod file_transform;
pub mod files;
mod host;
mod legacy_host;
pub mod logging;
pub mod lyrics;
pub mod manager;
pub mod manifest;
mod network_host;
pub mod package;
mod provider;
mod redact;
pub mod repository;
mod resolution;
mod runtime;
mod session_host;
pub mod signed_session;
pub mod storage;
pub mod transfer_policy;
mod utility_host;
pub use runtime::{ExtensionError, ExtensionRuntime, ExtensionServices, RuntimeLimits};
@@ -0,0 +1,220 @@
//! Process-style diagnostic buffer scoped to the native backend environment.
use crate::host::decode_go_utf8;
use crate::redact::sanitize_bytes;
use serde::Serialize;
use std::collections::VecDeque;
use std::sync::Mutex;
const CAPACITY: usize = 500;
const MAX_MESSAGE: usize = 4000;
#[derive(Clone, Debug, Serialize)]
pub struct LogEntry {
pub timestamp: String,
pub level: String,
pub tag: String,
pub message: String,
}
#[derive(Default)]
struct State {
entries: VecDeque<LogEntry>,
next_index: i64,
enabled: bool,
closed: bool,
}
#[derive(Default)]
pub struct LogBuffer(Mutex<State>);
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
#[error("log buffer closed")]
pub struct LogClosed;
impl State {
fn check(&self) -> Result<(), LogClosed> {
if self.closed { Err(LogClosed) } else { Ok(()) }
}
}
impl LogBuffer {
pub fn set_enabled(&self, enabled: bool) -> Result<(), LogClosed> {
let mut state = self.0.lock().expect("log buffer lock");
state.check()?;
state.enabled = enabled;
Ok(())
}
pub fn is_enabled(&self) -> Result<bool, LogClosed> {
let state = self.0.lock().expect("log buffer lock");
state.check()?;
Ok(state.enabled)
}
pub fn add(&self, level: &str, tag: &str, message: &str) -> Result<(), LogClosed> {
self.add_bytes(level, tag, message.as_bytes().to_vec())
}
fn add_bytes(&self, level: &str, tag: &str, message: Vec<u8>) -> Result<(), LogClosed> {
let mut state = self.0.lock().expect("log buffer lock");
state.check()?;
if !state.enabled && level != "ERROR" && level != "FATAL" {
return Ok(());
}
let message = decode_go_utf8(&truncate(sanitize_bytes(message)));
let entry = LogEntry {
timestamp: chrono::Local::now().format("%H:%M:%S%.3f").to_string(),
level: level.into(),
tag: tag.into(),
message,
};
if state.entries.len() == CAPACITY {
state.entries.pop_front();
}
use std::io::Write;
let _ = writeln!(
std::io::stderr().lock(),
"[{}] {}",
entry.tag,
entry.message
);
state.entries.push_back(entry);
state.next_index = state.next_index.wrapping_add(1);
Ok(())
}
pub fn backend(&self, message: &str) -> Result<(), LogClosed> {
self.backend_bytes(message.as_bytes().to_vec())
}
fn backend_bytes(&self, mut message: Vec<u8>) -> Result<(), LogClosed> {
if message.last() == Some(&b'\n') {
message.pop();
}
let mut tag = "Rust".to_owned();
if message.first() == Some(&b'[')
&& let Some(end) = message
.iter()
.position(|byte| *byte == b']')
.filter(|end| *end > 1)
{
tag = decode_go_utf8(&message[1..end]);
message = trim_bytes(&message[end + 1..]);
}
let lowercase = spotiflac_core::matching::lowercase(&decode_go_utf8(&message));
let level = if lowercase.contains("error") || lowercase.contains("failed") {
"ERROR"
} else if lowercase.contains("warning") || lowercase.contains("warn") {
"WARN"
} else if lowercase.contains("success") || lowercase.contains("match found") {
"INFO"
} else if lowercase.contains("searching")
|| lowercase.contains("trying")
|| lowercase.contains("found")
{
"DEBUG"
} else {
"INFO"
};
self.add_bytes(level, &tag, message)
}
pub(crate) fn extension(&self, id: &str, level: &str, arguments: Vec<String>, total: usize) {
let mut message = Vec::new();
for (index, argument) in arguments.into_iter().take(8).enumerate() {
if index > 0 {
message.push(b' ');
}
let bytes = argument.as_bytes();
message.extend_from_slice(&bytes[..bytes.len().min(512)]);
if bytes.len() > 512 {
message.extend_from_slice(b"...[truncated]");
}
}
if total > 8 {
if !message.is_empty() {
message.push(b' ');
}
message.extend_from_slice(format!("...[{} more args]", total - 8).as_bytes());
}
let formatted = truncate(sanitize_bytes(message));
let mut line = if level.is_empty() {
format!("[Extension:{id}] ")
} else {
format!("[Extension:{id}:{level}] ")
}
.into_bytes();
line.extend(formatted);
line.push(b'\n');
let _ = self.backend_bytes(line);
}
pub fn all(&self) -> Result<String, LogClosed> {
let state = self.0.lock().expect("log buffer lock");
state.check()?;
Ok(serde_json::to_string(&state.entries).expect("log entries JSON"))
}
pub fn since(&self, index: i64) -> Result<String, LogClosed> {
let state = self.0.lock().expect("log buffer lock");
state.check()?;
let earliest = state.next_index - state.entries.len() as i64;
let skip = if index >= state.next_index {
state.entries.len()
} else {
(index.max(earliest) - earliest) as usize
};
let logs: Vec<_> = state.entries.iter().skip(skip).collect();
Ok(serde_json::json!({"logs":logs,"next_index":state.next_index}).to_string())
}
pub fn clear(&self) -> Result<(), LogClosed> {
let mut state = self.0.lock().expect("log buffer lock");
state.check()?;
state.entries.clear();
Ok(())
}
pub fn count(&self) -> Result<u64, LogClosed> {
let state = self.0.lock().expect("log buffer lock");
state.check()?;
Ok(state.entries.len() as u64)
}
pub fn shutdown(&self) {
let mut state = self.0.lock().expect("log buffer lock");
state.closed = true;
state.entries.clear();
}
}
impl Drop for LogBuffer {
fn drop(&mut self) {
self.shutdown();
}
}
fn truncate(mut message: Vec<u8>) -> Vec<u8> {
if message.len() > MAX_MESSAGE {
message.truncate(MAX_MESSAGE);
if let Err(error) = std::str::from_utf8(&message) {
message.truncate(error.valid_up_to());
}
message.extend_from_slice(b"...[truncated]");
}
message
}
fn trim_bytes(bytes: &[u8]) -> Vec<u8> {
// Invalid bytes are non-whitespace in Go and are retained until JSON export.
let decoded = decode_go_utf8(bytes);
let left = decoded.len() - decoded.trim_start().len();
let right = decoded.len() - decoded.trim_end().len();
let end = bytes.len().saturating_sub(right);
if left >= end {
Vec::new()
} else {
bytes[left..end].to_vec()
}
}
@@ -0,0 +1,146 @@
//! Lyrics service adapter for installed providers and the shared built-in clients.
use crate::manager::ExtensionManager;
use serde_json::json;
use spotiflac_core::cancellation::{CancellationDomain, CancellationRegistry};
use spotiflac_core::lyrics::LyricsResponse;
use spotiflac_core::matching::lowercase;
use spotiflac_providers::lyrics::{
CallGraph, Check, LyricsError, LyricsFetcher, SearchRequest, builtin::BuiltinLyricsClient,
};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Weak, mpsc};
use std::time::Duration;
/// The native owner retains the manager and service separately. A weak manager
/// reference allows that owner to keep this adapter without a reference cycle.
/// Lyrics flights register their wait on the manager VM before queueing work.
/// A host call back into the service can then reject recursive dependencies.
pub struct InstalledLyricsFetcher {
manager: Weak<ExtensionManager>,
closed: Arc<AtomicBool>,
builtin: BuiltinLyricsClient,
calls: CallGraph,
}
impl InstalledLyricsFetcher {
pub fn new(manager: &Arc<ExtensionManager>, builtin: BuiltinLyricsClient) -> Self {
Self {
manager: Arc::downgrade(manager),
closed: manager.environment().closed_flag(),
builtin,
calls: manager.environment().lyrics_calls(),
}
}
}
impl LyricsFetcher for InstalledLyricsFetcher {
fn call_graph(&self) -> Option<CallGraph> {
Some(self.calls.clone())
}
fn check(&self) -> Result<(), LyricsError> {
if self.closed.load(Ordering::Acquire) || self.manager.strong_count() == 0 {
Err(LyricsError::Cancelled("extension manager closed".into()))
} else {
Ok(())
}
}
fn extensions(&self) -> Vec<String> {
self.manager
.upgrade()
.and_then(|manager| manager.provider_ids("lyrics_provider").ok())
.unwrap_or_default()
.into_iter()
.map(|id| lowercase(id.trim()))
.collect()
}
fn fetch(
&self,
provider: &str,
request: &SearchRequest,
check: &Check<'_>,
) -> Result<LyricsResponse, LyricsError> {
check().map_err(LyricsError::Cancelled)?;
let manager = self
.manager
.upgrade()
.ok_or_else(|| LyricsError::Cancelled("extension manager closed".into()))?;
let environment = manager.environment();
let check = || {
check()?;
if environment.is_closed() {
Err("extension manager closed".into())
} else {
Ok(())
}
};
check().map_err(LyricsError::Cancelled)?;
let Some(id) = provider.strip_prefix("extension:") else {
return self.builtin.fetch(provider, request, &check);
};
let id = manager
.provider_ids("lyrics_provider")
.map_err(|error| LyricsError::Other(error.to_string()))?
.into_iter()
.find(|candidate| lowercase(candidate.trim()) == id)
.ok_or_else(|| LyricsError::Other(format!("lyrics provider unavailable: {id}")))?;
let arguments = json!([request.track, request.artist, "", request.duration]).to_string();
let _dependency = request
.caller
.as_ref()
.map(|caller| caller.wait_for(&environment.lyrics_node(&id, false)))
.transpose()
.map_err(LyricsError::Recursive)?;
let cancellation = CancellationRegistry::new(CancellationDomain::ExtensionRequest);
let lease = Arc::new(
cancellation
.acquire("")
.expect("new lyrics cancellation lease"),
);
let result = std::thread::scope(|scope| {
let (done, completion) = mpsc::channel::<()>();
let worker_lease = &lease;
let worker = std::thread::Builder::new()
.name("installed-lyrics".into())
.spawn_scoped(scope, move || {
let _done = done;
manager.provider_call(
&id,
"fetchLyrics",
&arguments,
Some(worker_lease.clone()),
30_000,
)
})
.map_err(|error| LyricsError::Other(error.to_string()))?;
let mut cancelled = None;
loop {
if let Err(message) = check() {
cancelled = Some(message);
lease.release();
break;
}
if !matches!(
completion.recv_timeout(Duration::from_millis(5)),
Err(mpsc::RecvTimeoutError::Timeout)
) {
break;
}
}
let result = worker
.join()
.map_err(|_| LyricsError::Other("installed lyrics provider panicked".into()))?;
if let Some(message) = cancelled {
return Err(LyricsError::Cancelled(message));
}
check().map_err(LyricsError::Cancelled)?;
let response = result.map_err(|error| LyricsError::Other(error.to_string()))?;
serde_json::from_str(&response).map_err(|error| LyricsError::Other(error.to_string()))
});
lease.release();
result
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,456 @@
use super::*;
use serde::Deserialize;
#[derive(Default)]
pub(super) struct Pool {
pub active: Vec<Arc<ExtensionRuntime>>,
idle: Option<Arc<ExtensionRuntime>>,
generation: u64,
settings: Option<Map<String, Value>>,
}
#[derive(Default, Deserialize)]
#[serde(default)]
pub struct ProviderDownloadRequest {
pub track_id: String,
pub quality: String,
pub output_path: String,
pub item_id: String,
pub prepared_context: Option<Map<String, Value>>,
}
struct Borrowed<'a> {
manager: &'a ExtensionManager,
entry: Arc<Installed>,
runtime: Arc<ExtensionRuntime>,
generation: u64,
healthy: bool,
}
impl Drop for Borrowed<'_> {
fn drop(&mut self) {
let mut pool = self.entry.downloads.lock().expect("download pool lock");
// Environment settings access may create an absent store. Check manager
// ownership first so a late borrower cannot resurrect uninstalled data.
let reusable = self.healthy
&& !self.runtime.is_closed()
&& self.manager.current(&self.entry)
&& self
.entry
.status
.read()
.expect("extension status lock")
.enabled;
let settings_match = reusable
&& self
.manager
.environment
.settings(&self.entry.manifest.name)
.is_ok_and(|mut settings| {
settings.retain(|key, _| !key.starts_with('_'));
pool.settings.as_ref() == Some(&settings)
});
pool.active
.retain(|runtime| !Arc::ptr_eq(runtime, &self.runtime));
if reusable && pool.generation == self.generation && pool.idle.is_none() && settings_match {
pool.idle = Some(Arc::clone(&self.runtime));
} else if !self.runtime.is_closed() {
self.manager.teardown_runtime(&self.runtime);
} else {
self.runtime.shutdown();
}
}
}
impl ExtensionManager {
pub(crate) fn download_manifest(&self, id: &str) -> Result<ExtensionManifest, ManagerError> {
let entry = self.get(id)?;
let status = entry.status.read().expect("extension status lock");
if !status.enabled || !status.error.is_empty() {
return Err(error(format!("extension '{id}' is unavailable")));
}
Ok(entry.manifest.clone())
}
pub(crate) fn preflight_download(
&self,
id: &str,
lease: Arc<RequestLease>,
) -> Result<bool, ManagerError> {
self.preflight_session(id, lease, true)
}
pub(crate) fn preflight_auth(
&self,
id: &str,
lease: Arc<RequestLease>,
) -> Result<bool, ManagerError> {
self.preflight_session(id, lease, false)
}
fn preflight_session(
&self,
id: &str,
lease: Arc<RequestLease>,
download_only: bool,
) -> Result<bool, ManagerError> {
let Ok(entry) = self.get(id) else {
self.check()?;
return Ok(false);
};
if !entry.status.read().expect("extension status lock").enabled
|| (download_only && !entry.manifest.has_type("download_provider"))
|| entry.manifest.signed_session.is_none()
{
return Ok(false);
}
let runtime = {
let mut engine = self.download_engine(&entry, Some(&lease))?;
if !self.current(&entry) || !entry.status.read().expect("extension status lock").enabled
{
return Err(error("extension is disabled or no longer installed"));
}
self.ready_with_lease(&entry, &mut engine, true, Some(lease.clone()))?
};
runtime
.preflight_signed_session(Some(lease), 30_000)
.map_err(|failure| error(failure.to_string()))
}
pub(super) fn download_engine<'a>(
&self,
entry: &'a Installed,
lease: Option<&Arc<RequestLease>>,
) -> Result<std::sync::MutexGuard<'a, Engine>, ManagerError> {
loop {
self.check()?;
if let Some(lease) = lease {
lease
.check_active()
.map_err(|failure| error(failure.to_string()))?;
}
match entry.engine.try_lock() {
Ok(engine) => return Ok(engine),
Err(std::sync::TryLockError::WouldBlock) => {
std::thread::sleep(std::time::Duration::from_millis(5));
}
Err(std::sync::TryLockError::Poisoned(_)) => {
return Err(error("extension engine lock poisoned"));
}
}
}
}
pub(super) fn invalidate_download_pool(&self, pool: &mut Pool) -> u64 {
pool.generation = pool.generation.wrapping_add(1);
pool.settings = None;
if let Some(runtime) = pool.idle.take() {
self.teardown_runtime(&runtime);
1
} else {
0
}
}
/// Memory pressure releases idle heaps. Borrowed workers may finish but
/// cannot repopulate the pool created before this request.
pub fn release_idle_download_runtimes(&self) -> Result<u64, ManagerError> {
self.check()?;
let entries: Vec<_> = self
.entries
.lock()
.expect("extension manager lock")
.values()
.cloned()
.collect();
let mut released = 0;
for entry in entries {
let mut pool = entry.downloads.lock().expect("download pool lock");
released += self.invalidate_download_pool(&mut pool);
}
Ok(released)
}
fn borrow_download(
&self,
entry: Arc<Installed>,
lease: Option<Arc<RequestLease>>,
) -> Result<Borrowed<'_>, ManagerError> {
let mut engine = self.download_engine(&entry, lease.as_ref())?;
if !self.current(&entry) || !entry.status.read().expect("extension status lock").enabled {
return Err(error("extension is disabled or no longer installed"));
}
let primary = self.ready_with_lease(&entry, &mut engine, true, lease)?;
let mut settings = self
.environment
.settings(&entry.manifest.name)
.map_err(error_from_environment)?;
settings.retain(|key, _| !key.starts_with('_'));
let mut pool = entry.downloads.lock().expect("download pool lock");
if pool.settings.as_ref() != Some(&settings) {
self.invalidate_download_pool(&mut pool);
pool.settings = Some(settings.clone());
}
let idle = pool.idle.take();
let generation = pool.generation;
// Initialization runs extension code. Pressure may invalidate this
// borrow while it initializes, without cancelling the operation.
drop(pool);
let runtime = match idle {
Some(runtime) if !runtime.is_closed() => runtime,
_ => {
let runtime = self
.environment
.load_isolated(
&entry.manifest_json,
engine.source.as_ref().expect("initialized source"),
self.limits.clone(),
&primary,
)
.map_err(error_from_environment)?;
if !settings.is_empty()
&& let Err(error) =
self.lifecycle(&runtime, "initialize", &json!([settings]).to_string())
{
self.teardown_runtime(&runtime);
return Err(error);
}
runtime
}
};
entry
.downloads
.lock()
.expect("download pool lock")
.active
.push(Arc::clone(&runtime));
drop(engine);
Ok(Borrowed {
manager: self,
entry,
runtime,
generation,
healthy: false,
})
}
/// Execute the installed provider's download contract in an isolated worker.
/// File placement, post-processing and finalization remain caller-owned.
pub fn download(
&self,
id: &str,
request: ProviderDownloadRequest,
resolution_timeout_ms: u64,
) -> Result<String, ManagerError> {
self.download_with_lease(id, request, resolution_timeout_ms, None)
}
pub(crate) fn download_with_lease(
&self,
id: &str,
request: ProviderDownloadRequest,
resolution_timeout_ms: u64,
lease: Option<Arc<RequestLease>>,
) -> Result<String, ManagerError> {
let entry = self.get(id)?;
if !entry.manifest.has_type("download_provider") {
return Err(error(format!(
"extension '{id}' is not a download provider"
)));
}
if !entry.status.read().expect("extension status lock").enabled {
return Err(error(format!("extension '{id}' is disabled")));
}
let mut borrowed = match self.borrow_download(entry, lease.clone()) {
Ok(borrowed) => borrowed,
Err(error) => return Ok(failure("init_error", &error.to_string())),
};
let allowance = if resolution_timeout_ms == 0 {
60_000
} else {
resolution_timeout_ms.min(300_000)
};
let mut options = json!({"resolutionTimeoutMs":allowance});
if let Some(context) = request
.prepared_context
.filter(|context| !context.is_empty())
{
options["preparedContext"] = context.into();
}
// This marker belongs to one VM and one invocation, never to a previous
// download that returned its worker to the idle pool.
borrowed.runtime.take_verification_url();
let response = borrowed.runtime.call_download_provider(
&json!([
request.track_id,
request.quality,
request.output_path,
options
])
.to_string(),
&request.item_id,
allowance,
lease,
);
let response = match response {
Ok(response) => response,
Err((ExtensionError::Cancelled(cause), _)) => return Err(error(cause.to_string())),
Err((ExtensionError::Timeout, resolution)) => {
return Ok(failure(
"timeout",
if resolution {
"stream resolution timeout: extension took too long to resolve an audio stream"
} else {
"download timeout: extension took too long to complete"
},
));
}
Err((error, _)) => return Ok(failure("script_error", &error.to_string())),
};
let mut response: Value = serde_json::from_str(&response)
.map_err(|error| cause("invalid download result", error))?;
if let Some(error) = response.get("parseError").and_then(Value::as_str) {
return Ok(failure("script_error", error));
}
borrowed.healthy = true;
let mut value = response["value"].take();
if value.is_null() {
return Ok(failure("not_implemented", "download returned null"));
}
normalize_decryption(&mut value);
if value["success"] != true
&& !borrowed.runtime.take_verification_url().is_empty()
&& !value["error_type"]
.as_str()
.unwrap_or("")
.eq_ignore_ascii_case("verification_required")
{
value["error_type"] = "verification_required".into();
if value["error_message"].as_str().unwrap_or("").is_empty() {
value["error_message"] = "Verification required".into();
}
}
Ok(value.to_string())
}
}
fn failure(kind: &str, message: &str) -> String {
json!({"success":false,"error_type":kind,"error_message":message}).to_string()
}
fn normalize_decryption(value: &mut Value) {
const MOV: &str = "ffmpeg.mov_key";
let legacy = value["decryption_key"]
.as_str()
.unwrap_or("")
.trim()
.to_owned();
let mut info = value
.as_object_mut()
.unwrap()
.remove("decryption")
.and_then(|info| info.as_object().cloned());
if info.is_none() && !legacy.is_empty() {
info = Some(Map::new());
}
if let Some(info) = &mut info {
for key in ["strategy", "key", "iv", "input_format", "output_extension"] {
if let Some(value) = info.get_mut(key) {
*value = value.as_str().unwrap_or("").trim().into();
}
}
let strategy = info.get("strategy").and_then(Value::as_str).unwrap_or("");
let strategy = match spotiflac_core::matching::lowercase(strategy).as_str() {
""
| MOV
| "ffmpeg_mov_key"
| "mov_decryption_key"
| "mp4_decryption_key"
| "ffmpeg.mp4_decryption_key" => MOV.to_owned(),
_ => strategy.to_owned(),
};
info.insert("strategy".into(), strategy.clone().into());
if info
.get("key")
.and_then(Value::as_str)
.unwrap_or("")
.is_empty()
&& !legacy.is_empty()
{
info.insert("key".into(), legacy.clone().into());
}
if strategy == MOV
&& info
.get("input_format")
.and_then(Value::as_str)
.unwrap_or("")
.is_empty()
{
info.insert("input_format".into(), "mov".into());
}
info.retain(|_, value| value != "");
}
if info.as_ref().is_some_and(|info| {
info.get("strategy").and_then(Value::as_str) == Some(MOV)
&& info
.get("key")
.and_then(Value::as_str)
.unwrap_or("")
.is_empty()
}) {
info = None;
}
let key = info
.as_ref()
.filter(|info| info.get("strategy").and_then(Value::as_str) == Some(MOV))
.and_then(|info| info.get("key"))
.and_then(Value::as_str)
.unwrap_or(&legacy)
.to_owned();
let value = value.as_object_mut().unwrap();
value.remove("decryption_key");
if !key.is_empty() {
value.insert("decryption_key".into(), key.into());
}
if let Some(info) = info {
value.insert("decryption".into(), info.into());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn returning_a_borrow_after_uninstall_cannot_recreate_extension_data() {
let directory = tempfile::tempdir().unwrap();
let sources = directory.path().join("sources");
let data = directory.path().join("data");
let manager = ExtensionManager::new(
&sources,
&data,
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"1",
RuntimeLimits::default(),
)
.unwrap();
let source = sources.join("example.pool");
fs::create_dir(&source).unwrap();
fs::write(source.join("manifest.json"), json!({"name":"example.pool","version":"1","description":"Generic pool ownership test","type":["download_provider"],"permissions":{"storage":true}}).to_string()).unwrap();
fs::write(
source.join("index.js"),
"registerExtension({download(){return {success:true}}});",
)
.unwrap();
manager.load_all().unwrap();
manager.set_enabled("example.pool", true).unwrap();
let mut borrowed = manager
.borrow_download(manager.get("example.pool").unwrap(), None)
.unwrap();
borrowed.healthy = true;
manager.remove("example.pool").unwrap();
assert!(!data.join("example.pool").exists());
drop(borrowed);
assert!(!data.join("example.pool").exists());
}
}
@@ -0,0 +1,453 @@
use super::*;
use serde::Deserialize;
use spotiflac_core::cancellation::RequestLease;
use spotiflac_core::matching::{lowercase, uppercase};
use std::collections::BTreeSet;
#[derive(Default, Deserialize)]
#[serde(default)]
pub struct ProviderAvailabilityRequest {
pub isrc: String,
pub track_name: String,
pub artist_name: String,
pub spotify_id: String,
pub deezer_id: String,
pub tidal_id: String,
pub qobuz_id: String,
pub duration_ms: isize,
pub item_id: String,
pub track: Option<Map<String, Value>>,
}
impl ExtensionManager {
fn item_lease(&self, id: &str) -> Result<Option<Arc<RequestLease>>, ManagerError> {
if id.is_empty() {
return Ok(None);
}
self.environment
.download_state()
.cancellation
.acquire(id)
.map(|lease| Some(Arc::new(lease)))
.map_err(|failure| error(failure.to_string()))
}
pub fn check_availability(
&self,
id: &str,
request: ProviderAvailabilityRequest,
timeout_ms: u64,
) -> Result<String, ManagerError> {
let lease = self.item_lease(&request.item_id)?;
self.check_availability_with_lease(id, request, timeout_ms, lease)
}
pub(crate) fn check_availability_with_lease(
&self,
id: &str,
request: ProviderAvailabilityRequest,
timeout_ms: u64,
lease: Option<Arc<RequestLease>>,
) -> Result<String, ManagerError> {
let mut options = json!({"spotify_id":request.spotify_id,"deezer_id":request.deezer_id,
"tidal_id":request.tidal_id,"qobuz_id":request.qobuz_id,"duration_ms":request.duration_ms});
if let Some(track) = request.track.filter(|track| !track.is_empty()) {
options["track"] = track.into();
}
self.provider_operation(
id,
"checkAvailability",
&json!([
request.isrc,
request.track_name,
request.artist_name,
options
])
.to_string(),
lease,
timeout_ms,
&request.item_id,
)
}
/// Best-effort enrichment preserves the original provider attribution.
/// Cancellation remains an error, allowing the download worker to stop.
pub fn enrich_track(
&self,
id: &str,
track_json: &str,
item_id: &str,
timeout_ms: u64,
) -> Result<String, ManagerError> {
self.enrich_track_with_lease(
id,
track_json,
item_id,
timeout_ms,
self.item_lease(item_id)?,
)
}
pub(crate) fn enrich_track_export(
&self,
id: &str,
track_json: &str,
) -> Result<String, ManagerError> {
self.check()?;
let Ok(entry) = self.get(id) else {
return Ok(track_json.into());
};
if !entry.manifest.has_type("metadata_provider") {
return Ok(track_json.into());
}
// This export unmarshals into a value struct, unlike the nullable
// provider argument used by the internal download API.
let input = if track_json.trim() == "null" {
"{}"
} else {
track_json
};
self.enrich_track(id, input, "", 30_000).map_err(|failure| {
if let Some(message) = failure.0.strip_prefix("invalid track: ") {
error(format!("failed to parse track: {message}"))
} else {
failure
}
})
}
pub(crate) fn enrich_track_with_lease(
&self,
id: &str,
track_json: &str,
item_id: &str,
timeout_ms: u64,
lease: Option<Arc<RequestLease>>,
) -> Result<String, ManagerError> {
let (original, input) = track_input(track_json)?;
let entry = self.get(id)?;
if !entry.manifest.has_type("metadata_provider")
|| !entry.status.read().expect("extension status lock").enabled
{
return Ok(original.to_string());
}
match self.provider_operation(
id,
"enrichTrack",
&json!([input]).to_string(),
lease,
timeout_ms,
item_id,
) {
Ok(response) => {
let mut track: Value = serde_json::from_str(&response)
.map_err(|failure| cause("invalid enriched track", failure))?;
track["provider_id"] = original.get("provider_id").cloned().unwrap_or(json!(""));
Ok(track.to_string())
}
Err(failure) if failure.0 == "download cancelled" => Err(failure),
Err(failure) => {
let _ = self.environment.log_buffer().backend(&format!(
"[Extension] EnrichTrack error for {id}: {failure}"
));
Ok(original.to_string())
}
}
}
pub fn search_metadata_provider(
&self,
id: &str,
query: &str,
limit: isize,
timeout_ms: u64,
) -> Result<String, ManagerError> {
let id = id.trim();
if id.is_empty() {
return Err(error("metadata provider ID is required"));
}
let entry = self.get(id)?;
if !entry.manifest.has_type("metadata_provider") {
return Err(error(format!(
"extension '{id}' is not a metadata provider"
)));
}
{
let status = entry.status.read().expect("extension status lock");
if !status.enabled {
return Err(error(format!("extension '{id}' is disabled")));
}
if !status.error.is_empty() {
return Err(error(format!(
"extension '{id}' is unavailable: {}",
status.error
)));
}
}
let limit = search_limit(limit);
let response = self.provider_call(
id,
"searchTracks",
&json!([query, limit]).to_string(),
None,
timeout_ms,
)?;
let mut response: Value =
serde_json::from_str(&response).map_err(|e| error(e.to_string()))?;
let mut tracks = response["tracks"].take();
if let Some(tracks) = tracks.as_array_mut() {
tracks.truncate(limit);
}
Ok(tracks.to_string())
}
pub fn search_metadata_providers(
&self,
query: &str,
limit: isize,
include_extensions: bool,
item_id: &str,
timeout_ms: u64,
) -> Result<String, ManagerError> {
self.search_metadata_providers_with_lease(
query,
limit,
include_extensions,
item_id,
timeout_ms,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn search_metadata_providers_with_lease(
&self,
query: &str,
limit: isize,
include_extensions: bool,
item_id: &str,
timeout_ms: u64,
request_lease: Option<Arc<RequestLease>>,
) -> Result<String, ManagerError> {
self.check()?;
let mut ordered = self
.priorities
.lock()
.expect("provider priority lock")
.metadata
.clone();
let providers: BTreeSet<_> = if include_extensions {
self.provider_ids("metadata_provider")?
.into_iter()
.collect()
} else {
BTreeSet::new()
};
let prioritized: BTreeSet<_> = ordered.iter().cloned().collect();
ordered.extend(providers.difference(&prioritized).cloned());
let limit = search_limit(limit);
let mut tracks = Vec::new();
let mut seen = BTreeSet::new();
let mut verification = None;
// Keep the same cancellation acquisition alive between providers.
let lease = if request_lease.is_some() {
request_lease
} else if ordered.is_empty() {
None
} else {
self.item_lease(item_id)?
};
for id in ordered {
if let Some(lease) = &lease {
lease.check_active().map_err(|e| error(e.to_string()))?;
}
if !providers.contains(&id) {
continue;
}
let response = self.provider_operation(
&id,
"searchTracks",
&json!([query, limit]).to_string(),
lease.clone(),
timeout_ms,
item_id,
);
if let Some(lease) = &lease {
lease.check_active().map_err(|e| error(e.to_string()))?;
}
let response = match response {
Ok(response) => response,
Err(failure) if failure.0 == "download cancelled" => return Err(failure),
Err(failure) => {
let _ = self.environment.log_buffer().backend(&format!(
"[MetadataSearch] Search error from {id}: {failure}"
));
if verification.is_none() && requires_verification(&failure.0) {
verification = Some(error(format!(
"verification_required: extension '{id}' needs verification: {failure}"
)));
}
continue;
}
};
let response: Value =
serde_json::from_str(&response).map_err(|e| error(e.to_string()))?;
if let Some(found) = response["tracks"].as_array() {
for track in found {
if seen.insert(dedup_key(track)) {
tracks.push(track.clone());
if tracks.len() >= limit {
return Ok(json!(tracks).to_string());
}
}
}
}
}
if tracks.is_empty()
&& let Some(failure) = verification
{
return Err(failure);
}
Ok(json!(tracks).to_string())
}
}
fn search_limit(limit: isize) -> usize {
if limit <= 0 { 20 } else { limit as usize }
}
fn requires_verification(message: &str) -> bool {
let message = lowercase(message);
if ["isp blocking", "try using vpn", "change dns", "cancel"]
.iter()
.any(|part| message.contains(part))
{
return false;
}
[
"verification_required",
"session is not authenticated",
"signed session is not authenticated",
"signed session expired",
]
.iter()
.any(|part| message.contains(part))
}
fn dedup_key(track: &Value) -> String {
let field = |name: &str| track[name].as_str().unwrap_or("").trim();
if !field("isrc").is_empty() {
format!("isrc:{}", uppercase(field("isrc")))
} else if !field("spotify_id").is_empty() {
format!("spotify:{}", field("spotify_id"))
} else if !field("provider_id").is_empty() && !field("id").is_empty() {
format!("{}:{}", field("provider_id"), field("id"))
} else {
format!("{}|{}", field("name"), field("artists"))
}
}
// Native inputs use the canonical Go metadata JSON schema, not the aliases
// accepted when reading extension JavaScript results. Every input field is
// present, including zero values omitted from serialized metadata results.
fn track_input(text: &str) -> Result<(Value, Value), ManagerError> {
let value: Value = serde_json::from_str(text).map_err(|e| cause("invalid track", e))?;
if value.is_null() {
return Ok((Value::Null, json!({})));
}
let Some(value) = value.as_object() else {
return Err(error("invalid track: expected object or null"));
};
let mut input = Map::new();
let mut original = Map::new();
for key in [
"id",
"name",
"artists",
"album_name",
"album_artist",
"album_id",
"album_url",
"artist_id",
"artist_url",
"external_urls",
"cover_url",
"preview_url",
"images",
"release_date",
"isrc",
"provider_id",
"item_type",
"album_type",
"tidal_id",
"qobuz_id",
"deezer_id",
"spotify_id",
"label",
"copyright",
"genre",
"composer",
"comment",
"audio_quality",
"audio_modes",
"upc",
] {
let text = match value.get(key).filter(|v| !v.is_null()) {
None => "",
Some(value) => value
.as_str()
.ok_or_else(|| error(format!("invalid track: {key} must be a string")))?,
};
input.insert(key.into(), json!(text));
if !text.is_empty() || ["id", "name", "artists", "album_name", "provider_id"].contains(&key)
{
original.insert(key.into(), json!(text));
}
}
for key in [
"duration_ms",
"track_number",
"total_tracks",
"disc_number",
"total_discs",
] {
let integer = match value.get(key).filter(|v| !v.is_null()) {
None => 0,
Some(value) => value
.as_i64()
.and_then(|value| isize::try_from(value).ok())
.ok_or_else(|| error(format!("invalid track: {key} must be a native integer")))?,
};
input.insert(key.into(), json!(integer));
if integer != 0 || key == "duration_ms" {
original.insert(key.into(), json!(integer));
}
}
let explicit = match value.get("explicit").filter(|v| !v.is_null()) {
None => false,
Some(value) => value
.as_bool()
.ok_or_else(|| error("invalid track: explicit must be a boolean"))?,
};
input.insert("explicit".into(), json!(explicit));
if explicit {
original.insert("explicit".into(), json!(true));
}
let links = value.get("external_links").cloned().unwrap_or(Value::Null);
if !links.is_null()
&& !links
.as_object()
.is_some_and(|links| links.values().all(Value::is_string))
{
return Err(error("invalid track: external_links must be a string map"));
}
if links.as_object().is_some_and(|links| !links.is_empty()) {
original.insert("external_links".into(), links.clone());
}
// Go's argument adapter copies a typed nil map to an empty JS object.
input.insert(
"external_links".into(),
if links.is_null() { json!({}) } else { links },
);
Ok((original.into(), input.into()))
}
@@ -0,0 +1,306 @@
use super::*;
use crate::files::clean;
use serde::{Deserialize, Serialize};
use spotiflac_core::matching::lowercase;
#[derive(Clone, Default, Deserialize, Serialize)]
#[serde(default)]
pub struct PostProcessInput {
#[serde(skip_serializing_if = "String::is_empty")]
pub item_id: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub path: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub uri: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub name: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub mime_type: String,
#[serde(skip_serializing_if = "is_zero")]
pub size: i64,
#[serde(skip_serializing_if = "is_false")]
pub is_saf: bool,
}
fn is_zero(value: &i64) -> bool {
*value == 0
}
fn is_false(value: &bool) -> bool {
!value
}
fn failed(message: impl std::fmt::Display) -> Value {
json!({"success":false,"error":message.to_string()})
}
impl ExtensionManager {
pub fn post_process(
&self,
id: &str,
input: PostProcessInput,
metadata: Map<String, Value>,
hook: &str,
timeout_ms: u64,
) -> Result<String, ManagerError> {
let lease = self.post_process_lease(&input)?;
self.post_process_hook(id, &input, &metadata, hook, timeout_ms, lease)
.map(|value| value.to_string())
}
fn post_process_lease(
&self,
input: &PostProcessInput,
) -> Result<Option<Arc<RequestLease>>, ManagerError> {
if input.item_id.is_empty() {
return Ok(None);
}
let lease = self
.environment
.download_state()
.acquire(&input.item_id)
.map_err(|failure| error(failure.to_string()))?;
lease
.check_active()
.map_err(|failure| error(failure.to_string()))?;
Ok(Some(Arc::new(lease)))
}
fn post_process_hook(
&self,
id: &str,
input: &PostProcessInput,
metadata: &Map<String, Value>,
hook: &str,
timeout_ms: u64,
lease: Option<Arc<RequestLease>>,
) -> Result<Value, ManagerError> {
let entry = self.get(id)?;
if !entry
.manifest
.post_processing
.as_ref()
.is_some_and(|config| config.enabled)
{
return Err(error(format!(
"extension '{id}' does not support post-processing"
)));
}
if !entry.status.read().expect("extension status lock").enabled {
return Err(error(format!("extension '{id}' is disabled")));
}
let mut engine = self.download_engine(&entry, lease.as_ref())?;
if !self.current(&entry) || !entry.status.read().expect("extension status lock").enabled {
return Ok(failed("extension is disabled or no longer installed"));
}
let runtime = match self.ready_with_lease(&entry, &mut engine, true, lease.clone()) {
Ok(runtime) => runtime,
Err(error) => {
if lease
.as_ref()
.is_some_and(|lease| lease.check_active().is_err())
{
return Err(error);
}
self.failed(&entry, &error);
return Ok(failed(error));
}
};
let result = match runtime.call_post_process(
&json!([input, metadata, hook]).to_string(),
timeout_ms,
&input.item_id,
lease,
) {
Ok(result) => result,
Err(ExtensionError::Timeout) => {
return Ok(failed(
"postProcess timeout: extension took too long to complete",
));
}
Err(error) => return Ok(failed(error)),
};
let mut response: Value =
serde_json::from_str(&result).map_err(|e| error(e.to_string()))?;
Ok(response["value"].take())
}
pub fn run_post_processing(
&self,
mut input: PostProcessInput,
metadata: Map<String, Value>,
timeout_ms: u64,
) -> Result<String, ManagerError> {
self.check()?;
let lease = self.post_process_lease(&input)?;
let check = || -> Result<(), ManagerError> {
self.check()?;
if let Some(lease) = &lease {
lease
.check_active()
.map_err(|failure| error(failure.to_string()))?;
}
Ok(())
};
// Go's map iteration has no defined provider order. Use stable IDs;
// hooks within each provider retain their manifest order.
let providers: Vec<_> = self
.entries
.lock()
.expect("extension manager lock")
.values()
.filter(|entry| {
let status = entry.status.read().expect("extension status lock");
status.enabled
&& status.error.is_empty()
&& entry
.manifest
.post_processing
.as_ref()
.is_some_and(|config| config.enabled)
})
.cloned()
.collect();
for provider in providers {
let config = provider
.manifest
.post_processing
.as_ref()
.expect("post-processing config");
for hook in &config.hooks {
check()?;
if !hook.default_enabled {
continue;
}
let mut extension = extension(&input.path);
if extension.is_empty() {
extension = self::extension(&input.name);
}
if !hook.supported_formats.is_empty()
&& !extension.is_empty()
&& !hook.supported_formats.iter().any(|format| {
format!(".{format}") == extension || format == &extension[1..]
})
{
continue;
}
let id = &provider.manifest.name;
let log = |message: String| {
let _ = self
.environment
.log_buffer()
.backend(&format!("[PostProcessV2] {message}"));
};
log(format!(
"Running hook {} from {id} on {}",
hook.id, input.path
));
let result = match self.post_process_hook(
id,
&input,
&metadata,
&hook.id,
timeout_ms,
lease.clone(),
) {
Ok(result) => result,
Err(error) => {
log(format!("Hook {} failed: {error}", hook.id));
continue;
}
};
check()?;
if result["success"] != true {
continue;
}
if let Err(error) = self.validate_post_process_result(&provider, &input, &result) {
log(format!(
"Hook {} returned an unsafe result: {error}",
hook.id
));
continue;
}
if let Some(path) = result["new_file_path"]
.as_str()
.filter(|path| !path.is_empty())
{
input.path = path.into();
if input.name.is_empty() {
let clean = clean(Path::new(path));
input.name = clean
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or("/".into());
}
}
if let Some(uri) = result["new_file_uri"]
.as_str()
.filter(|uri| !uri.is_empty())
{
input.uri = uri.into();
}
}
}
check()?;
let mut result = json!({"success":true});
if !input.path.is_empty() {
result["new_file_path"] = input.path.into();
}
if !input.uri.is_empty() {
result["new_file_uri"] = input.uri.into();
}
Ok(result.to_string())
}
fn validate_post_process_result(
&self,
entry: &Installed,
input: &PostProcessInput,
result: &Value,
) -> Result<(), ManagerError> {
let path = result["new_file_path"].as_str().unwrap_or("");
let uri = result["new_file_uri"].as_str().unwrap_or("");
if !uri.is_empty() && uri != input.uri {
return Err(error("an extension cannot replace the destination URI"));
}
if path.is_empty() || clean(Path::new(path)) == clean(Path::new(&input.path)) {
return Ok(());
}
if !entry.manifest.permissions.file {
return Err(error(
"file permission is required to replace the processed file",
));
}
if !Path::new(path).is_absolute() {
return Err(error("replacement file path must be absolute"));
}
self.environment
.validate_post_process_path(&entry.manifest.name, &input.path, path)
.map_err(error)
}
}
fn extension(path: &str) -> String {
let name = path.rsplit('/').next().unwrap_or("");
name.rfind('.')
.map(|index| lowercase(&name[index..]))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::extension;
#[test]
fn extension_uses_go_filename_rules_before_path_cleaning() {
for (path, expected) in [
("song.FLAC", ".flac"),
("album.flac/", ""),
(".hidden", ".hidden"),
("a/.", "."),
("a/..", "."),
("a\\b.FLAC", ".flac"),
("", ""),
] {
assert_eq!(extension(path), expected, "{path}");
}
}
}
@@ -0,0 +1,400 @@
use super::*;
use spotiflac_core::matching::lowercase;
use std::collections::BTreeSet;
use std::sync::TryLockError;
use std::time::Duration;
#[derive(Default)]
pub(super) struct Priorities {
download: Vec<String>,
pub(super) metadata: Vec<String>,
fallback: Option<Vec<String>>,
}
impl ExtensionManager {
pub(crate) fn metadata_revision(&self) -> u64 {
self.metadata_revision.load(Ordering::Acquire)
}
pub(crate) fn share_providers(
&self,
source_id: &str,
) -> Result<Vec<spotiflac_core::metadata::share::Provider>, ManagerError> {
self.check()?;
Ok(self
.entries
.lock()
.expect("extension manager lock")
.values()
.filter(|entry| {
let status = entry.status.read().expect("extension status lock");
entry.manifest.name != source_id
&& entry.manifest.has_type("metadata_provider")
&& status.enabled
&& status.error.is_empty()
})
.map(|entry| spotiflac_core::metadata::share::Provider {
id: entry.manifest.name.clone(),
display_name: entry.manifest.display_name.clone(),
source_dir: entry.source.to_string_lossy().into_owned(),
capabilities: json!(entry.manifest.capabilities),
})
.collect())
}
pub fn set_provider_priority(&self, kind: &str, ids: Vec<String>) -> Result<(), ManagerError> {
self.check()?;
let (provider_type, legacy): (&str, &[&str]) = match kind {
"download" => ("download_provider", &["deezer", "qobuz", "tidal"]),
"metadata" => (
"metadata_provider",
&["deezer", "spotify", "qobuz", "tidal"],
),
_ => return Err(error("unknown provider priority kind")),
};
let enabled: BTreeSet<_> = self
.provider_ids(provider_type)?
.into_iter()
.map(|id| lowercase(id.trim()))
.collect();
let mut seen = BTreeSet::new();
let ids = ids
.into_iter()
.filter_map(|id| {
let id = id.trim();
let normalized = lowercase(id);
if id.is_empty()
|| (legacy.contains(&normalized.as_str()) && !enabled.contains(&normalized))
{
return None;
}
let key = if kind == "download" {
normalized
} else {
id.to_owned()
};
seen.insert(key).then(|| id.to_owned())
})
.collect();
let mut priorities = self.priorities.lock().expect("provider priority lock");
if kind == "download" {
priorities.download = ids;
} else {
priorities.metadata = ids;
}
Ok(())
}
pub fn set_fallback_providers(&self, ids: Option<Vec<String>>) -> Result<(), ManagerError> {
self.check()?;
let mut seen = BTreeSet::new();
self.priorities
.lock()
.expect("provider priority lock")
.fallback = ids.map(|ids| {
ids.into_iter()
.filter_map(|id| {
let id = id.trim();
(!id.is_empty() && seen.insert(id.to_owned())).then(|| id.to_owned())
})
.collect()
});
Ok(())
}
pub fn provider_priorities(&self) -> Result<String, ManagerError> {
self.check()?;
let priorities = self.priorities.lock().expect("provider priority lock");
Ok(json!({"download":priorities.download,"metadata":priorities.metadata,"fallback":priorities.fallback}).to_string())
}
pub fn fallback_allowed(&self, id: &str) -> Result<bool, ManagerError> {
self.check()?;
Ok(self
.priorities
.lock()
.expect("provider priority lock")
.fallback
.as_ref()
.is_none_or(|ids| ids.iter().any(|entry| entry == id)))
}
pub fn find_url_handler(&self, url: &str) -> Result<Option<String>, ManagerError> {
self.check()?;
let mut matches: Vec<_> = self
.entries
.lock()
.expect("extension manager lock")
.values()
.filter(|entry| {
let status = entry.status.read().expect("extension status lock");
status.enabled && status.error.is_empty() && entry.manifest.matches_url(url)
})
.map(|entry| entry.manifest.name.clone())
.collect();
let priorities = self.priorities.lock().expect("provider priority lock");
let ranks: BTreeMap<_, _> = priorities
.metadata
.iter()
.enumerate()
.map(|(index, id)| (lowercase(id.trim()), index))
.collect();
matches.sort_by_key(|id| {
(
ranks.get(&lowercase(id)).copied().unwrap_or(usize::MAX),
id.clone(),
)
});
Ok(matches.into_iter().next())
}
/// Validate the application metadata wrapper before checking its resource type.
pub(crate) fn require_metadata_provider(&self, id: &str) -> Result<(), ManagerError> {
let entry = self.get(id)?;
if !entry.manifest.has_type("metadata_provider") {
return Err(error(format!(
"extension '{id}' is not a metadata provider"
)));
}
if !entry.status.read().expect("extension status lock").enabled {
return Err(error(format!("extension '{id}' is disabled")));
}
Ok(())
}
/// Typed metadata/lyrics results from installed providers. Download and
/// post-processing orchestration use their separate runtime/file lifecycles.
pub fn provider_call(
&self,
id: &str,
method: &str,
arguments: &str,
lease: Option<Arc<RequestLease>>,
timeout_ms: u64,
) -> Result<String, ManagerError> {
self.provider_operation(id, method, arguments, lease, timeout_ms, "")
}
pub(super) fn provider_operation(
&self,
id: &str,
method: &str,
arguments: &str,
lease: Option<Arc<RequestLease>>,
timeout_ms: u64,
item_id: &str,
) -> Result<String, ManagerError> {
let entry = self.get(id)?;
let manifest = &entry.manifest;
let requirement =
match method {
"getTrack" | "getAlbum" | "getPlaylist" | "getArtist" | "searchTracks"
| "enrichTrack" => (!manifest.has_type("metadata_provider"))
.then_some("is not a metadata provider"),
"checkAvailability" => (!manifest.has_type("download_provider"))
.then_some("is not a download provider"),
"fetchLyrics" => {
(!manifest.has_type("lyrics_provider")).then_some("is not a lyrics provider")
}
"customSearch" => (!manifest
.search_behavior
.as_ref()
.is_some_and(|config| config.enabled))
.then_some("does not support custom search"),
"handleUrl" => (!manifest
.url_handler
.as_ref()
.is_some_and(|config| config.enabled && !config.patterns.is_empty()))
.then_some("does not support URL handling"),
"getHomeFeed" => None,
_ => return Err(error(format!("unsupported provider method: {method}"))),
};
if let Some(requirement) = requirement {
return Err(error(format!("extension '{id}' {requirement}")));
}
if !entry.status.read().expect("extension status lock").enabled {
return Err(error(format!("extension '{id}' is disabled")));
}
let mut engine = loop {
self.check()?;
if let Some(lease) = &lease {
lease
.check_active()
.map_err(|failure| error(failure.to_string()))?;
}
match entry.engine.try_lock() {
Ok(engine) => break engine,
Err(TryLockError::WouldBlock) => std::thread::sleep(Duration::from_millis(5)),
Err(TryLockError::Poisoned(_)) => {
return Err(error("extension engine lock poisoned"));
}
}
};
if !self.current(&entry) || !entry.status.read().expect("extension status lock").enabled {
return Err(error("extension is disabled or no longer installed"));
}
let runtime = self
.ready_with_lease(&entry, &mut engine, true, lease.clone())
.inspect_err(|failure| {
if lease
.as_ref()
.is_none_or(|lease| lease.check_active().is_ok())
{
self.failed(&entry, failure);
}
})?;
let mut arguments: Vec<Value> = serde_json::from_str(arguments)
.map_err(|failure| cause("invalid provider arguments", failure))?;
if method == "customSearch" {
if arguments.len() < 2 {
arguments.resize(2, Value::Null);
}
if arguments[1].is_null() {
arguments[1] = json!({});
}
}
if method == "checkAvailability" {
runtime.take_verification_url();
}
let response = runtime
.call_provider_operation(
method,
&json!(arguments).to_string(),
lease,
timeout_ms,
item_id.into(),
)
.map_err(|failure| match failure {
ExtensionError::Timeout => error(format!(
"{method} timeout: extension took too long to respond"
)),
ExtensionError::Cancelled(cause) => error(cause.to_string()),
_ if method == "checkAvailability"
&& !runtime.take_verification_url().is_empty() =>
{
cause("checkAvailability failed", verification_error(id))
}
other => cause(&format!("{method} failed"), other),
})?;
let mut response: Value =
serde_json::from_str(&response).map_err(|e| error(e.to_string()))?;
if let Some(message) = response.get("parseError").and_then(Value::as_str) {
if method == "getHomeFeed" {
return Err(error(format!("failed to marshal result: {message}")));
}
let kind = match method {
"searchTracks" | "customSearch" => "search result",
"getAlbum" => "album",
"getPlaylist" => "playlist",
"getArtist" => "artist",
"handleUrl" => "URL handle result",
"fetchLyrics" => "lyrics result",
"checkAvailability" => "availability result",
_ => "track",
};
return Err(error(format!("failed to parse {kind}: {message}")));
}
let mut value = response["value"].take();
if method == "checkAvailability" && (value.is_null() || value["available"] != true) {
if !runtime.take_verification_url().is_empty() {
return Err(verification_error(id));
}
if value.is_null() {
return Ok(json!({"available":false,"reason":"not implemented"}).to_string());
}
}
if value.is_null() {
return if method == "customSearch" {
Ok("[]".into())
} else if method == "handleUrl" {
Err(error("handleUrl returned null - URL not recognized"))
} else {
Err(error(format!("{method} returned null")))
};
}
match method {
"getTrack" => stamp(&mut value, id),
"getAlbum" | "getPlaylist" => stamp_album(&mut value, id),
"searchTracks" => stamp_tracks(&mut value["tracks"], id),
"customSearch" => stamp_tracks(&mut value, id),
"getArtist" => {
// Go stamps releases here; albums/top tracks keep their supplied
// provider fields until URL handling performs recursive stamping.
stamp(&mut value, id);
if let Some(releases) = value.get_mut("releases").and_then(Value::as_array_mut) {
for release in releases {
stamp_album(release, id);
}
}
}
"handleUrl" => {
if let Some(track) = value.get_mut("track") {
stamp(track, id);
}
if let Some(tracks) = value.get_mut("tracks") {
stamp_tracks(tracks, id);
}
if let Some(album) = value.get_mut("album") {
stamp_album(album, id);
}
if let Some(artist) = value.get_mut("artist") {
stamp(artist, id);
for key in ["albums", "releases"] {
if let Some(albums) = artist.get_mut(key).and_then(Value::as_array_mut) {
for album in albums {
stamp_album(album, id);
}
}
}
if let Some(tracks) = artist.get_mut("top_tracks") {
stamp_tracks(tracks, id);
}
}
}
"fetchLyrics" => {
value["source"] = format!("Extension: {id}").into();
if value["provider"] == "" {
value["provider"] = manifest.display_name.clone().into();
}
if value["lines"].as_array().is_some_and(Vec::is_empty) {
let plain = value["plainLyrics"].as_str().unwrap_or("").to_owned();
value["lines"] = Value::Null;
if !plain.is_empty() && value["instrumental"] != true {
value["syncType"] = "UNSYNCED".into();
let lines: Vec<_> = plain
.split('\n')
.filter(|line| !line.trim().is_empty())
.map(|line| json!({"startTimeMs":0,"words":line,"endTimeMs":0}))
.collect();
if !lines.is_empty() {
value["lines"] = lines.into();
}
}
}
}
_ => {}
}
Ok(value.to_string())
}
}
fn verification_error(id: &str) -> ManagerError {
error(format!(
"verification_required: extension '{id}' needs signed-session verification"
))
}
fn stamp(value: &mut Value, id: &str) {
value["provider_id"] = id.into();
}
fn stamp_tracks(value: &mut Value, id: &str) {
if let Some(tracks) = value.as_array_mut() {
for track in tracks {
stamp(track, id);
}
}
}
fn stamp_album(value: &mut Value, id: &str) {
stamp(value, id);
stamp_tracks(&mut value["tracks"], id);
}
@@ -0,0 +1,582 @@
//! 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()))
})
}
}
@@ -0,0 +1,194 @@
use crate::host::decode_go_utf8;
use crate::runtime::{Control, ExtensionServices};
use base64::Engine;
use rquickjs::{Array, Ctx, Function, Object, TypedArray, Value};
use spotiflac_network::{HttpRequest, HttpResponse};
use std::collections::BTreeMap;
use std::sync::Arc;
pub(crate) fn register<'js>(
ctx: &Ctx<'js>,
host: &Object<'js>,
control: Arc<Control>,
services: &ExtensionServices,
) -> rquickjs::Result<()> {
host.set("networkEnabled", services.network.is_some())?;
let Some(session) = &services.network else {
return Ok(());
};
let validator = Arc::clone(session);
host.set(
"validateURL",
Function::new(ctx.clone(), move |url: String| {
validator.validate_url(&url).err()
})?,
)?;
let clearer = Arc::clone(session);
host.set(
"clearCookies",
Function::new(ctx.clone(), move || {
clearer.clear_cookies();
true
})?,
)?;
let session = Arc::clone(session);
host.set(
"request",
Function::new(
ctx.clone(),
move |url: String,
method: String,
body: String,
headers: Object<'js>,
default_json: bool,
user_agent: String,
fetch: bool|
-> rquickjs::Result<Object<'js>> {
let ctx = headers.ctx().clone();
let headers = headers
.props::<String, String>()
.collect::<rquickjs::Result<BTreeMap<_, _>>>()?;
let result = session.request(
HttpRequest {
url,
method,
body,
headers,
default_json,
user_agent,
},
|| control.check().map_err(|error| error.to_string()),
);
match result {
Ok(response) => response_object(&ctx, response, fetch),
Err(error) => {
let object = Object::new(ctx)?;
object.set("error", error)?;
Ok(object)
}
}
},
)?,
)?;
host.set(
"decodeBuffer",
Function::new(ctx.clone(), |bytes: TypedArray<'js, u8>| {
decode_buffer(&bytes)
})?,
)?;
host.set(
"parseJSONBuffer",
Function::new(ctx.clone(), |ctx: Ctx<'js>, bytes: TypedArray<'js, u8>| {
parse_json(&ctx, &decode_buffer(&bytes))
})?,
)?;
host.set(
"encodeBuffer",
Function::new(ctx.clone(), |bytes: TypedArray<'js, u8>| {
encode_buffer(&bytes)
})?,
)?;
Ok(())
}
fn parse_json<'js>(ctx: &Ctx<'js>, text: &str) -> rquickjs::Result<Value<'js>> {
let text = spotiflac_core::normalize_json_surrogates(text);
let bytes = text.as_bytes();
let (mut position, mut depth) = (0, 0usize);
// Go validates every float64 before overwriting duplicate object keys.
// The engine supplies syntax validation and allocates directly in its heap.
while position < bytes.len() {
match bytes[position] {
b'"' => {
position += 1;
while position < bytes.len() && bytes[position] != b'"' {
position += if bytes[position] == b'\\' { 2 } else { 1 };
}
}
b'[' | b'{' => {
depth += 1;
if depth > 10_000 {
return Err(rquickjs::Exception::throw_syntax(
ctx,
"JSON exceeds maximum depth",
));
}
}
b']' | b'}' => depth = depth.saturating_sub(1),
b'-' | b'0'..=b'9' => {
let start = position;
while position < bytes.len()
&& matches!(
bytes[position],
b'0'..=b'9' | b'-' | b'+' | b'.' | b'e' | b'E'
)
{
position += 1;
}
if !text[start..position]
.parse::<f64>()
.is_ok_and(f64::is_finite)
{
return Err(rquickjs::Exception::throw_syntax(
ctx,
"invalid JSON number",
));
}
continue;
}
_ => {}
}
position += 1;
}
ctx.json_parse(text.as_bytes())
}
fn response_object<'js>(
ctx: &Ctx<'js>,
response: HttpResponse,
fetch: bool,
) -> rquickjs::Result<Object<'js>> {
let object = Object::new(ctx.clone())?;
object.set("status", response.status)?;
object.set("ok", (200..300).contains(&response.status))?;
object.set("url", response.url)?;
let headers = Object::new(ctx.clone())?;
for (key, values) in response.headers {
if values.len() == 1 {
headers.set(key, &values[0])?;
} else {
let array = Array::new(ctx.clone())?;
for (index, value) in values.into_iter().enumerate() {
array.set(index, value)?;
}
headers.set(key, array)?;
}
}
object.set("headers", headers)?;
if fetch {
object.set("statusText", response.status_text)?;
// Copy into engine-owned memory so retained responses count against the
// VM heap limit. No base64 intermediate or per-byte JS Number array.
object.set("bytes", TypedArray::new_copy(ctx.clone(), response.body)?)?;
} else {
object.set("statusCode", response.status)?;
object.set("body", decode_go_utf8(&response.body))?;
}
Ok(object)
}
#[allow(unsafe_code)]
fn decode_buffer(bytes: &TypedArray<'_, u8>) -> String {
// SAFETY: the VM has one worker and exposes no shared-memory workers. No
// JavaScript or engine operation runs while borrowing its buffer. The owned
// Rust String is completed before returning through rquickjs into JavaScript.
let bytes = unsafe { bytes.as_bytes() }.unwrap_or_default();
decode_go_utf8(bytes)
}
#[allow(unsafe_code)]
fn encode_buffer(bytes: &TypedArray<'_, u8>) -> String {
// SAFETY: same single-worker, no-engine-call borrowing rule as decode_buffer.
let bytes = unsafe { bytes.as_bytes() }.unwrap_or_default();
base64::engine::general_purpose::STANDARD.encode(bytes)
}
@@ -0,0 +1,335 @@
//! Inspect before extraction; only a private staging directory receives files.
use crate::host::decode_go_utf8;
use crate::manifest::ExtensionManifest;
use spotiflac_core::matching::lowercase;
use std::collections::HashSet;
use std::ffi::OsString;
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::os::unix::ffi::OsStringExt;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use zip::ZipArchive;
const MAX_ENTRIES: u64 = 2048;
const MAX_EXTRACTED: u64 = 256 * 1024 * 1024;
const MAX_MANIFEST: u64 = 1024 * 1024;
const INVALID_ARCHIVE: &str =
"cannot open extension file: the file may be corrupted or not a valid extension package";
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct PackageError(pub String);
fn error(message: impl Into<String>) -> PackageError {
PackageError(message.into())
}
fn corrupt(_: impl std::fmt::Display) -> PackageError {
error(INVALID_ARCHIVE)
}
fn io_error(message: &str, cause: impl std::fmt::Display) -> PackageError {
error(format!("{message}: {cause}"))
}
pub fn is_package_path(path: &Path) -> bool {
let name = lowercase(&path.to_string_lossy());
name.ends_with(".spotiflac-ext") || name.ends_with(".sflx")
}
struct Entry {
path: PathBuf,
directory: bool,
size: u64,
}
pub struct ExtensionPackage {
archive: ZipArchive<File>,
entries: Vec<Entry>,
pub manifest: ExtensionManifest,
pub manifest_json: String,
}
impl ExtensionPackage {
pub fn open(path: &Path) -> Result<Self, PackageError> {
if !is_package_path(path) {
return Err(error(
"invalid file format: please select a .spotiflac-ext or .sflx file",
));
}
let mut input = OpenOptions::new()
.read(true)
.custom_flags(rustix::fs::OFlags::NONBLOCK.bits() as i32)
.open(path)
.map_err(corrupt)?;
if !input.metadata().map_err(corrupt)?.is_file() {
return Err(error(INVALID_ARCHIVE));
}
// zip-rs coalesces duplicate raw names. Inspect the original central
// directory first, and bound its entry count before it allocates metadata.
let names = central_names(&mut input)?;
let mut archive = ZipArchive::new(input).map_err(corrupt)?;
if archive.len() != names.len() {
return Err(error(INVALID_ARCHIVE));
}
let mut entries = Vec::with_capacity(names.len());
let mut total = 0u64;
let mut manifest_index = None;
let mut has_index = false;
for (index, name) in names.into_iter().enumerate() {
let file = archive.by_index_raw(index).map_err(corrupt)?;
let mode = file.unix_mode().unwrap_or(0) & 0o170000;
if mode == 0o120000 {
return Err(error(format!(
"unsafe path in extension archive: {}",
decode_go_utf8(&name)
)));
}
let path = clean_path(&name)?;
let directory = name.ends_with(b"/") || mode == 0o040000;
if !directory {
if file.size() > MAX_EXTRACTED - total {
return Err(error(
"extension archive exceeds the 256 MiB extracted size limit",
));
}
total += file.size();
}
if path == Path::new("manifest.json") && !directory {
manifest_index = Some(index);
}
if path == Path::new("index.js") && !directory {
has_index = true;
}
entries.push(Entry {
path,
directory,
size: file.size(),
});
}
let index = manifest_index
.ok_or_else(|| error("invalid extension package: root manifest.json not found"))?;
if !has_index {
return Err(error("invalid extension package: root index.js not found"));
}
if entries[index].size > MAX_MANIFEST {
return Err(error(
"invalid extension package: manifest.json is too large",
));
}
let mut bytes = Vec::new();
archive
.by_index(index)
.map_err(|e| io_error("failed to open manifest.json", e))?
.take(MAX_MANIFEST + 1)
.read_to_end(&mut bytes)
.map_err(|e| io_error("failed to read manifest.json", e))?;
if bytes.len() as u64 > MAX_MANIFEST {
return Err(error(
"invalid extension package: manifest.json is too large",
));
}
let manifest_json = decode_go_utf8(&bytes);
let manifest = ExtensionManifest::parse(&manifest_json)
.map_err(|e| io_error("invalid extension manifest", e))?;
Ok(Self {
archive,
entries,
manifest,
manifest_json,
})
}
pub fn extract(
&mut self,
destination: &Path,
check: &dyn Fn() -> Result<(), String>,
) -> Result<(), PackageError> {
let metadata = fs::symlink_metadata(destination)
.map_err(|e| io_error("failed to inspect staging directory", e))?;
if !metadata.is_dir() || fs::read_dir(destination).map_err(corrupt)?.next().is_some() {
return Err(error(
"extension extraction requires an empty staging directory",
));
}
let mut total = 0u64;
let mut buffer = [0u8; 64 * 1024];
for (index, entry) in self.entries.iter().enumerate() {
check().map_err(error)?;
if entry.directory {
continue;
}
let path = destination.join(&entry.path);
fs::create_dir_all(path.parent().expect("staging parent"))
.map_err(|e| io_error("failed to create extension directory", e))?;
let mut output = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&path)
.map_err(|e| io_error("failed to create extension file", e))?;
let mut input = self
.archive
.by_index(index)
.map_err(|e| io_error("failed to open file in archive", e))?;
let mut written = 0u64;
loop {
check().map_err(error)?;
let count = input
.read(&mut buffer)
.map_err(|e| io_error("failed to extract extension file", e))?;
if count == 0 {
break;
}
if count as u64 > MAX_EXTRACTED - total
|| count as u64 > entry.size.saturating_sub(written)
{
return Err(error(
"extension archive exceeds its declared extracted size",
));
}
output
.write_all(&buffer[..count])
.map_err(|e| io_error("failed to extract extension file", e))?;
total += count as u64;
written += count as u64;
}
if written != entry.size {
return Err(error("failed to extract extension file: unexpected EOF"));
}
output
.sync_all()
.map_err(|e| io_error("failed to close extracted extension file", e))?;
}
Ok(())
}
}
fn clean_path(name: &[u8]) -> Result<PathBuf, PackageError> {
let unsafe_path = || {
error(format!(
"unsafe path in extension archive: {}",
decode_go_utf8(name)
))
};
if name.starts_with(b"/") || name.contains(&b'\\') || name.contains(&0) {
return Err(unsafe_path());
}
let mut parts = Vec::new();
for part in name.split(|byte| *byte == b'/') {
match part {
b"" | b"." => {}
b".." => {
if parts.pop().is_none() {
return Err(unsafe_path());
}
}
_ => parts.push(part),
}
}
if parts.is_empty() {
return Err(unsafe_path());
}
Ok(PathBuf::from(OsString::from_vec(parts.join(&b'/'))))
}
fn number16(bytes: &[u8], offset: usize) -> u64 {
u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap()) as u64
}
fn number32(bytes: &[u8], offset: usize) -> u64 {
u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) as u64
}
fn number64(bytes: &[u8], offset: usize) -> u64 {
u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap())
}
fn read_at<const N: usize>(input: &mut File, offset: u64) -> Result<[u8; N], PackageError> {
input.seek(SeekFrom::Start(offset)).map_err(corrupt)?;
let mut bytes = [0; N];
input.read_exact(&mut bytes).map_err(corrupt)?;
Ok(bytes)
}
fn central_names(input: &mut File) -> Result<Vec<Vec<u8>>, PackageError> {
let length = input.metadata().map_err(corrupt)?.len();
let tail_size = length.min(65557) as usize;
let mut tail = vec![0; tail_size];
input
.seek(SeekFrom::Start(length - tail_size as u64))
.map_err(corrupt)?;
input.read_exact(&mut tail).map_err(corrupt)?;
let end = (0..tail.len().saturating_sub(21))
.rev()
.find(|index| {
tail[*index..].starts_with(b"PK\x05\x06")
&& *index + 22 + number16(&tail, *index + 20) as usize <= tail.len()
})
.ok_or_else(|| error(INVALID_ARCHIVE))?;
let absolute_end = length - tail_size as u64 + end as u64;
let mut count = number16(&tail, end + 10);
let mut size = number32(&tail, end + 12);
let mut offset = number32(&tail, end + 16);
let mut directory_end = absolute_end;
if absolute_end >= 20 {
let locator = read_at::<20>(input, absolute_end - 20)?;
if locator.starts_with(b"PK\x06\x07") {
let record_offset = number64(&locator, 8);
let record = read_at::<56>(input, record_offset)?;
if !record.starts_with(b"PK\x06\x06") {
return Err(error(INVALID_ARCHIVE));
}
count = number64(&record, 32);
size = number64(&record, 40);
offset = number64(&record, 48);
directory_end = record_offset;
}
}
if count > size / 46 {
return Err(error(INVALID_ARCHIVE));
}
if count > MAX_ENTRIES {
return Err(error(
"extension archive contains too many entries (maximum 2048)",
));
}
let inferred = directory_end
.checked_sub(size)
.ok_or_else(|| error(INVALID_ARCHIVE))?;
let mut position = inferred;
if offset < length && read_at::<4>(input, offset)? == *b"PK\x01\x02" {
position = offset;
}
let mut names = Vec::with_capacity(count as usize);
let mut seen = HashSet::new();
for _ in 0..count {
let header = read_at::<46>(input, position)?;
if !header.starts_with(b"PK\x01\x02") {
return Err(error(INVALID_ARCHIVE));
}
let name_length = number16(&header, 28);
let mut name = vec![0; name_length as usize];
input.read_exact(&mut name).map_err(corrupt)?;
let normalized = clean_path(&name)?;
use std::os::unix::ffi::OsStrExt;
let key = lowercase(&decode_go_utf8(normalized.as_os_str().as_bytes()));
if !seen.insert(key) {
return Err(error(format!(
"duplicate path in extension archive: {}",
decode_go_utf8(&name)
)));
}
position = position
.checked_add(46 + name_length + number16(&header, 30) + number16(&header, 32))
.filter(|value| *value <= directory_end)
.ok_or_else(|| error(INVALID_ARCHIVE))?;
names.push(name);
}
if position
.checked_add(4)
.is_some_and(|end| end <= directory_end)
&& read_at::<4>(input, position)? == *b"PK\x01\x02"
{
return Err(error(INVALID_ARCHIVE));
}
input.rewind().map_err(corrupt)?;
Ok(names)
}
@@ -0,0 +1,723 @@
(function (host) {
"use strict";
const symbolDescription = Object.getOwnPropertyDescriptor(Symbol.prototype, "description").get;
const stringApply = Reflect.apply;
const primitiveSymbol = Symbol.toPrimitive;
const boxedStringValue = String.prototype.valueOf;
const boxedNumberValue = Number.prototype.valueOf;
const boxedBooleanValue = Boolean.prototype.valueOf;
function isPrimitive(value) {
return value === null || (typeof value !== "object" && typeof value !== "function");
}
function goString(value) {
// Goja Value.String accepts a Symbol returned by an object's coercion.
if (!isPrimitive(value)) {
const exotic = value[primitiveSymbol];
if (exotic !== undefined && exotic !== null) {
value = stringApply(exotic, value, ["string"]);
if (!isPrimitive(value)) throw new TypeError("cannot convert object to primitive value");
} else {
let converted = false;
for (const key of ["toString", "valueOf"]) {
const method = value[key];
if (typeof method !== "function") continue;
const result = stringApply(method, value, []);
if (isPrimitive(result)) { value = result; converted = true; break; }
}
if (!converted) throw new TypeError("cannot convert object to primitive value");
}
}
if (typeof value === "symbol") return (stringApply(symbolDescription, value, []) || "").toWellFormed();
return String(value).toWellFormed();
}
function goNumberPrimitive(value) {
if (isPrimitive(value)) return value;
const exotic = value[primitiveSymbol];
if (exotic !== undefined && exotic !== null) {
const result = stringApply(exotic, value, ["number"]);
if (isPrimitive(result)) return result;
} else {
for (const key of ["valueOf", "toString"]) {
const method = value[key];
if (typeof method !== "function") continue;
const result = stringApply(method, value, []);
if (isPrimitive(result)) return result;
}
}
throw new TypeError("cannot convert object to primitive value");
}
function isMap(value) {
return value !== null && typeof value === "object" && !Array.isArray(value)
&& !ArrayBuffer.isView(value) && !(value instanceof Date)
&& !(value instanceof Map) && !(value instanceof Set);
}
function formatGo(value) {
if (value === null || value === undefined) return "<nil>";
if (typeof value === "number") {
if (Number.isNaN(value)) return "NaN";
if (!Number.isFinite(value)) return value < 0 ? "-Inf" : "+Inf";
if (Object.is(value, -0)) return "-0";
}
if (Array.isArray(value) || ArrayBuffer.isView(value)) {
return "[" + Array.from(value, formatGo).join(" ") + "]";
}
if (isMap(value)) {
return "map[" + host.sortKeys(Object.keys(value))
.map(key => goString(key) + ":" + formatGo(value[key])).join(" ") + "]";
}
return goString(value);
}
const byteArrays = new WeakSet();
const responseByteArrays = new WeakMap();
function emptyBytes() {
const bytes = [];
byteArrays.add(bytes);
return bytes;
}
function exportValue(value, ancestors) {
if (value === undefined || value === null) return null;
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new TypeError("unsupported JSON number");
return value;
}
if (typeof value === "boolean") return value;
if (typeof value === "string") return value.toWellFormed();
if (typeof value !== "object") throw new TypeError("unsupported JSON value");
let stringObject;
try { stringObject = stringApply(boxedStringValue, value, []); } catch (_) {}
let primitive;
try { primitive = stringApply(boxedBooleanValue, value, []); } catch (_) {}
if (typeof primitive === "boolean") return primitive;
try { primitive = stringApply(boxedNumberValue, value, []); } catch (_) {}
if (typeof primitive === "number") return exportValue(primitive, ancestors);
if (ancestors.length >= 128 || ancestors.includes(value)) throw new TypeError("cyclic or deeply nested JSON value");
if (responseByteArrays.has(value)) return host.encodeBuffer(responseByteArrays.get(value));
if (byteArrays.has(value) || value instanceof Uint8Array) return host.encodeBytes(Array.from(value));
if (value instanceof Date) {
if (!Number.isFinite(value.getTime())) return null;
const pad = (number, width = 2) => String(number).padStart(width, "0");
const year = value.getFullYear();
if (year < 0 || year > 9999) throw new TypeError("unsupported JSON date");
const offset = -value.getTimezoneOffset();
const zone = offset === 0 ? "Z" : (offset < 0 ? "-" : "+")
+ pad(Math.trunc(Math.abs(offset) / 60)) + ":" + pad(Math.abs(offset) % 60);
const milliseconds = value.getMilliseconds();
const fraction = milliseconds ? "." + pad(milliseconds, 3).replace(/0+$/, "") : "";
return pad(year, 4) + "-" + pad(value.getMonth() + 1) + "-" + pad(value.getDate())
+ "T" + pad(value.getHours()) + ":" + pad(value.getMinutes()) + ":"
+ pad(value.getSeconds()) + fraction + zone;
}
if (value instanceof Map) throw new TypeError("unsupported JSON map");
ancestors.push(value);
let result;
if (Array.isArray(value) || ArrayBuffer.isView(value) || value instanceof Set) {
result = Array.from(value, item => exportValue(item, ancestors));
} else {
result = Object.create(null);
for (const key of Object.keys(value)) {
if (stringObject !== undefined && /^(0|[1-9][0-9]*)$/.test(key) && Number(key) < stringObject.length) continue;
result[key.toWellFormed()] = exportValue(value[key], ancestors);
}
}
ancestors.pop();
return result;
}
function serialize(value) {
// Goja exports values before encoding/json. That retains undefined object
// properties as null, encodes byte slices as base64, and ignores toJSON.
function encode(value) {
if (value === null) return "null";
if (typeof value === "string") return host.quoteJSON(value);
if (typeof value === "number") return Object.is(value, -0) ? "-0" : String(value);
if (typeof value === "boolean") return String(value);
if (Array.isArray(value)) return "[" + value.map(encode).join(",") + "]";
const fields = host.sortKeys(Object.keys(value))
.map(key => host.quoteJSON(key) + ":" + encode(value[key]));
return "{" + fields.join(",") + "}";
}
return encode(exportValue(value, []));
}
let registered = false;
globalThis.registerExtension = function (value) {
if (arguments.length) {
registered = value !== undefined;
globalThis.extension = value;
}
};
if (host.storageEnabled) {
function read(credentials, key, fallback) {
const result = JSON.parse(host.storageRead(credentials, goString(key)));
if (result.error !== undefined) return undefined;
return result.found ? result.value : fallback;
}
function write(credentials, key, value) {
try {
return JSON.parse(host.storageWrite(credentials, goString(key), serialize(value)));
} catch (error) {
return {success: false, error: goString(error)};
}
}
globalThis.storage = {
get(key, fallback) { return arguments.length ? read(false, key, fallback) : undefined; },
set(key, value) { return arguments.length >= 2 && write(false, key, value).success; },
remove(key) {
return arguments.length > 0 && JSON.parse(host.storageRemove(false, goString(key))).success;
}
};
globalThis.credentials = {
get(key, fallback) { return arguments.length ? read(true, key, fallback) : undefined; },
store(key, value) {
return arguments.length < 2 ? {success: false, error: "key and value are required"} : write(true, key, value);
},
remove(key) {
return arguments.length > 0 && JSON.parse(host.storageRemove(true, goString(key))).success;
},
has(key) {
return arguments.length > 0 && JSON.parse(host.storageRead(true, goString(key))).found === true;
}
};
}
if (host.networkEnabled) {
function responseBytes(bytes) {
// Go exposes []byte as a fresh, array-like object sharing the body.
// A sparse array proxy preserves that shape without a Number slot
// per byte; the backing Uint8Array remains charged to the VM heap.
const array = [];
array.length = bytes.length;
function index(key) {
if (typeof key !== "string" || !/^(0|[1-9][0-9]*)$/.test(key)) return -1;
const value = Number(key);
return value < bytes.length ? value : -1;
}
const proxy = new Proxy(array, {
get(target, key, receiver) {
const offset = index(key);
return offset >= 0 && offset < target.length ? bytes[offset] : Reflect.get(target, key, receiver);
},
set(target, key, value) {
const offset = index(key);
if (offset >= 0) { bytes[offset] = value; return true; }
return Reflect.set(target, key, value);
},
has(target, key) { return (index(key) >= 0 && index(key) < target.length) || Reflect.has(target, key); },
ownKeys(target) {
return Array.from({length: Math.min(target.length, bytes.length)}, (_, i) => String(i))
.concat(Reflect.ownKeys(target).filter(key => index(key) < 0));
},
getOwnPropertyDescriptor(target, key) {
const offset = index(key);
return offset >= 0 && offset < target.length
? {value: bytes[offset], writable: true, enumerable: true, configurable: true}
: Reflect.getOwnPropertyDescriptor(target, key);
}
});
byteArrays.add(proxy);
responseByteArrays.set(proxy, bytes);
return proxy;
}
function bodyString(value, exported) {
if (value === undefined || value === null) return "";
if (typeof value === "string") return goString(value);
if (isMap(value) || (Array.isArray(value) && !byteArrays.has(value))) return serialize(value);
return exported ? formatGo(value) : goString(value);
}
function requestHeaders(value) {
const headers = Object.create(null);
if (isMap(value)) {
for (const key of Object.keys(value)) headers[goString(key)] = formatGo(value[key]);
}
return headers;
}
function fetchError(error) {
return {ok: false, status: 0, statusText: "Network Error", error,
text() { return ""; }, json() { return undefined; }};
}
function dispatch(args, method, fetch, options) {
const fail = error => fetch ? fetchError(error) : {error};
if (!args.length) return fail("URL is required");
const url = goString(args[0]);
const denied = host.validateURL(url);
if (denied !== undefined && denied !== null) return fail(denied);
let body = "", headers = {};
try {
if (options && isMap(args[1])) {
const opts = args[1];
if (typeof opts.method === "string") method = goString(opts.method).toUpperCase();
body = bodyString(opts.body, true);
headers = requestHeaders(opts.headers);
} else if (!options) {
if (method === "GET" || method === "DELETE") headers = requestHeaders(args[1]);
else {
body = bodyString(args[1], false);
headers = requestHeaders(args[2]);
}
}
} catch (error) { return fail("failed to stringify body: " + goString(error)); }
const result = host.request(url, method, body, headers,
(!options && method === "POST") || body !== "",
fetch ? host.appUserAgent() : "Spotiflac-Extension/1.0", fetch);
if (result.error !== undefined) return fail(result.error);
if (!fetch) return result;
const bytes = result.bytes;
delete result.bytes;
let text;
result.text = function () {
if (text === undefined) text = host.decodeBuffer(bytes);
return text;
};
result.json = function () {
try { return host.parseJSONBuffer(bytes); }
catch (_) { return undefined; }
};
result.arrayBuffer = function () { return responseBytes(bytes); };
return result;
}
globalThis.http = {
get() { return dispatch(arguments, "GET", false, false); },
post() { return dispatch(arguments, "POST", false, false); },
put() { return dispatch(arguments, "PUT", false, false); },
delete() { return dispatch(arguments, "DELETE", false, false); },
patch() { return dispatch(arguments, "PATCH", false, false); },
request() { return dispatch(arguments, "GET", false, true); },
clearCookies() { return host.clearCookies(); }
};
globalThis.fetch = function () { return dispatch(arguments, "GET", true, true); };
if (host.sessionEnabled) {
function invokeSession(method, args) { return JSON.parse(host.sessionCall(method, JSON.stringify(args))); }
globalThis.session = {
status() { return invokeSession("status", []); },
clear() { return invokeSession("clear", []); },
completeGrant(grant) { return invokeSession("completeGrant", arguments.length ? [goString(grant)] : []); },
signedFetch(method, path, body, headers) {
if (arguments.length < 2) return invokeSession("signedFetch", []);
try { return invokeSession("signedFetch", [goString(method), goString(path), bodyString(body, false), requestHeaders(headers)]); }
catch (error) { return {ok: false, error: goString(error)}; }
}
};
}
if (host.authEnabled) {
function invokeAuth(method, args, expiresIsFloat = false) {
const result = JSON.parse(host.authCall(method, JSON.stringify(args), expiresIsFloat));
return method === "getAuthCode" && result === null ? undefined : result;
}
function goFloat(value) {
return typeof value === "number" && (!Number.isInteger(value) || Object.is(value, -0)
|| value < -9223372036854775808 || value >= 9223372036854775808);
}
function authConfig(args) {
if (!args.length) return [];
if (!isMap(args[0])) return [null];
const config = Object.create(null);
for (const key of ["authUrl", "clientId", "redirectUri", "scope", "tokenUrl", "code"]) {
if (typeof args[0][key] === "string") config[key] = goString(args[0][key]);
}
if (isMap(args[0].extraParams)) config.extraParams = requestHeaders(args[0].extraParams);
return [config];
}
globalThis.auth = {
openAuthUrl(url, callback) {
const args = [];
if (arguments.length) args.push(goString(url));
if (arguments.length > 1 && callback !== undefined) args.push(goString(callback));
return invokeAuth("openAuthUrl", args);
},
getAuthCode() { return invokeAuth("getAuthCode", []); },
setAuthCode(value) {
if (!arguments.length) return invokeAuth("setAuthCode", []);
if (typeof value === "string") return invokeAuth("setAuthCode", [goString(value)]);
const config = Object.create(null);
if (isMap(value)) {
for (const key of ["code", "access_token", "refresh_token"]) {
if (typeof value[key] === "string") config[key] = goString(value[key]);
}
if (typeof value.expires_in === "number") config.expires_in = value.expires_in;
}
const expiresIsFloat = goFloat(config.expires_in);
// JSON cannot carry NaN/Infinity. Go's duration conversion
// makes these already expired; retain that outcome.
if (expiresIsFloat && !Number.isFinite(config.expires_in)) config.expires_in = 0;
return invokeAuth("setAuthCode", [config], expiresIsFloat);
},
clearAuth() { return invokeAuth("clearAuth", []); },
isAuthenticated() { return invokeAuth("isAuthenticated", []); },
getTokens() { return invokeAuth("getTokens", []); },
generatePKCE(length) {
// Goja exports integral JS values as int64; this legacy host
// only honors float64 lengths, otherwise it defaults to 64.
const size = goFloat(length) && length >= 43 && length <= 128 ? Math.trunc(length) : 64;
return invokeAuth("generatePKCE", [size]);
},
getPKCE() { return invokeAuth("getPKCE", []); },
startOAuthWithPKCE() { return invokeAuth("startOAuthWithPKCE", authConfig(arguments)); },
exchangeCodeWithPKCE() { return invokeAuth("exchangeCodeWithPKCE", authConfig(arguments)); }
};
}
}
function binaryOptions(value) {
if (value === null || typeof value !== "object" || Array.isArray(value) || ArrayBuffer.isView(value)
|| value instanceof ArrayBuffer || value instanceof Date || value instanceof Map || value instanceof Set) return null;
const result = Object.create(null);
for (const key of Object.keys(value)) {
let item = value[key];
if (typeof item === "string") item = goString(item);
else if (responseByteArrays.has(item)) item = responseByteArrays.get(item);
else if (item instanceof Uint8ClampedArray) item = new Uint8Array(item.buffer, item.byteOffset, item.byteLength);
if (key === "segments" && Array.isArray(item)) item = Array.from(item, binaryOptions);
result[key] = item;
}
return result;
}
function binaryPayload(value) {
if (typeof value === "string") return goString(value);
if (responseByteArrays.has(value)) return responseByteArrays.get(value);
if (value instanceof ArrayBuffer) return new Uint8Array(value);
if (value instanceof Uint8ClampedArray) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
return value;
}
function blockTransform(operation, args) {
if (args.length < 2) return {success: false, error: "data and options are required"};
return host.blockTransform(operation, binaryPayload(args[0]), binaryOptions(args[1]));
}
if (host.filesEnabled) {
globalThis.ffmpeg = {
getInfo(path) {
if (!arguments.length) return {success: false, error: "file path is required"};
return host.mediaInfo(goString(path));
},
convert(input, output, options) {
if (arguments.length < 2) return {success: false, error: "input and output paths are required"};
return JSON.parse(host.mediaConvert(goString(input), goString(output), binaryOptions(options)));
}
};
if (host.rawFfmpegStub) {
globalThis.ffmpeg.execute = function() {
return {success: false, error: "raw FFmpeg execution is disabled; use ffmpeg.convert"};
};
}
function fileCall(method, args) {
const paired = ["copy", "move", "transformPatternedBlocks"].includes(method);
const write = method === "write" || method === "writeBytes";
const required = method === "transformPatternedBlocks" ? 3 : paired || write ? 2 : 1;
if (args.length < required) {
if (method === "exists") return false;
return {success: false, error: method === "transformPatternedBlocks"
? "input path, output path, and options are required"
: paired ? "source and destination paths are required"
: write ? "path and data are required" : "path is required"};
}
const value = paired || method === "write" ? goString(args[1])
: method === "writeBytes" ? binaryPayload(args[1]) : undefined;
const options = method === "readBytes" ? binaryOptions(args[1])
: method === "writeBytes" || method === "transformPatternedBlocks" ? binaryOptions(args[2]) : null;
return host.fileCall(method, goString(args[0]), value, options, args[3]);
}
globalThis.file = {};
globalThis.file.download = function(url, path, options) {
if (arguments.length < 2) return {success: false, error: "URL and output path are required"};
if (!host.networkEnabled) return {success: false, error: "network access unavailable"};
const opts = binaryOptions(options);
const headers = Object.create(null);
if (opts && isMap(opts.headers)) {
for (const key of Object.keys(opts.headers)) headers[goString(key)] = formatGo(opts.headers[key]);
}
return JSON.parse(host.downloadCall(goString(url), goString(path), opts, JSON.stringify(headers)));
};
globalThis.file.downloadSegments = function(segments, path, options) {
const failure = (error_type, error) => ({success: false, error, error_type, attempts: 0});
if (arguments.length < 2) return failure("invalid_request", "segments and output path are required");
if (!Array.isArray(segments) || !segments.length) return failure("invalid_request", "segments must be a non-empty array");
const opts = binaryOptions(options);
const common = Object.create(null);
function headersInto(target, source) {
if (source && typeof source === "object" && !Array.isArray(source)
&& !ArrayBuffer.isView(source) && !(source instanceof ArrayBuffer)
&& !(source instanceof Date) && !(source instanceof Map) && !(source instanceof Set)) {
for (const key of Object.keys(source)) target[goString(key)] = formatGo(source[key]);
}
}
if (host.networkEnabled && opts) headersInto(common, opts.headers);
const specs = [];
for (let index = 0; index < segments.length; index++) {
const item = segments[index];
const headers = Object.assign(Object.create(null), common);
let url;
if (typeof item === "string") url = item;
else if (item && typeof item === "object" && !Array.isArray(item)
&& !ArrayBuffer.isView(item) && !(item instanceof ArrayBuffer)
&& !(item instanceof Date) && !(item instanceof Map) && !(item instanceof Set)) {
url = typeof item.url === "string" ? item.url : "";
if (host.networkEnabled) headersInto(headers, item.headers);
} else return failure("invalid_request", "segment " + index + " must be a URL string or object");
specs.push({url: goString(url), headers});
}
return JSON.parse(host.downloadSegmentsCall(JSON.stringify(specs), goString(path), opts));
};
for (const method of ["exists", "delete", "read", "readBytes", "write", "writeBytes", "copy", "move", "getSize", "transformPatternedBlocks"]) {
globalThis.file[method] = function() { return fileCall(method, arguments); };
}
}
const logTypedName = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Uint8Array.prototype), Symbol.toStringTag).get;
const logArrayBufferLength = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "byteLength").get;
const logDateValue = Date.prototype.getTime;
const logMapSize = Object.getOwnPropertyDescriptor(Map.prototype, "size").get;
const logSetSize = Object.getOwnPropertyDescriptor(Set.prototype, "size").get;
const logBoxValues = [Number.prototype.valueOf, Boolean.prototype.valueOf, BigInt.prototype.valueOf, Symbol.prototype.valueOf];
const logApply = Reflect.apply;
const logSlice = String.prototype.slice;
function logValue(value) {
if (value === null || value === undefined) return "<value>";
if (byteArrays.has(value)) return "<[]uint8>";
const opaque = host.logOpaqueType(value);
if (opaque) return opaque;
const kind = typeof value;
if (kind === "bigint") return "<*big.Int>";
if (kind === "function") return "<func(goja.FunctionCall) goja.Value>";
if (kind !== "object") return goString(value);
if (Array.isArray(value)) return "<[]interface {}>";
const typedName = logApply(logTypedName, value, []);
if (typedName) {
const types = {Uint8Array:"uint8", Uint8ClampedArray:"uint8", Int8Array:"int8", Uint16Array:"uint16", Int16Array:"int16", Uint32Array:"uint32", Int32Array:"int32", Float32Array:"float32", Float64Array:"float64", BigInt64Array:"int64", BigUint64Array:"uint64"};
return "<[]" + types[typedName] + ">";
}
for (const method of logBoxValues) {
let primitive;
try { primitive = logApply(method, value, []); } catch (_) { continue; }
return typeof primitive === "bigint" ? "<*big.Int>" : goString(value);
}
for (const [method, name] of [[logArrayBufferLength,"goja.ArrayBuffer"], [logDateValue,"time.Time"], [logMapSize,"[][2]interface {}"], [logSetSize,"[]interface {}"]]) {
try { logApply(method, value, []); return "<" + name + ">"; } catch (_) {}
}
return "<map[string]interface {}>";
}
globalThis.log = {};
function logArguments(args) {
const values = [];
for (let index = 0; index < Math.min(args.length, 8); index++) {
// Enough UTF-16 units for Go's byte cut, including overflow detection.
values.push(logApply(logSlice, logValue(args[index]), [0, 513]));
}
return values;
}
for (const level of ["debug", "info", "warn", "error"]) {
globalThis.log[level] = function() {
host.extensionLog(level.toUpperCase(), logArguments(arguments), arguments.length);
};
}
if (host.managedConsole) globalThis.console = {log() { host.extensionLog("", logArguments(arguments), arguments.length); }};
globalThis.matching = {
compareStrings(first, second) {
return arguments.length < 2 ? 0 : host.compareStrings(goString(first), goString(second));
},
compareDuration(first, second, tolerance) {
return arguments.length < 2 ? false : host.compareDuration(+first, +second, tolerance === undefined ? 3000 : +tolerance);
},
normalizeString(value) { return arguments.length ? host.normalizeMatching(goString(value)) : ""; }
};
if (host.legacyBackend) {
const mapEntries = Map.prototype.entries;
const setValues = Set.prototype.values;
const mapSize = Object.getOwnPropertyDescriptor(Map.prototype, "size").get;
const setSize = Object.getOwnPropertyDescriptor(Set.prototype, "size").get;
function filenameMetadata(value) {
const seen = new WeakSet();
function visit(value, depth) {
if (typeof value === "string" || typeof value === "number") return value;
if (value === null || typeof value !== "object" || host.logOpaqueType(value)) return null;
try { return stringApply(boxedNumberValue, value, []); } catch (_) {}
try { stringApply(boxedBooleanValue, value, []); return null; } catch (_) {}
if (value instanceof Date || value instanceof ArrayBuffer
|| (ArrayBuffer.isView(value) && !(value instanceof DataView))) return null;
if (seen.has(value)) return null;
if (depth >= 128) throw new TypeError("deeply nested filename metadata");
seen.add(value);
if (value instanceof Map || value instanceof Set) {
const map = value instanceof Map;
const count = stringApply(map ? mapSize : setSize, value, []);
const iterator = stringApply(map ? mapEntries : setValues, value, []);
for (let index = 0; index < count; index++) {
const entry = iterator.next();
if (entry.done) break;
if (map) visit(entry.value[0], depth + 1);
visit(map ? entry.value[1] : entry.value, depth + 1);
}
return null;
}
if (Array.isArray(value)) {
const length = value.length;
for (let index = 0; index < length; index++) visit(value[index], depth + 1);
return null;
}
let stringObject;
try { stringObject = stringApply(boxedStringValue, value, []); } catch (_) {}
const result = depth === 0 ? Object.create(null) : null;
for (const key of Object.keys(value)) {
if (stringObject !== undefined && /^(0|[1-9][0-9]*)$/.test(key) && Number(key) < stringObject.length) continue;
// Goja exports ignored values too: preserve getter order,
// errors and shared/cyclic graph visitation without JSON.
const item = visit(value[key], depth + 1);
if (result === null) continue;
if (typeof item === "string") result[key.toWellFormed()] = item.toWellFormed();
else if (typeof item === "number") {
result[key.toWellFormed()] = Number.isFinite(item) ? item : Number.isNaN(item) ? 0 : item < 0 ? -1e300 : 1e300;
}
}
return result;
}
const result = visit(value, 0);
return isMap(result) ? result : null;
}
globalThis.gobackend = {
sanitizeFilename(value) { return arguments.length ? host.legacySanitize(goString(value)) : ""; },
buildFilename(template, metadata) {
if (arguments.length < 2) return "";
const pattern = goString(template);
const fields = filenameMetadata(metadata);
return fields === null ? "" : host.legacyFilename(pattern, serialize(fields));
},
getLocalTime() { return JSON.parse(host.legacyLocalTime()); },
getAudioQuality(path) {
return arguments.length ? JSON.parse(host.legacyQuality(goString(path))) : {error: "file path is required"};
},
getLyricsLRC(spotifyID, track, artist, path, duration) {
if (arguments.length < 3) return {error: "spotifyID, trackName, and artistName are required"};
const id = goString(spotifyID), title = goString(track), name = goString(artist);
const file = path == null ? "" : goString(path);
const value = duration == null ? 0 : goNumberPrimitive(duration);
const milliseconds = typeof value === "string" ? 0 : +value;
const text = typeof value === "string"
? (typeof duration === "string" ? value : value.trim()).toWellFormed() : null;
return JSON.parse(host.legacyLyrics(id, title, name, file, milliseconds,
text, typeof duration !== "string"));
},
checkISRCExists(directory, isrc) {
return arguments.length < 2 ? {error: "outputDir and isrc are required"}
: JSON.parse(host.legacyIsrc(false, goString(directory), goString(isrc), ""));
},
addToISRCIndex(directory, isrc, path) {
return arguments.length < 3 ? {error: "outputDir, isrc, and filePath are required"}
: JSON.parse(host.legacyIsrc(true, goString(directory), goString(isrc), goString(path)));
}
};
}
globalThis.utils = {
isDownloadCancelled() { return host.downloadCancelled(); },
isRequestCancelled() { return host.requestCancelled(); },
randomUserAgent() { return host.randomUserAgent(); },
appVersion() { return host.appVersion(); },
appUserAgent() { return host.appUserAgent(); },
setDownloadStatus(status) {
if (arguments.length && host.downloadItemActive()) host.downloadStatus(goString(status));
},
getResolutionRemainingMs() { return host.resolutionRemaining(); },
encrypt(data, key) {
return arguments.length < 2 ? {success: false, error: "plaintext and key are required"}
: host.cryptoText(false, goString(data), goString(key));
},
decrypt(data, key) {
return arguments.length < 2 ? {success: false, error: "ciphertext and key are required"}
: host.cryptoText(true, goString(data), goString(key));
},
generateKey(length) {
length = length === undefined ? 32 : Number(length);
if (!Number.isInteger(length) || length < 1 || length > 4096)
return {success: false, error: "key length must be an integer between 1 and 4096 bytes"};
return host.generateKey(length);
},
encryptBlockCipher() { return blockTransform("encrypt", arguments); },
decryptBlockCipher() { return blockTransform("decrypt", arguments); },
decryptCTRSegments() { return blockTransform("segments", arguments); },
base64Encode(value) { return arguments.length ? host.base64Encode(goString(value)) : ""; },
base64Decode(value) { return arguments.length ? host.base64Decode(goString(value), false) : ""; },
md5(value) { return arguments.length ? host.md5(goString(value)) : ""; },
sha256(value) { return arguments.length ? host.sha256(goString(value)) : ""; },
hmacSHA256(message, key) {
return arguments.length < 2 ? "" : host.hmacSHA256(goString(message), goString(key));
},
hmacSHA256Base64(message, key) {
return arguments.length < 2 ? "" : host.hmacSHA256Base64(goString(message), goString(key));
},
hmacSHA1(key, message) {
function bytes(value) {
if (typeof value === "string") return host.encode(goString(value));
if (Array.isArray(value)) return Array.from(value, item => typeof item === "number" ? item & 255 : 0);
return null;
}
key = bytes(key);
message = bytes(message);
return key === null || message === null ? emptyBytes() : host.hmacSHA1(key, message);
},
parseJSON(value) {
if (!arguments.length) return undefined;
try {
// encoding/json rejects overflowing numbers and replaces lone surrogates.
return JSON.parse(serialize(JSON.parse(String(value))));
} catch (_) { return undefined; }
},
stringifyJSON(value) {
if (!arguments.length) return "";
try { return serialize(value); } catch (_) { return ""; }
},
sleep(milliseconds) {
if (typeof milliseconds !== "number" || !(milliseconds > 0)) return true;
return host.sleep(Math.min(Math.trunc(milliseconds), 300000));
}
};
globalThis.btoa = function (value) {
return arguments.length ? host.base64Encode(goString(value)) : "";
};
globalThis.atob = function (value) {
return arguments.length ? host.base64Decode(goString(value), true) : "";
};
globalThis.TextEncoder = function () {
this.encoding = "utf-8";
this.encode = function (value) {
return arguments.length ? host.encode(goString(value)) : emptyBytes();
};
this.encodeInto = function (value, destination) {
if (arguments.length < 2) return { read: 0, written: 0 };
const length = host.encode(goString(value)).length;
// Preserve the existing Go host contract, including no destination copy.
return { read: length, written: length };
};
};
globalThis.TextDecoder = function (encoding) {
this.encoding = encoding === undefined ? "utf-8" : goString(encoding);
this.fatal = false;
this.ignoreBOM = false;
this.decode = function (value) {
if (typeof value === "string") return goString(value);
if (!Array.isArray(value) && !(value instanceof Uint8Array)) return "";
return host.decode(Array.from(value, item => typeof item === "number" ? item & 255 : 0));
};
};
return {
providerHelpers: {goString, isMap, exportValue, serialize},
registered() { return registered; },
getExtension() { return globalThis.extension; },
invokeManaged(action, method, args) {
const extension = globalThis.extension;
let callback;
if (extension !== undefined && (action ? extension : true) && typeof extension[method] === "function") {
callback = () => extension[method].apply(extension, args);
} else if (action && method === "completeGrant" && typeof session !== "undefined" && session && typeof session.completeGrant === "function") {
callback = () => session.completeGrant();
}
if (!callback) return action ? {success:false, error:"Action function not found: " + method} : {success:true, message:"no " + method + " function"};
try {
const result = callback();
if (!action) return {success:true};
if (result && typeof result.then === "function") return {success:true, pending:true, message:"Action started"};
if (result !== null && result !== undefined && typeof result === "object" && !Array.isArray(result)) {
const output = {success:true};
for (const key in result) output[key] = result[key];
return output;
}
return {success:true, result};
} catch (error) { return {success:false, error:error.toString()}; }
},
serialize
};
})
@@ -0,0 +1,352 @@
(function (host, helpers) {
"use strict";
const object = Object;
const keys = Object.keys;
const isArray = Array.isArray;
const apply = Reflect.apply;
const stringify = JSON.stringify;
const isView = ArrayBuffer.isView;
const dateType = Date;
const bufferType = ArrayBuffer;
const mapType = Map;
const setType = Set;
const mapEach = Map.prototype.forEach;
const setEach = Set.prototype.forEach;
const stringValue = String.prototype.valueOf;
const numberValue = Number.prototype.valueOf;
const booleanValue = Boolean.prototype.valueOf;
const symbolValue = Symbol.prototype.valueOf;
const {goString, exportValue, serialize} = helpers;
const empty = value => value === undefined || value === null;
const trim = value => host.providerTrim(value);
// Export happens before Go's type assertion. Traverse rejected objects too:
// their getters can change later fields. Shared/cyclic objects are visited
// once per Export, and unsupported JSON values are harmless if discarded.
function exported(value, seen) {
if (typeof value === "symbol") return goString(value);
if (empty(value) || typeof value !== "object" || host.logOpaqueType(value) !== "") return value;
let primitive;
try { primitive = apply(numberValue, value, []); } catch (_) {}
if (typeof primitive === "number") return primitive;
try { primitive = apply(booleanValue, value, []); } catch (_) {}
if (typeof primitive === "boolean") return primitive;
try { primitive = apply(symbolValue, value, []); } catch (_) {}
if (typeof primitive === "symbol") return goString(primitive);
if (value instanceof dateType || value instanceof bufferType || isView(value)) return value;
if (seen === undefined) seen = new mapType();
if (seen.has(value)) return seen.get(value);
if (isArray(value) || value instanceof setType) {
const result = [];
seen.set(value, result);
if (isArray(value)) {
const length = value.length;
for (let index = 0; index < length; index++) result.push(exported(value[index], seen));
} else apply(setEach, value, [item => result.push(exported(item, seen))]);
return result;
}
if (value instanceof mapType) {
seen.set(value, value);
apply(mapEach, value, [(item, key) => { exported(key, seen); exported(item, seen); }]);
return value;
}
let stringObject;
try { stringObject = apply(stringValue, value, []); } catch (_) {}
const result = object.create(null);
seen.set(value, result);
for (const key of keys(value)) {
if (stringObject !== undefined && /^(0|[1-9][0-9]*)$/.test(key) && Number(key) < stringObject.length) continue;
result[key] = exported(value[key], seen);
}
return result;
}
function first(value, names) {
for (const name of names) {
const field = value[name];
if (!empty(field)) return field;
}
}
function text(value, names) {
for (const name of names) {
const field = exported(value[name]);
if (typeof field === "string") return field.toWellFormed();
}
return "";
}
function int(value, names, wide = false) {
const field = first(value, names);
return empty(field) ? 0n : BigInt(host.providerInteger(+field, wide));
}
function map(value, names) {
const field = exported(first(value, names));
if (empty(field) || !helpers.isMap(field) || host.logOpaqueType(field) !== "") return null;
const result = exportValue(field, []);
return result !== null && typeof result === "object" && keys(result).length ? result : null;
}
function stringMap(value, names) {
const field = first(value, names);
if (empty(field)) return null;
const result = object.create(null);
for (const name of keys(object(field))) {
const child = field[name];
if (!empty(child)) result[name.toWellFormed()] = goString(child);
}
return keys(result).length ? result : null;
}
function strings(value, names) {
const field = exported(first(value, names));
if (!isArray(field) || host.logOpaqueType(field) !== "") return [];
const result = [];
for (const item of field) {
const string = typeof item === "string" ? item.toWellFormed() : "";
if (trim(string)) result.push(trim(string));
}
return result;
}
function fields(value, schema, required) {
value = object(value);
const result = object.create(null);
for (const [name, kind, ...aliases] of schema) {
const names = [name, ...aliases];
let field;
switch (kind) {
case "s": field = text(value, names); break;
case "i": field = int(value, names); break;
case "l": field = int(value, names, true); break;
case "b": field = Boolean(first(value, names)); break;
case "m": field = map(value, names); break;
case "M": field = stringMap(value, names); break;
case "a": field = strings(value, names); break;
default: throw Error("unknown provider field kind");
}
if (required.includes(name) || (field !== "" && field !== 0n && field !== false && field !== null && (!isArray(field) || field.length))) result[name] = field;
}
return result;
}
function array(value, parse) {
if (empty(value)) return [];
const lengthValue = object(value).length;
if (empty(lengthValue)) throw Error("value is not an array");
const length = Number(BigInt(host.providerInteger(+lengthValue, true)));
if (length <= 0) return [];
const count = Number(BigInt(host.providerInteger(length, false)));
if (count < 0) throw Error("array length exceeds native integer range");
const result = [];
for (let index = 0; index < count; index++) {
const item = value[index];
if (!empty(item)) result.push(parse(item));
}
return result;
}
const trackSchema = [
["id","s"],["name","s"],["artists","s"],["album_name","s","albumName"],
["album_artist","s","albumArtist"],["album_id","s","albumId"],["album_url","s","albumUrl"],
["artist_id","s","artistId"],["artist_url","s","artistUrl"],
["external_urls","s","externalUrls","external_url","externalUrl","url"],
["duration_ms","i","durationMs"],["cover_url","s","coverUrl"],["preview_url","s","previewUrl"],
["images","s"],["release_date","s","releaseDate"],["track_number","i","trackNumber"],
["total_tracks","i","totalTracks"],["disc_number","i","discNumber"],["total_discs","i","totalDiscs"],
["isrc","s"],["provider_id","s","providerId"],["item_type","s","itemType"],
["album_type","s","albumType"],["explicit","b","is_explicit","isExplicit"],["upc","s","barcode"],
["tidal_id","s","tidalId"],["qobuz_id","s","qobuzId"],["deezer_id","s","deezerId"],
["spotify_id","s","spotifyId"],["external_links","M","externalLinks"],["label","s"],
["copyright","s"],["genre","s"],["composer","s"],["comment","s","comments"],
["audio_quality","s","audioQuality"],["audio_modes","s","audioModes"]
];
const track = value => fields(value, trackSchema, ["id","name","artists","album_name","duration_ms","provider_id"]);
function album(value) {
const tracks = array(first(object(value), ["tracks"]), track);
const result = fields(value, [
["id","s"],["name","s"],["artists","s"],["artist_id","s","artistId"],
["cover_url","s","coverUrl","images"],["header_image","s","headerImage"],
["header_video","s","headerVideo"],["release_date","s","releaseDate"],
["total_tracks","i","totalTracks"],["album_type","s","albumType"],
["audio_traits","a","audioTraits"],["provider_id","s","providerId"]
], ["id","name","artists","total_tracks","provider_id"]);
result.tracks = tracks;
if (!trim(result.artists)) {
const explicit = tracks.find(item => trim(item.album_artist || ""));
if (explicit) result.artists = trim(explicit.album_artist);
else {
const counts = new Map();
for (const item of tracks) {
const artist = trim(item.artists);
if (artist) counts.set(artist, (counts.get(artist) || 0) + 1);
}
let count = 0;
result.artists = "";
for (const [artist, votes] of counts) if (votes > count) { result.artists = artist; count = votes; }
}
}
if (!trim(result.release_date || "")) {
const source = tracks.find(item => trim(item.release_date || ""));
if (source) result.release_date = trim(source.release_date);
else delete result.release_date;
}
if (!result.audio_traits) {
const traits = host.providerAudioTraits(tracks.map(item => item.audio_quality || ""), tracks.map(item => item.audio_modes || ""));
if (traits.length) result.audio_traits = traits;
}
return result;
}
function artist(value) {
const albums = array(first(object(value), ["albums"]), album);
const releases = array(first(object(value), ["releases"]), album);
const tracks = array(first(object(value), ["top_tracks","topTracks","tracks"]), track);
const result = fields(value, [["id","s"],["name","s"],["image_url","s","imageUrl"],
["header_image","s","headerImage"],["header_video","s","headerVideo"],
["listeners","i"],["provider_id","s","providerId"]], ["id","name","provider_id"]);
if (albums.length) result.albums = albums;
if (releases.length) result.releases = releases;
if (tracks.length) result.top_tracks = tracks;
return result;
}
function decryption(value) {
if (empty(value)) return null;
const result = fields(value, [["strategy","s"],["key","s"],["iv","s"],
["input_format","s","inputFormat"],["output_extension","s","outputExtension"],["options","m"]], []);
return keys(result).length ? result : null;
}
function download(value) {
const result = fields(value, [
["success","b"],["file_path","s","filePath","path"],["already_exists","b","alreadyExists"],
["bit_depth","i","bitDepth"],["sample_rate","i","sampleRate"],["audio_codec","s","audioCodec","codec"],
["duration_ms","i","durationMs"],["error_message","s","errorMessage","error"],["error_type","s","errorType"],
["retry_after_seconds","i","retryAfterSeconds"],["title","s"],["artist","s"],["album","s"],
["album_artist","s","albumArtist"],["track_number","i","trackNumber"],["disc_number","i","discNumber"],
["total_tracks","i","totalTracks"],["total_discs","i","totalDiscs"],["release_date","s","releaseDate"],
["cover_url","s","coverUrl"],["isrc","s"],["genre","s"],["label","s"],["copyright","s"],
["composer","s"],["comment","s","comments"],["explicit","b","is_explicit","isExplicit"],
["album_type","s","albumType"],["upc","s","barcode"],["lyrics_lrc","s","lyricsLrc"],
["decryption_key","s","decryptionKey"]
], ["success"]);
const info = decryption(first(object(value), ["decryption"]));
if (info) result.decryption = info;
object.assign(result, fields(value, [["actual_extension","s","actualExtension"],
["output_extension","s","outputExtension"],["actual_container","s","actualContainer","container"],
["requires_container_conversion","b","requiresContainerConversion"]], []));
return result;
}
function url(value) {
const result = fields(value, [["type","s"],["id","s"],["name","s"],["cover_url","s","coverUrl"],
["header_image","s","headerImage"],["header_video","s","headerVideo"]], ["type"]);
value = object(value);
for (const [name, parse] of [["track",track],["tracks",v => array(v,track)],["album",album],["artist",artist]]) {
const field = first(value, [name]);
if (!empty(field)) {
const parsed = parse(field);
if (!isArray(parsed) || parsed.length) result[name] = parsed;
}
}
return result;
}
function lyrics(value) {
const lines = array(first(object(value), ["lines"]), line => fields(line, [
["startTimeMs","l","start_time_ms"],["words","s"],["endTimeMs","l","end_time_ms"]
], ["startTimeMs","words","endTimeMs"]));
const result = fields(value, [["syncType","s","sync_type"],["instrumental","b"],
["plainLyrics","s","plain_lyrics"],["provider","s"]], ["syncType","instrumental","plainLyrics","provider"]);
result.lines = lines;
return result;
}
function search(value) {
const raw = first(object(value), ["tracks"]);
const tracks = array(empty(raw) ? value : raw, track);
const total = empty(raw) ? BigInt(tracks.length) : int(object(value), ["total"]);
return {tracks, total: total || BigInt(tracks.length)};
}
function encode(value) {
// BigInts here are native integer fields created by this parser. Opaque
// extension maps pass through the existing Go export conversion first.
if (typeof value === "bigint") return String(value);
if (isArray(value)) return "[" + value.map(encode).join(",") + "]";
if (value !== null && typeof value === "object") return "{" + keys(value).map(key => host.quoteJSON(key) + ":" + encode(value[key])).join(",") + "}";
return serialize(value);
}
const parsers = {
getTrack: track, enrichTrack: track, getAlbum: album, getPlaylist: album, getArtist: artist,
searchTracks: search, customSearch: value => array(value, track), handleUrl: url,
checkAvailability: value => fields(value, [["available","b"],["reason","s"],["track_id","s","trackId"],
["skip_fallback","b","skipFallback"],["prepared_context","m","preparedContext"]], ["available"]),
download, fetchLyrics: lyrics,
postProcessV2: value => fields(value, [["success","b"],["new_file_path","s","newFilePath"],
["new_file_uri","s","newFileUri"],["error","s"],["bit_depth","i","bitDepth"],["sample_rate","i","sampleRate"]], ["success"])
};
function invoke(method, args) {
const extension = globalThis.extension;
if (empty(extension)) return null;
let name = method;
if (method === "getPlaylist" && typeof extension.getPlaylist !== "function") name = "getAlbum";
if (method === "postProcessV2" && typeof extension.postProcessV2 !== "function") {
name = "postProcess";
args = [args[0].path || "", args[1], args[2]];
}
const callback = extension[name];
return typeof callback === "function" ? apply(callback, extension, args) : null;
}
function invokeVerified(method, args) {
if (method === "getHomeFeed") {
const extension = globalThis.extension;
if (!empty(extension) && typeof extension[method] === "function") {
return apply(extension[method], extension, args);
}
const callback = globalThis[method];
return typeof callback === "function" ? apply(callback, globalThis, args) : null;
}
try { return invoke(method, args); }
catch (error) {
const id = method === "enrichTrack" ? null : host.providerPendingVerification();
if (!empty(id) && !empty(error)) {
let message;
try {
const value = typeof error === "object" || typeof error === "function" ? error.message : error;
if (!empty(value)) message = trim(goString(value));
} catch (_) {}
if (message === "VERIFY_REQUIRED") {
throw "verification_required: extension '" + id + "' needs signed-session verification: " + goString(error);
}
}
throw error;
}
}
return {
invoke: invokeVerified,
invokeDownload(method, args) {
const extension = globalThis.extension;
if (empty(extension)) return null;
const callback = extension[method];
if (typeof callback !== "function") return null;
const progress = function (value) {
if (!arguments.length) return;
const percent = BigInt(host.providerInteger(+value, false));
host.providerProgress(Number(percent < 0n ? 0n : percent > 100n ? 100n : percent));
};
return apply(callback, extension, [args[0], args[1], args[2], progress, args[3]]);
},
invokePostProcess(method, args) {
const result = {};
try { result.value = invokeVerified(method, args); }
catch (error) { result.failure = goString(error); }
return result;
},
parsePostProcess(method, result) {
let value;
try {
value = result.failure !== undefined ? {success:false,error:result.failure}
: empty(result.value) ? {success:false,error:"postProcess returned null"}
: parsers.postProcessV2(result.value);
} catch (error) { value = {success:false,error:error.message || goString(error)}; }
return "{\"value\":" + encode(value) + "}";
},
parse(method, value) {
if (empty(value)) return "{\"value\":null}";
if (method === "getHomeFeed") {
// The Go export marshals goja.Value directly: JSON.stringify,
// including toJSON/undefined semantics, without awaiting Promises.
try { return "{\"value\":" + (stringify(value) || "null") + "}"; }
catch (error) { return serialize({parseError: error.message || goString(error)}); }
}
try { return "{\"value\":" + encode(parsers[method](value)) + "}"; }
catch (error) { return serialize({parseError: error.message || goString(error)}); }
}
};
})
@@ -0,0 +1,79 @@
//! Native numeric and album-quality rules used while reading provider objects.
pub(crate) fn integer(value: f64, wide: bool) -> String {
let integer = value as i64;
if wide {
integer.to_string()
} else {
(integer as isize).to_string()
}
}
pub(crate) fn audio_traits(qualities: Vec<String>, modes: Vec<String>) -> Vec<String> {
let mut atmos = false;
let mut hires = false;
let mut lossless = false;
for (quality, mode) in qualities.iter().zip(&modes) {
// Go uses one-rune uppercase mappings, so expanding ß to SS must not
// accidentally make an unrecognized label match LOSSLESS.
let upper = |value: &str| {
value
.chars()
.map(|character| {
let mut mapped = character.to_uppercase();
let first = mapped.next().unwrap();
if mapped.next().is_none() {
first
} else {
character
}
})
.collect::<String>()
};
let quality = upper(quality);
atmos |= upper(mode).contains("ATMOS") || quality.contains("ATMOS");
hires |= ["HI_RES", "HIRES", "MASTER", "MQA"]
.iter()
.any(|part| quality.contains(part));
lossless |= quality.contains("LOSSLESS") || quality.contains("FLAC");
let lower = spotiflac_core::matching::lowercase(&quality);
let numeric_prefix = |suffix: &str, decimal: bool| {
lower
.find(suffix)
.map(|end| {
let mut start = end;
while start > 0
&& (lower.as_bytes()[start - 1].is_ascii_digit()
|| (decimal && lower.as_bytes()[start - 1] == b'.'))
{
start -= 1;
}
&lower[start..end]
})
.unwrap_or("")
};
let depth = numeric_prefix("bit", false).parse::<isize>().unwrap_or(0);
let rate = numeric_prefix("khz", true)
.parse::<f64>()
.ok()
.filter(|value| value.is_finite())
.unwrap_or(0.0);
if depth > 0 {
if depth > 16 || rate > 48.0 {
hires = true;
} else {
lossless = true;
}
}
}
let mut traits = Vec::new();
if atmos {
traits.push("dolby_atmos".into());
}
if hires {
traits.push("hi_res_lossless".into());
} else if lossless {
traits.push("lossless".into());
}
traits
}
@@ -0,0 +1,109 @@
use regex::Regex;
use std::borrow::Cow;
use std::sync::LazyLock;
static PATTERNS: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
// Go's RE2 uses ASCII whitespace and word boundaries. Keep Unicode token
// contents inside the redacted match instead of treating them as delimiters.
vec![
(r"(?i)(?-u:\b)Authorization(?-u:\b)[\t\n\f\r ]*[:=][\t\n\f\r ]*Bearer[\t\n\f\r ]+[A-Za-z0-9._~+/\-]+=*", "Authorization: Bearer [REDACTED]"),
(r#"(?i)("?(?:access[_\t\n\f\r -]?token|refresh[_\t\n\f\r -]?token|id[_\t\n\f\r -]?token|client[_\t\n\f\r -]?secret|authorization|password|api[_\t\n\f\r -]?key|session[_\t\n\f\r -]?secret|decryption[_\t\n\f\r -]?key|cookie|set-cookie)"?)([\t\n\f\r ]*[:=][\t\n\f\r ]*)("(?:\\.|[^"\\])*"|[^\t\n\f\r ,;}\]]+)"#, "${1}${2}[REDACTED]"),
(r"(?i)([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password|code|grant|sig|signature|x-amz-signature|x-amz-credential|x-amz-security-token|awsaccesskeyid|googleaccessid|policy|key-pair-id)=)[^&\t\n\f\r ]+", "${1}[REDACTED]"),
(r"(?i)(?-u:\b)Bearer[\t\n\f\r ]+[A-Za-z0-9._~+/\-]+=*", "Bearer [REDACTED]"),
(r"(?i)(-decryption_key[\t\n\f\r ]+)[^\t\n\f\r ]+", "${1}[REDACTED]"),
]
.into_iter()
.map(|(pattern, replacement)| (Regex::new(pattern).expect("static redaction pattern"), replacement))
.collect()
});
fn sanitize_text(mut text: String) -> String {
for (pattern, replacement) in PATTERNS.iter() {
if let Cow::Owned(replaced) = pattern.replace_all(&text, *replacement) {
text = replaced;
}
}
text
}
pub(crate) fn preview(bytes: &[u8], limit: usize) -> String {
let mut text = sanitize_text(crate::host::decode_go_utf8(bytes));
if text.len() > limit {
text = crate::host::decode_go_utf8(&text.as_bytes()[..limit]) + "...[truncated]";
}
text
}
/// Log arguments can end mid-codepoint after Go's 512-byte truncation. Preserve
/// their raw bytes while matching as Go's regexp UTF-8 decoder would; converting
/// them to replacement characters first would change the later 4000-byte cut.
pub(crate) fn sanitize_bytes(bytes: Vec<u8>) -> Vec<u8> {
let mut bytes = match String::from_utf8(bytes) {
Ok(text) => return sanitize_text(text).into_bytes(),
Err(error) => error.into_bytes(),
};
for (pattern, replacement) in PATTERNS.iter() {
let (text, invalid) = decoded_positions(&bytes);
let position = |index: usize| index - 2 * invalid.partition_point(|start| *start < index);
let mut output = Vec::with_capacity(bytes.len());
let mut copied = 0;
for captures in pattern.captures_iter(&text) {
let found = captures.get(0).expect("redaction match");
let start = position(found.start());
let end = position(found.end());
output.extend_from_slice(&bytes[copied..start]);
let mut expanded = String::new();
// Retained captures are only key names/separators, never secret
// values; they cannot contain an invalid UTF-8 byte.
captures.expand(replacement, &mut expanded);
output.extend_from_slice(expanded.as_bytes());
copied = end;
}
output.extend_from_slice(&bytes[copied..]);
bytes = output;
}
bytes
}
fn decoded_positions(mut bytes: &[u8]) -> (String, Vec<usize>) {
let mut text = String::with_capacity(bytes.len());
let mut invalid = Vec::new();
while !bytes.is_empty() {
match std::str::from_utf8(bytes) {
Ok(valid) => {
text.push_str(valid);
break;
}
Err(error) => {
let prefix = error.valid_up_to();
text.push_str(std::str::from_utf8(&bytes[..prefix]).expect("valid UTF-8 prefix"));
invalid.push(text.len());
text.push('\u{fffd}');
bytes = &bytes[prefix + 1..];
}
}
}
(text, invalid)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redaction_preserves_unicode_and_invalid_bytes_around_secret_values() {
for prefix in [b"track: ".as_slice(), "曲名: ".as_bytes(), b"\xff\xe3\x81 "] {
let input = [prefix, b"https://example.test/audio?token=fixture-secret&track=7 password=fixture-password;"].concat();
let expected = [
prefix,
b"https://example.test/audio?token=[REDACTED]&track=7 password=[REDACTED];",
]
.concat();
assert_eq!(sanitize_bytes(input.clone()), expected);
assert_eq!(
preview(&input, usize::MAX),
crate::host::decode_go_utf8(&expected)
);
}
}
}
@@ -0,0 +1,549 @@
//! Native-owned registry cache and verified package downloads.
mod model;
mod package;
mod urls;
pub use model::{Registry, RepoExtension, normalize_sha256};
pub use package::{destination_path, write_verified_package};
pub use urls::{RegistryLocation, registry_location, require_https};
use serde::{Deserialize, Serialize};
use spotiflac_network::{HttpRequest, NetworkService, NetworkSession};
use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const MAX_REGISTRY_BYTES: usize = 4 << 20;
const MAX_CACHE_BYTES: u64 = 8 << 20;
const CACHE_TTL: Duration = Duration::from_secs(30 * 60);
const CACHE_FILE: &str = "store_cache.json";
const REGISTRY_TIMEOUT: Duration = Duration::from_secs(30);
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300);
#[derive(Clone, Debug, thiserror::Error)]
#[error("{0}")]
pub struct RepositoryError(pub String);
fn error(message: impl Into<String>) -> RepositoryError {
RepositoryError(message.into())
}
fn cause(context: &str, error: impl std::fmt::Display) -> RepositoryError {
RepositoryError(format!("{context}: {error}"))
}
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
#[derive(Clone, Default, Deserialize, Serialize)]
#[serde(default)]
struct Cache {
registry_url: String,
#[serde(
serialize_with = "serialize_registry",
deserialize_with = "deserialize_registry"
)]
registry: Arc<Registry>,
cache_time: i64,
etag: String,
last_modified: String,
}
fn serialize_registry<S>(registry: &Arc<Registry>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
registry.as_ref().serialize(serializer)
}
fn deserialize_registry<'de, D>(deserializer: D) -> Result<Arc<Registry>, D::Error>
where
D: serde::Deserializer<'de>,
{
Registry::deserialize(deserializer).map(Arc::new)
}
#[derive(Default)]
struct Flight {
result: Mutex<Option<Result<Arc<Registry>, RepositoryError>>>,
ready: Condvar,
}
#[derive(Default)]
struct State {
url: String,
generation: u64,
cache: Option<Arc<Cache>>,
flights: BTreeMap<u64, Arc<Flight>>,
}
pub struct ExtensionRepository {
network: Arc<NetworkService>,
cache_directory: PathBuf,
_directory_lock: File,
state: Mutex<State>,
pending: Mutex<usize>,
idle: Condvar,
closed: AtomicBool,
parent_closed: Option<Arc<AtomicBool>>,
}
impl ExtensionRepository {
/// The cache directory belongs to this native object, never to JavaScript.
pub fn with_network(
directory: &Path,
network: Arc<NetworkService>,
) -> Result<Self, RepositoryError> {
if directory.as_os_str().is_empty() {
return Err(error("repository cache directory is not configured"));
}
fs::create_dir_all(directory).map_err(|e| cause("failed to create repository cache", e))?;
let cache_directory = fs::canonicalize(directory)
.map_err(|e| cause("failed to inspect repository cache", e))?;
let lock = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.custom_flags(
(rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK).bits() as i32,
)
.open(cache_directory.join(".backend-repository.lock"))
.map_err(|e| cause("failed to lock repository cache", e))?;
if !lock
.metadata()
.map_err(|e| cause("failed to inspect repository lock", e))?
.is_file()
{
return Err(error("repository cache lock must be a regular file"));
}
lock.try_lock()
.map_err(|_| error("repository cache is already managed"))?;
let cache = (|| {
let file = OpenOptions::new()
.read(true)
.custom_flags(
(rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK).bits() as i32,
)
.open(cache_directory.join(CACHE_FILE))
.ok()?;
let metadata = file.metadata().ok()?;
if !metadata.is_file() || metadata.len() > MAX_CACHE_BYTES {
return None;
}
let mut bytes = Vec::new();
file.take(MAX_CACHE_BYTES + 1)
.read_to_end(&mut bytes)
.ok()?;
if bytes.len() as u64 > MAX_CACHE_BYTES {
return None;
}
serde_json::from_slice::<Cache>(&bytes).ok().map(Arc::new)
})();
Ok(Self {
network,
cache_directory,
_directory_lock: lock,
state: Mutex::new(State {
url: cache
.as_ref()
.map_or_else(String::new, |cache| cache.registry_url.clone()),
cache,
..State::default()
}),
pending: Mutex::new(0),
idle: Condvar::new(),
closed: AtomicBool::new(false),
parent_closed: None,
})
}
pub(crate) fn attach_environment(mut self, closed: Arc<AtomicBool>) -> Self {
self.parent_closed = Some(closed);
self
}
fn enter(&self) -> Result<Operation<'_>, RepositoryError> {
let mut pending = self.pending.lock().expect("repository operations lock");
self.check()?;
*pending += 1;
Ok(Operation(self))
}
fn check(&self) -> Result<(), RepositoryError> {
if self.closed.load(Ordering::Acquire)
|| self
.parent_closed
.as_ref()
.is_some_and(|closed| closed.load(Ordering::Acquire))
{
Err(error("extension repository closed"))
} else {
Ok(())
}
}
fn check_generation(&self, generation: u64) -> Result<(), RepositoryError> {
self.check()?;
if self.state.lock().expect("repository state lock").generation != generation {
Err(error("registry changed while operation was running"))
} else {
Ok(())
}
}
pub fn registry_url(&self) -> Result<String, RepositoryError> {
self.check()?;
Ok(self
.state
.lock()
.expect("repository state lock")
.url
.clone())
}
pub fn set_registry_url(&self, input: &str) -> Result<(), RepositoryError> {
let _operation = self.enter()?;
let url = match registry_location(input)? {
RegistryLocation::Direct(url) => url,
RegistryLocation::GitHub { owner, repository } => {
let api = format!("https://api.github.com/repos/{owner}/{repository}");
let session = self.network.native_session(Duration::from_secs(10));
let response = session.request(request(&api, BTreeMap::new()), || {
self.check().map_err(|e| e.to_string())
});
self.check()?;
let branch = response
.ok()
.filter(|response| response.status == 200)
.and_then(|response| {
serde_json::from_slice::<serde_json::Value>(&response.body).ok()
})
.and_then(|value| {
value["default_branch"]
.as_str()
.filter(|value| !value.is_empty())
.map(str::to_owned)
})
.unwrap_or_else(|| "main".into());
format!(
"https://raw.githubusercontent.com/{owner}/{repository}/{branch}/registry.json"
)
}
};
require_https(&url, "registry")?;
let mut state = self.state.lock().expect("repository state lock");
self.check()?;
if state.url != url {
state.url = url;
self.clear_locked(&mut state);
}
Ok(())
}
pub fn clear_registry_url(&self) -> Result<(), RepositoryError> {
self.check()?;
let mut state = self.state.lock().expect("repository state lock");
state.url.clear();
self.clear_locked(&mut state);
Ok(())
}
pub fn clear_cache(&self) -> Result<(), RepositoryError> {
self.check()?;
self.clear_locked(&mut self.state.lock().expect("repository state lock"));
Ok(())
}
fn clear_locked(&self, state: &mut State) {
state.generation = state.generation.wrapping_add(1);
state.cache = None;
state.flights.clear();
let _ = fs::remove_file(self.cache_directory.join(CACHE_FILE));
}
fn save_locked(&self, cache: &Cache) {
// Hold the state lock through publication: a URL change/clear cannot
// race an old snapshot back onto disk. A cache write failure is benign.
let _ = (|| -> Result<(), Box<dyn std::error::Error>> {
let bytes = serde_json::to_vec(cache)?;
if bytes.len() as u64 > MAX_CACHE_BYTES {
return Ok(());
}
let mut file = tempfile::NamedTempFile::new_in(&self.cache_directory)?;
file.write_all(&bytes)?;
file.as_file().sync_all()?;
file.persist(self.cache_directory.join(CACHE_FILE))?;
Ok(())
})();
}
pub fn fetch(&self, force: bool) -> Result<Arc<Registry>, RepositoryError> {
let _operation = self.enter()?;
let (url, generation, cached, flight, leader) = {
let mut state = self.state.lock().expect("repository state lock");
if state.url.is_empty() {
return Err(error(
"no registry URL configured. Please add a repository URL first",
));
}
require_https(&state.url, "registry")?;
if !force
&& let Some(cache) = &state.cache
&& now().saturating_sub(cache.cache_time) < CACHE_TTL.as_secs() as i64
{
return Ok(Arc::clone(&cache.registry));
}
let generation = state.generation;
let leader = !state.flights.contains_key(&generation);
let flight = state.flights.entry(generation).or_default().clone();
(
state.url.clone(),
generation,
state.cache.clone(),
flight,
leader,
)
};
if !leader {
let mut result = flight.result.lock().expect("repository refresh lock");
loop {
self.check_generation(generation)?;
if let Some(result) = &*result {
return result.clone();
}
result = flight
.ready
.wait_timeout(result, Duration::from_millis(10))
.expect("repository refresh wait")
.0;
}
}
let result = self.refresh(&url, generation, cached);
*flight.result.lock().expect("repository refresh lock") = Some(result.clone());
flight.ready.notify_all();
self.state
.lock()
.expect("repository state lock")
.flights
.remove(&generation);
result
}
fn refresh(
&self,
url: &str,
generation: u64,
cached: Option<Arc<Cache>>,
) -> Result<Arc<Registry>, RepositoryError> {
let check = || self.check_generation(generation).map_err(|e| e.to_string());
let session = self.network.native_session(REGISTRY_TIMEOUT);
let mut headers = BTreeMap::new();
if let Some(cache) = &cached {
if !cache.etag.is_empty() {
headers.insert("If-None-Match".into(), cache.etag.clone());
}
if !cache.last_modified.is_empty() {
headers.insert("If-Modified-Since".into(), cache.last_modified.clone());
}
}
let response = read_registry(&session, request(url, headers), &check);
self.check_generation(generation)?;
let (status, headers, body) = match response {
Ok(response) => response,
Err(error) if error.0.starts_with("failed to read registry:") => return Err(error),
Err(error) => {
return cached.map(|cache| Arc::clone(&cache.registry)).ok_or(error);
}
};
if status != 200 && !(status == 304 && cached.is_some()) {
return cached
.map(|cache| Arc::clone(&cache.registry))
.ok_or_else(|| error(format!("registry returned HTTP {status}")));
}
let cache = if status == 304 {
let mut cache = (*cached.expect("304 cache")).clone();
cache.cache_time = now();
cache
} else {
let registry = match Registry::parse(&body) {
Ok(registry) => registry,
Err(error) => {
return cached.map(|cache| Arc::clone(&cache.registry)).ok_or(error);
}
};
let header = |name| {
headers
.get(name)
.and_then(|values| values.first())
.map_or("", |value| value.trim())
.to_owned()
};
Cache {
registry_url: url.into(),
registry: Arc::new(registry),
cache_time: now(),
etag: header("Etag"),
last_modified: header("Last-Modified"),
}
};
let registry = Arc::clone(&cache.registry);
let mut state = self.state.lock().expect("repository state lock");
self.check()?;
if state.generation != generation {
return Err(error("registry changed while operation was running"));
}
self.save_locked(&cache);
state.cache = Some(Arc::new(cache));
Ok(registry)
}
pub fn extensions(
&self,
force: bool,
installed: &BTreeMap<String, String>,
query: &str,
category: &str,
) -> Result<String, RepositoryError> {
Ok(
serde_json::to_string(&self.fetch(force)?.responses(installed, query, category))
.expect("registry JSON"),
)
}
pub fn categories(&self) -> Result<Vec<String>, RepositoryError> {
self.check()?;
Ok(["metadata", "download", "utility", "lyrics", "integration"]
.map(str::to_owned)
.to_vec())
}
pub fn download(&self, id: &str, directory: &Path) -> Result<PathBuf, RepositoryError> {
let _operation = self.enter()?;
let generation = self.state.lock().expect("repository state lock").generation;
let registry = self.fetch(false)?;
let extension = registry
.extensions
.iter()
.find(|entry| entry.id == id)
.ok_or_else(|| error(format!("extension {id} not found in repo")))?;
require_https(extension.download_url(), "extension download")?;
let destination = destination_path(directory, id, extension.download_url())?;
let check = || self.check_generation(generation).map_err(|e| e.to_string());
let session = self.network.native_session(DOWNLOAD_TIMEOUT);
let headers = [
("Cache-Control".into(), "no-cache".into()),
("Pragma".into(), "no-cache".into()),
]
.into();
let mut stream = session
.open_stream(
request(extension.download_url(), headers),
DOWNLOAD_TIMEOUT,
DOWNLOAD_TIMEOUT,
check,
)
.map_err(|e| cause("failed to download", e))?;
if stream.response.status != 200 {
return Err(error(format!(
"download returned HTTP {}",
stream.response.status
)));
}
write_verified_package(
|buffer| stream.read(buffer, check),
&destination,
extension.raw_sha256(),
&check,
)?;
Ok(destination)
}
pub fn shutdown(&self) {
self.closed.store(true, Ordering::Release);
let pending = self.pending.lock().expect("repository operations lock");
drop(
self.idle
.wait_while(pending, |pending| *pending > 0)
.expect("repository operations wait"),
);
}
}
struct Operation<'a>(&'a ExtensionRepository);
impl Drop for Operation<'_> {
fn drop(&mut self) {
let mut pending = self.0.pending.lock().expect("repository operations lock");
*pending -= 1;
self.0.idle.notify_all();
}
}
fn request(url: &str, headers: BTreeMap<String, String>) -> HttpRequest {
HttpRequest {
url: url.into(),
method: "GET".into(),
body: String::new(),
headers,
default_json: false,
user_agent: "Go-http-client/1.1".into(),
}
}
type RegistryResponse = (u16, BTreeMap<String, Vec<String>>, Vec<u8>);
fn read_registry(
session: &NetworkSession,
request: HttpRequest,
check: &dyn Fn() -> Result<(), String>,
) -> Result<RegistryResponse, RepositoryError> {
let mut stream = session
.open_stream(request, REGISTRY_TIMEOUT, REGISTRY_TIMEOUT, check)
.map_err(|e| cause("failed to fetch registry", e))?;
let status = stream.response.status;
let mut body = Vec::new();
if status == 200 {
let mut buffer = [0; 64 * 1024];
loop {
let limit = buffer.len().min(MAX_REGISTRY_BYTES + 1 - body.len());
let count = stream
.read(&mut buffer[..limit], check)
.map_err(|e| cause("failed to read registry", e))?;
if count == 0 {
break;
}
body.extend_from_slice(&buffer[..count]);
if body.len() > MAX_REGISTRY_BYTES {
return Err(error(format!(
"registry response exceeds {MAX_REGISTRY_BYTES} bytes"
)));
}
}
}
Ok((status, stream.response.headers, body))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_registry_clone_shares_arc_and_round_trips_disk_shape() {
let bytes = br#"{"registry_url":"https://example.test/registry.json","registry":{"version":1,"updated_at":"2026-09-14","extensions":[]},"cache_time":123,"etag":"etag","last_modified":"Mon, 14 Sep 2026 00:00:00 GMT"}"#;
let cache: Cache = serde_json::from_slice(bytes).unwrap();
let clone = cache.clone();
assert!(Arc::ptr_eq(&cache.registry, &clone.registry));
assert_eq!(serde_json::to_vec(&cache).unwrap(), bytes);
}
}
@@ -0,0 +1,226 @@
use super::{RepositoryError, error};
use crate::environment::compare_versions;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Value, json};
use spotiflac_core::matching::lowercase;
use std::cmp::Ordering;
use std::collections::BTreeMap;
// Preserve Go's case-insensitive fields, duplicate-field order and null rules.
// A null scalar leaves its previous value; a null slice clears the slice.
trait NullField {
fn clear_null(&mut self) {}
}
impl NullField for String {}
impl NullField for isize {}
impl<T> NullField for Vec<T> {
fn clear_null(&mut self) {
self.clear();
}
}
#[derive(Clone, Debug, Default, Serialize)]
#[serde(transparent)]
pub struct Tags(Vec<String>);
impl<'de> Deserialize<'de> for Tags {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(Self(
Vec::<Option<String>>::deserialize(deserializer)?
.into_iter()
.map(Option::unwrap_or_default)
.collect(),
))
}
}
impl std::ops::Deref for Tags {
type Target = Vec<String>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl NullField for Tags {
fn clear_null(&mut self) {
self.0.clear();
}
}
fn field_matches(key: &str, wire: &str) -> bool {
key.chars()
.map(|value| match value {
'ſ' => 's',
'K' => 'k',
value => value.to_ascii_lowercase(),
})
.eq(wire.chars().map(|value| value.to_ascii_lowercase()))
}
macro_rules! wire_struct {
($name:ident {$($field:ident: $kind:ty => $wire:literal),* $(,)?}) => {
#[derive(Clone, Debug, Default, Serialize)]
pub struct $name {
$(#[serde(rename = $wire)] pub $field: $kind,)*
}
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 registry 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 field_matches(&key, $wire) {
match map.next_value::<Option<$kind>>()? {
Some(value) => result.$field = value,
None => result.$field.clear_null(),
}
continue;
})*
map.next_value::<serde::de::IgnoredAny>()?;
}
Ok(result)
}
}
deserializer.deserialize_any(Visitor)
}
}
};
}
wire_struct!(Registry {
version: isize => "version",
updated_at: String => "updated_at",
extensions: Vec<RepoExtension> => "extensions",
});
wire_struct!(RepoExtension {
id: String => "id",
name: String => "name",
display_name: String => "display_name",
version: String => "version",
description: String => "description",
download_url: String => "download_url",
icon_url: String => "icon_url",
category: String => "category",
tags: Tags => "tags",
downloads: isize => "downloads",
updated_at: String => "updated_at",
min_app_version: String => "min_app_version",
sha256: String => "sha256",
checksum_sha256: String => "checksum_sha256",
display_name_alt: String => "displayName",
download_url_alt: String => "downloadUrl",
icon_url_alt: String => "iconUrl",
min_app_version_alt: String => "minAppVersion",
checksum_alt: String => "checksumSha256",
});
fn first<'a>(values: &[&'a str]) -> &'a str {
values
.iter()
.copied()
.find(|value| !value.is_empty())
.unwrap_or("")
}
pub fn normalize_sha256(value: &str) -> String {
let normalized = lowercase(value.trim());
let normalized = normalized.strip_prefix("sha256:").unwrap_or(&normalized);
if normalized.len() == 64 && normalized.bytes().all(|byte| byte.is_ascii_hexdigit()) {
normalized.into()
} else {
String::new()
}
}
impl RepoExtension {
pub fn raw_sha256(&self) -> &str {
first(&[
self.sha256.trim(),
self.checksum_sha256.trim(),
self.checksum_alt.trim(),
])
}
pub fn download_url(&self) -> &str {
first(&[&self.download_url, &self.download_url_alt])
}
pub fn response(&self, installed: &BTreeMap<String, String>) -> Value {
let version = installed.get(&self.id);
let mut result = json!({
"id":self.id,"name":self.name,"version":self.version,
"display_name":first(&[&self.display_name,&self.display_name_alt,&self.name]),
"description":self.description,"download_url":self.download_url(),
"category":self.category,"downloads":self.downloads,"updated_at":self.updated_at,
"is_installed":version.is_some(),
"has_update":version.is_some_and(|installed| compare_versions(&self.version,installed)==Ordering::Greater),
});
for (key, value) in [
("icon_url", first(&[&self.icon_url, &self.icon_url_alt])),
(
"min_app_version",
first(&[&self.min_app_version, &self.min_app_version_alt]),
),
("installed_version", version.map_or("", String::as_str)),
("sha256", normalize_sha256(self.raw_sha256()).as_str()),
] {
if !value.is_empty() {
result[key] = value.into();
}
}
if !self.tags.is_empty() {
result["tags"] = json!(self.tags);
}
result
}
}
impl Registry {
pub fn parse(body: &[u8]) -> Result<Self, RepositoryError> {
let text = crate::host::decode_go_utf8(body);
let mut registry: Self = serde_json::from_str(&text).map_err(|cause| {
if text.trim_start().starts_with('<') {
error("registry URL returned a web page instead of JSON. Make sure the URL points to a registry.json file or a GitHub repository that contains one")
} else {
error(format!("failed to parse registry: {cause}"))
}
})?;
registry.extensions.retain(|entry| {
entry.raw_sha256().is_empty() || !normalize_sha256(entry.raw_sha256()).is_empty()
});
Ok(registry)
}
pub fn responses(
&self,
installed: &BTreeMap<String, String>,
query: &str,
category: &str,
) -> Vec<Value> {
let query = lowercase(query);
self.extensions
.iter()
.filter(|entry| category.is_empty() || entry.category == category)
.map(|entry| entry.response(installed))
.filter(|entry| {
query.is_empty()
|| ["name", "display_name", "description"]
.iter()
.any(|key| lowercase(entry[key].as_str().unwrap_or("")).contains(&query))
|| entry["tags"].as_array().is_some_and(|tags| {
tags.iter()
.any(|tag| lowercase(tag.as_str().unwrap_or("")).contains(&query))
})
})
.collect()
}
}
@@ -0,0 +1,102 @@
use super::{RepositoryError, cause, error, normalize_sha256};
use sha2::{Digest, Sha256};
use spotiflac_core::filename::sanitize_filename;
use spotiflac_core::matching::lowercase;
use spotiflac_network::url::UrlParts;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
const MAX_PACKAGE_BYTES: u64 = 64 << 20;
pub fn destination_path(directory: &Path, id: &str, url: &str) -> Result<PathBuf, RepositoryError> {
if id.trim().is_empty() {
return Err(error("invalid extension id"));
}
let path = UrlParts::parse(url)
.map_or_else(|| url.into(), |url| crate::host::decode_go_utf8(&url.path));
let suffix = if lowercase(&path).ends_with(".sflx") {
".sflx"
} else {
".spotiflac-ext"
};
Ok(crate::files::clean(
&directory.join(format!("{}{suffix}", sanitize_filename(id))),
))
}
/// Streams to a private sibling file and publishes only verified bytes. An
/// interrupted reader, oversized body or checksum mismatch preserves the old
/// destination. Native callers own the destination directory during this call.
pub fn write_verified_package(
mut read: impl FnMut(&mut [u8]) -> Result<usize, String>,
destination: &Path,
expected: &str,
check: &dyn Fn() -> Result<(), String>,
) -> Result<(), RepositoryError> {
check().map_err(error)?;
let directory = destination
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or(Path::new("."));
fs::create_dir_all(directory)
.map_err(|e| cause("failed to prepare extension download directory", e))?;
let name = destination
.file_name()
.unwrap_or_default()
.to_string_lossy();
let mut file = tempfile::Builder::new()
.prefix(&format!(".{name}.download-"))
.tempfile_in(directory)
.map_err(|e| cause("failed to create extension download", e))?;
let mut hasher = Sha256::new();
let mut written = 0u64;
let mut buffer = [0; 64 * 1024];
loop {
check().map_err(error)?;
let limit = buffer.len().min((MAX_PACKAGE_BYTES + 1 - written) as usize);
let count = read(&mut buffer[..limit])
.map_err(|e| cause("failed to write extension package", e))?;
if count == 0 {
break;
}
if count > limit {
return Err(error("invalid extension package reader length"));
}
written += count as u64;
if written > MAX_PACKAGE_BYTES {
return Err(error("extension package exceeds the 64 MiB size limit"));
}
file.write_all(&buffer[..count])
.map_err(|e| cause("failed to write extension package", e))?;
hasher.update(&buffer[..count]);
}
file.as_file()
.sync_all()
.map_err(|e| cause("failed to flush extension package", e))?;
let normalized = normalize_sha256(expected);
if !expected.trim().is_empty() && normalized.is_empty() {
return Err(error(
"registry contains an invalid extension SHA-256 checksum",
));
}
if !normalized.is_empty() {
let actual = format!("{:x}", hasher.finalize());
// The checksum is public, but keep the comparison independent of its
// first mismatching byte, matching the Go implementation.
if actual
.bytes()
.zip(normalized.bytes())
.fold(0, |difference, (a, b)| difference | (a ^ b))
!= 0
{
return Err(error(
"extension package integrity check failed: SHA-256 mismatch",
));
}
}
check().map_err(error)?;
file.persist(destination)
.map_err(|e| cause("failed to publish extension package", e))?;
Ok(())
}
@@ -0,0 +1,49 @@
use super::{RepositoryError, error};
use spotiflac_network::url::UrlParts;
#[derive(Debug, PartialEq, Eq)]
pub enum RegistryLocation {
Direct(String),
GitHub { owner: String, repository: String },
}
pub fn registry_location(input: &str) -> Result<RegistryLocation, RepositoryError> {
let input = input.trim();
if input.is_empty() {
return Err(error("registry URL is empty"));
}
if input.contains("raw.githubusercontent.com") {
return Ok(RegistryLocation::Direct(input.into()));
}
let Some(path) = input
.strip_prefix("https://github.com/")
.or_else(|| input.strip_prefix("http://github.com/"))
else {
return Ok(RegistryLocation::Direct(input.into()));
};
let mut parts = path.splitn(3, '/');
let owner = parts.next().unwrap_or("");
let repository = parts.next().unwrap_or("");
if owner.is_empty() || repository.is_empty() {
return Err(error(
"invalid GitHub URL: expected github.com/<owner>/<repo>",
));
}
Ok(RegistryLocation::GitHub {
owner: owner.into(),
repository: repository.strip_suffix(".git").unwrap_or(repository).into(),
})
}
pub fn require_https(input: &str, context: &str) -> Result<(), RepositoryError> {
if input.is_empty() {
return Err(error(format!("{context} URL is empty")));
}
let url = UrlParts::parse(input)
.filter(|url| !url.hostname.is_empty())
.ok_or_else(|| error(format!("{context} URL is invalid: {input}")))?;
if url.scheme != "https" {
return Err(error(format!("{context} URL must use https: {input}")));
}
Ok(())
}
@@ -0,0 +1,114 @@
//! Resolver allowance shared by a download call and its native segment workers.
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
pub(crate) struct ResolutionBudget(Mutex<State>);
struct State {
remaining: Duration,
started: Instant,
pauses: usize,
charges: usize,
}
impl State {
fn remaining(&self, now: Instant) -> Duration {
if self.pauses == 0 || self.charges > 0 {
self.remaining
.saturating_sub(now.saturating_duration_since(self.started))
} else {
self.remaining
}
}
fn settle(&mut self, now: Instant) {
self.remaining = self.remaining(now);
self.started = now;
}
}
impl ResolutionBudget {
pub fn new(allowance: Duration) -> Arc<Self> {
Arc::new(Self(Mutex::new(State {
remaining: allowance,
started: Instant::now(),
pauses: 0,
charges: 0,
})))
}
pub fn remaining(&self) -> Duration {
self.0
.lock()
.expect("resolution budget lock")
.remaining(Instant::now())
}
pub fn enter(self: &Arc<Self>, charge: bool) -> BudgetGuard {
let mut state = self.0.lock().expect("resolution budget lock");
state.settle(Instant::now());
if charge {
state.charges += 1;
} else {
state.pauses += 1;
}
BudgetGuard {
budget: Arc::clone(self),
charge,
}
}
}
pub(crate) struct BudgetGuard {
budget: Arc<ResolutionBudget>,
charge: bool,
}
impl Drop for BudgetGuard {
fn drop(&mut self) {
let mut state = self.budget.0.lock().expect("resolution budget lock");
state.settle(Instant::now());
if self.charge {
state.charges -= 1;
} else {
state.pauses -= 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn charges_override_nested_pauses_and_exhaustion_is_terminal() {
let now = Instant::now();
let mut state = State {
remaining: Duration::from_secs(60),
started: now,
pauses: 0,
charges: 0,
};
state.settle(now + Duration::from_secs(10));
state.pauses = 2;
assert_eq!(
state.remaining(now + Duration::from_secs(3600)),
Duration::from_secs(50)
);
state.settle(now + Duration::from_secs(3600));
state.charges = 1;
state.settle(now + Duration::from_secs(3610));
assert_eq!(state.remaining, Duration::from_secs(40));
state.charges = 0;
state.settle(now + Duration::from_secs(4000));
state.pauses = 0;
state.settle(now + Duration::from_secs(4041));
assert_eq!(state.remaining, Duration::ZERO);
state.pauses = 1;
assert_eq!(
state.remaining(now + Duration::from_secs(5000)),
Duration::ZERO
);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,62 @@
use crate::runtime::{Control, ExtensionServices};
use rquickjs::{Ctx, Function, Object};
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::sync::Arc;
use zeroize::Zeroizing;
pub(crate) fn register<'js>(
ctx: &Ctx<'js>,
host: &Object<'js>,
control: Arc<Control>,
services: &ExtensionServices,
) -> rquickjs::Result<()> {
host.set("sessionEnabled", services.session.is_some())?;
if let Some(session) = &services.session {
let session = Arc::clone(session);
host.set(
"sessionCall",
Function::new(ctx.clone(), move |method: String, arguments: String| {
let arguments = Zeroizing::new(arguments);
let check = || control.check().map_err(|error| error.to_string());
let result = (|| {
check()?;
let args: Vec<Value> =
serde_json::from_str(&arguments).map_err(|error| error.to_string())?;
let string = |index| args.get(index).and_then(Value::as_str).unwrap_or("");
match method.as_str() {
"status" => session.status(),
"clear" => session.clear().map(|()| json!({"success":true})),
"completeGrant" => session
.complete_grant(string(0), check)
.map(|()| json!({"success":true})),
"signedFetch" => {
if args.len() < 2 {
return Err("method and path are required".into());
}
let headers: BTreeMap<String, String> = args
.get(3)
.cloned()
.map(serde_json::from_value)
.transpose()
.map_err(|error| error.to_string())?
.unwrap_or_default();
session.signed_fetch(string(0), string(1), string(2), &headers, check)
}
_ => Err("unknown signed session method".into()),
}
})();
match result {
Ok(value) => value,
Err(error) if method == "status" => {
json!({"authenticated":false,"error":error})
}
Err(error) if method == "signedFetch" => json!({"ok":false,"error":error}),
Err(error) => json!({"success":false,"error":error}),
}
.to_string()
})?,
)?;
}
Ok(())
}
@@ -0,0 +1,10 @@
//! Gateway signed sessions shared by scope, with isolated extension permissions.
mod coordinator;
mod exchange;
mod fetch;
pub mod protocol;
mod store;
pub use coordinator::{SignedRegistry, SignedSessionClient};
pub use store::{RecordStore, RuntimeHints};
@@ -0,0 +1,319 @@
use super::{RecordStore, RuntimeHints, protocol};
use crate::auth::{AuthRegistry, PendingAuthRequest};
use crate::manifest::SignedSession;
use serde_json::{Value, json};
use spotiflac_network::{HttpRequest, HttpResponse, NetworkSession};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
use zeroize::Zeroizing;
pub(super) type Check<'a> = &'a dyn Fn() -> Result<(), String>;
#[derive(Default)]
pub(super) struct Flight {
result: Mutex<Option<Result<(), String>>>,
done: Condvar,
}
impl Flight {
pub fn finish(&self, result: Result<(), String>) {
*self.result.lock().expect("signed session flight lock") = Some(result);
self.done.notify_all();
}
pub fn wait(&self, check: Check<'_>) -> Result<(), String> {
let mut result = self.result.lock().expect("signed session flight lock");
loop {
check()?;
if let Some(result) = &*result {
return result.clone();
}
result = self
.done
.wait_timeout(result, Duration::from_millis(10))
.expect("signed session flight wait")
.0;
}
}
}
#[derive(Default)]
pub(super) struct State {
pub clear_generation: u64,
pub completed_grant: String,
pub blocked_generation: String,
pub challenge: Option<PendingAuthRequest>,
pub pending_ids: BTreeSet<String>,
pub bootstrap: Option<Arc<Flight>>,
pub refresh: Option<Arc<Flight>>,
pub exchange: Option<Arc<Flight>>,
}
impl State {
pub fn blocked(&self, record: &protocol::Record) -> bool {
let generation = record.generation();
!generation.is_empty() && self.blocked_generation == generation
}
pub fn clear_challenge(&mut self, auth: &AuthRegistry) {
for id in &self.pending_ids {
auth.clear_pending(id);
}
self.pending_ids.clear();
self.challenge = None;
}
pub fn remember(&mut self, pending: PendingAuthRequest) {
self.pending_ids.insert(pending.extension_id.clone());
self.challenge = Some(pending);
}
}
pub struct SignedRegistry {
root: PathBuf,
pub(super) auth: Arc<AuthRegistry>,
hints: Mutex<RuntimeHints>,
scopes: Mutex<BTreeMap<PathBuf, Arc<Mutex<State>>>>,
pub(super) grants: Mutex<BTreeMap<String, Zeroizing<String>>>,
closed: AtomicBool,
}
impl SignedRegistry {
pub fn new(root: &Path, auth: Arc<AuthRegistry>) -> Arc<Self> {
Arc::new(Self {
root: root.to_owned(),
auth,
hints: Mutex::default(),
scopes: Mutex::default(),
grants: Mutex::default(),
closed: AtomicBool::new(false),
})
}
pub fn set_runtime_state(&self, raw: &str) -> Result<(), String> {
self.check()?;
*self.hints.lock().expect("session hints lock") = RuntimeHints::parse(raw);
Ok(())
}
pub fn set_grant(&self, id: &str, grant: &str) -> Result<(), String> {
let mut grants = self.grants.lock().expect("session grants lock");
self.check()?;
if !id.trim().is_empty() && !grant.trim().is_empty() {
grants.insert(id.trim().into(), Zeroizing::new(grant.trim().into()));
}
Ok(())
}
pub fn session(
self: &Arc<Self>,
id: &str,
config: SignedSession,
network: Arc<NetworkSession>,
) -> Result<Arc<SignedSessionClient>, String> {
self.check()?;
let config = protocol::defaults(config);
let store = RecordStore::open(&self.root, &config)?;
let mut scopes = self.scopes.lock().expect("signed session scopes lock");
self.check()?;
let scope = Arc::clone(scopes.entry(store.path().to_owned()).or_default());
Ok(Arc::new(SignedSessionClient {
id: id.to_owned(),
registry: Arc::clone(self),
config,
store,
scope,
network,
verification_url: Mutex::new(String::new()),
}))
}
pub(super) fn check(&self) -> Result<(), String> {
if self.closed.load(Ordering::Acquire) {
Err("extension environment closed".into())
} else {
Ok(())
}
}
pub fn shutdown(&self) {
self.closed.store(true, Ordering::Release);
self.grants.lock().expect("session grants lock").clear();
for state in self
.scopes
.lock()
.expect("signed session scopes lock")
.values()
{
state
.lock()
.expect("signed session coordinator lock")
.clear_challenge(&self.auth);
}
}
pub(crate) fn forget_extension(&self, id: &str) {
self.grants.lock().expect("session grants lock").remove(id);
// Session records can be shared by multiple extensions through their
// manifest namespace. Only remove this extension's pending challenge.
for scope in self
.scopes
.lock()
.expect("signed session scopes lock")
.values()
{
let mut state = scope.lock().expect("signed session coordinator lock");
state.pending_ids.remove(id);
if state
.challenge
.as_ref()
.is_some_and(|pending| pending.extension_id == id)
{
state.challenge = None;
}
}
}
}
pub struct SignedSessionClient {
pub(super) id: String,
pub(super) registry: Arc<SignedRegistry>,
pub(super) config: SignedSession,
pub(super) store: RecordStore,
pub(super) scope: Arc<Mutex<State>>,
pub(super) network: Arc<NetworkSession>,
verification_url: Mutex<String>,
}
impl SignedSessionClient {
pub(super) fn check(&self, check: Check<'_>) -> Result<(), String> {
self.registry.check()?;
check()
}
pub(super) fn load(&self) -> Result<protocol::Record, String> {
self.store
.load(&self.registry.hints.lock().expect("session hints lock"))
}
pub fn status(&self) -> Result<Value, String> {
self.registry.check()?;
let state = self.scope.lock().expect("signed session coordinator lock");
let record = self.load()?;
let blocked = state.blocked(&record);
Ok(
json!({"authenticated":record.usable(self.registry.auth.now()) && !blocked, "verification_required":blocked,
"expires_at":record.expires_at,"install_id":record.install_id,"session_id":record.session_id,
"app_version":self.config.app_version,"platform":self.config.platform}),
)
}
pub fn clear(&self) -> Result<(), String> {
self.registry.check()?;
let mut state = self.scope.lock().expect("signed session coordinator lock");
let mut record = self.load()?;
record.clear();
self.store.save(&record)?;
state.clear_generation = state.clear_generation.wrapping_add(1);
state.completed_grant.clear();
state.blocked_generation.clear();
state.clear_challenge(&self.registry.auth);
self.registry.auth.clear_pending(&self.id);
Ok(())
}
pub fn take_verification_url(&self) -> String {
std::mem::take(
&mut self
.verification_url
.lock()
.expect("signed session verification URL lock"),
)
}
pub(super) fn verification_required(&self, url: String) -> Value {
if !url.is_empty() {
*self
.verification_url
.lock()
.expect("signed session verification URL lock") = url.clone();
}
json!({"ok":false,"needsVerification":true,"error":"VERIFY_REQUIRED","open_auth_url":url,"auth_url":url})
}
pub(super) fn request(
&self,
method: &str,
url: String,
body: String,
mut headers: BTreeMap<String, String>,
check: Check<'_>,
) -> Result<HttpResponse, String> {
self.check(check)?;
// Header matching is case-insensitive on the wire. Normalize before
// applying extension overrides so a lowercase override cannot coexist.
headers = headers
.into_iter()
.map(|(key, value)| (key.to_ascii_lowercase(), value))
.collect();
headers
.entry("accept".into())
.or_insert_with(|| "application/json".into());
self.network.request(
HttpRequest {
url,
method: method.into(),
body,
headers,
default_json: false,
user_agent: format!("SpotiFLAC-Mobile/{}", self.config.app_version),
},
|| self.check(check),
)
}
pub(super) fn signed_request(
&self,
record: &protocol::Record,
method: &str,
path: &str,
body: &str,
extras: &BTreeMap<String, String>,
check: Check<'_>,
) -> Result<HttpResponse, String> {
let url = protocol::endpoint(&self.config, path)?;
let mut headers: BTreeMap<_, _> = protocol::signed_headers(
&self.config,
record,
method,
&url,
body.as_bytes(),
self.registry.auth.now(),
&protocol::random_hex(12)?,
)?
.into_iter()
.map(|(key, value)| (key.to_ascii_lowercase(), value))
.collect();
if !body.is_empty() {
headers.insert("content-type".into(), "application/json".into());
}
for (key, value) in extras {
headers.insert(key.to_ascii_lowercase(), value.clone());
}
self.request(method, url, body.into(), headers, check)
}
pub(super) fn wait(&self, delay: Duration, check: Check<'_>) -> Result<(), String> {
let end = Instant::now() + delay;
loop {
self.check(check)?;
let Some(remaining) = end.checked_duration_since(Instant::now()) else {
return Ok(());
};
std::thread::sleep(remaining.min(Duration::from_millis(10)));
}
}
}
@@ -0,0 +1,450 @@
use super::coordinator::{Check, Flight, SignedSessionClient};
use super::protocol::{self, Record};
use crate::auth::{PendingAuthRequest, callback_state};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use spotiflac_network::{query, url::UrlParts};
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use zeroize::Zeroizing;
enum Bootstrap {
Session(Record),
Challenge {
url: String,
callback: String,
state: String,
},
}
fn response_record(body: &[u8]) -> Result<Record, String> {
let fields = protocol::object_fields(
body,
&[
"session_id",
"session_secret",
"expires_at",
"challenge_id",
"challenge_url",
"auth_url",
],
&[],
&[],
)?;
serde_json::from_value(Value::Object(fields)).map_err(|error| error.to_string())
}
impl SignedSessionClient {
pub fn preflight(&self, check: impl Fn() -> Result<(), String>) -> Result<bool, String> {
self.check(&check)?;
{
let state = self.scope.lock().expect("signed session coordinator lock");
let record = self.load()?;
if record.usable(self.registry.auth.now()) && !state.blocked(&record) {
return Ok(false);
}
}
let url = self.bootstrap(&check)?;
if !url.is_empty() {
return Ok(true);
}
let _state = self.scope.lock().expect("signed session coordinator lock");
if self.load()?.usable(self.registry.auth.now()) {
Ok(false)
} else {
Err(
"signed-session bootstrap did not return a session or verification challenge"
.into(),
)
}
}
pub(super) fn bootstrap(&self, check: Check<'_>) -> Result<String, String> {
loop {
self.check(check)?;
let mut state = self.scope.lock().expect("signed session coordinator lock");
if let Some(challenge) = state.challenge.as_ref().filter(|challenge| {
!challenge.auth_url.trim().is_empty()
&& self.registry.auth.now() - challenge.created_at < 180_000_000_000
}) {
let mut pending = challenge.clone();
pending.extension_id = self.id.clone();
self.registry.auth.register_pending(pending.clone())?;
state.pending_ids.insert(self.id.clone());
return Ok(pending.auth_url);
}
if state.challenge.is_some() {
state.clear_challenge(&self.registry.auth);
}
if let Some(pending) = self
.registry
.auth
.pending(&self.id)
.filter(|pending| !pending.auth_url.trim().is_empty())
{
let url = pending.auth_url.clone();
state.remember(pending);
return Ok(url);
}
let record = self
.load()
.map_err(|error| format!("load signed-session bootstrap state: {error}"))?;
if record.usable(self.registry.auth.now()) && !state.blocked(&record) {
return Ok(String::new());
}
if let Some(flight) = state.bootstrap.clone() {
drop(state);
flight.wait(&|| self.check(check))?;
continue;
}
let flight = Arc::new(Flight::default());
state.bootstrap = Some(Arc::clone(&flight));
let generation = state.clear_generation;
drop(state);
let bootstrap = self.perform_bootstrap(&record, check);
let mut state = self.scope.lock().expect("signed session coordinator lock");
let result = (|| {
let bootstrap = bootstrap?;
self.check(check)?;
if state.clear_generation != generation {
return Err("signed-session bootstrap was superseded by session clear".into());
}
let mut latest = self.load()?;
if latest.usable(self.registry.auth.now())
&& !latest.same_session(&record)
&& !state.blocked(&latest)
{
state.clear_challenge(&self.registry.auth);
return Ok(String::new());
}
match bootstrap {
Bootstrap::Session(record) => {
latest.session_id.clone_from(&record.session_id);
latest.session_secret.clone_from(&record.session_secret);
latest.expires_at.clone_from(&record.expires_at);
self.store.save(&latest).map_err(|error| {
format!("save bootstrapped signed session: {error}")
})?;
state.blocked_generation.clear();
state.clear_challenge(&self.registry.auth);
Ok(String::new())
}
Bootstrap::Challenge {
url,
callback,
state: nonce,
} => {
let pending = PendingAuthRequest {
extension_id: self.id.clone(),
auth_url: url.clone(),
callback_url: callback,
state: nonce,
created_at: self.registry.auth.now(),
};
self.registry.auth.register_pending(pending.clone())?;
state.remember(pending);
Ok(url)
}
}
})();
state.bootstrap = None;
flight.finish(result.as_ref().map(|_| ()).map_err(Clone::clone));
return result;
}
}
fn perform_bootstrap(&self, record: &Record, check: Check<'_>) -> Result<Bootstrap, String> {
let url = protocol::endpoint(&self.config, &self.config.endpoints.bootstrap)
.map_err(|error| format!("build signed-session bootstrap URL: {error}"))?;
let url = protocol::with_query(
&url,
&[
("app_version", &self.config.app_version),
("install_id", &record.install_id),
],
)?;
let mut response = self.request("GET", url.clone(), String::new(), BTreeMap::new(), check);
if response.is_err() {
self.check(check)?;
self.network.reset_connections();
response = self.request("GET", url.clone(), String::new(), BTreeMap::new(), check);
}
let response = response.map_err(|error| {
format!(
"signed-session bootstrap network request to {} failed: {error}",
UrlParts::parse(&url)
.map(|url| url.authority())
.unwrap_or_default()
)
})?;
if !(200..300).contains(&response.status) {
let mut error = format!(
"signed-session bootstrap {}returned HTTP {}",
if response.status >= 500 {
"network request "
} else {
""
},
response.status
);
let retry = protocol::response_retry_after(&response, self.registry.auth.now());
if retry > 0 {
error += &format!("; retry-after seconds: {retry}");
}
return Err(error);
}
let body = Zeroizing::new(response.body);
let record = response_record(&body)
.map_err(|error| format!("decode signed-session bootstrap response: {error}"))?;
let boot = protocol::object_fields(
&body,
&["auth_url", "challenge_url", "challenge_id"],
&[],
&[],
)
.map_err(|error| format!("decode signed-session bootstrap response: {error}"))?;
if !record.session_id.is_empty()
&& !record.session_secret.is_empty()
&& !record.expires_at.is_empty()
{
return Ok(Bootstrap::Session(record));
}
let text = |key| boot.get(key).and_then(Value::as_str).unwrap_or("");
let mut auth_url = text("auth_url").to_owned();
if auth_url.is_empty() {
auth_url = text("challenge_url").to_owned();
}
let mut state = callback_state()
.map_err(|error| format!("prepare signed-session callback state: {error}"))?;
if let Some(url) = UrlParts::parse(&auth_url) {
let values = query::parse(&url.raw_query);
let server_state = values
.get(b"state".as_slice())
.and_then(|values| values.first())
.map(|bytes| crate::host::decode_go_utf8(bytes))
.unwrap_or_default();
if !server_state.trim().is_empty() {
state = server_state.trim().to_owned();
} else if !auth_url.is_empty() {
auth_url = protocol::with_query(&auth_url, &[("state", &state)])?;
}
}
let callback = protocol::with_query(&self.config.callback_url, &[("state", &state)])
.map_err(|error| format!("prepare signed-session callback: {error}"))?;
if auth_url.is_empty() && !text("challenge_id").is_empty() {
auth_url = protocol::challenge_url(&self.config, text("challenge_id"), &state)
.unwrap_or_default();
}
if auth_url.is_empty() {
return Err(
"signed-session bootstrap did not return a session or verification challenge"
.into(),
);
}
Ok(Bootstrap::Challenge {
url: auth_url,
callback,
state,
})
}
pub fn complete_grant(
&self,
grant: &str,
check: impl Fn() -> Result<(), String>,
) -> Result<(), String> {
self.registry.set_grant(&self.id, grant)?;
let grant = self
.registry
.grants
.lock()
.expect("session grants lock")
.get(&self.id)
.cloned()
.filter(|grant| !grant.is_empty())
.ok_or("no pending grant")?;
let end = Instant::now() + Duration::from_secs(30);
let check = || {
self.check(&check)?;
if Instant::now() >= end {
Err("context deadline exceeded".into())
} else {
Ok(())
}
};
self.exchange(&grant, &check)?;
let mut grants = self.registry.grants.lock().expect("session grants lock");
// A newer callback can arrive during HTTP. Do not discard its grant.
if grants
.get(&self.id)
.is_some_and(|pending| pending.as_str() == grant.as_str())
{
grants.remove(&self.id);
}
self.registry.auth.clear_pending(&self.id);
Ok(())
}
fn exchange(&self, grant: &str, check: Check<'_>) -> Result<(), String> {
let generation = self
.scope
.lock()
.expect("signed session coordinator lock")
.clear_generation;
let flight = loop {
self.check(check)?;
let mut state = self.scope.lock().expect("signed session coordinator lock");
if let Some(flight) = state.exchange.clone() {
drop(state);
// Exchange slots serialize different grants. The preceding
// grant's failure is not the outcome of this new operation.
let _ = flight.wait(&|| self.check(check));
self.check(check)?;
} else {
let flight = Arc::new(Flight::default());
state.exchange = Some(Arc::clone(&flight));
break flight;
}
};
let result = self.exchange_owned(grant, generation, check);
self.scope
.lock()
.expect("signed session coordinator lock")
.exchange = None;
flight.finish(result.clone());
result
}
fn exchange_owned(&self, grant: &str, generation: u64, check: Check<'_>) -> Result<(), String> {
let grant_hash = format!("{:x}", Sha256::digest(grant));
let record = {
let mut state = self.scope.lock().expect("signed session coordinator lock");
if state.clear_generation != generation {
return Err("signed-session exchange was superseded by session clear".into());
}
let record = self.load()?;
if state.completed_grant == grant_hash && record.usable(self.registry.auth.now()) {
state.blocked_generation.clear();
state.clear_challenge(&self.registry.auth);
return Ok(());
}
record
};
let url = protocol::endpoint(&self.config, &self.config.endpoints.exchange)?;
let body = Zeroizing::new(json!({"grant":grant,"install_id":record.install_id,"app_version":self.config.app_version,"platform":self.config.platform}).to_string());
let mut exchanged = None;
for attempt in 0..3 {
let response = self.request(
"POST",
url.clone(),
body.to_string(),
BTreeMap::from([("content-type".into(), "application/json".into())]),
check,
)?;
let retry = protocol::response_retry_after(&response, self.registry.auth.now());
if response.status == 429 && attempt < 2 {
self.wait(Duration::from_secs(retry.clamp(1, 300) as u64), check)?;
continue;
}
if !(200..300).contains(&response.status) {
let mut error = format!("session exchange failed: HTTP {}", response.status);
if retry > 0 {
error += &format!("; retry-after seconds: {retry}");
}
return Err(error);
}
exchanged = Some(
response_record(&Zeroizing::new(response.body))
.map_err(|error| format!("invalid session exchange response: {error}"))?,
);
break;
}
let exchanged = exchanged.ok_or("session exchange did not complete")?;
if exchanged.session_id.is_empty()
|| exchanged.session_secret.is_empty()
|| exchanged.expires_at.is_empty()
{
return Err("session exchange response missing session fields".into());
}
self.check(check)?;
let mut state = self.scope.lock().expect("signed session coordinator lock");
if state.clear_generation != generation {
return Err("signed-session exchange was superseded by session clear".into());
}
let mut latest = self.load()?;
if !(latest.usable(self.registry.auth.now())
&& (state.completed_grant == grant_hash || !latest.same_session(&record)))
{
latest.session_id.clone_from(&exchanged.session_id);
latest.session_secret.clone_from(&exchanged.session_secret);
latest.expires_at.clone_from(&exchanged.expires_at);
self.store.save(&latest)?;
state.completed_grant = grant_hash;
}
state.blocked_generation.clear();
state.clear_challenge(&self.registry.auth);
Ok(())
}
pub(super) fn refresh(&self, check: Check<'_>) -> Result<(), String> {
loop {
self.check(check)?;
let mut state = self.scope.lock().expect("signed session coordinator lock");
let record = self.load()?;
if !record.refresh_due(&self.config, self.registry.auth.now()) {
return Ok(());
}
if let Some(flight) = state.refresh.clone() {
drop(state);
flight.wait(&|| self.check(check))?;
continue;
}
let flight = Arc::new(Flight::default());
state.refresh = Some(Arc::clone(&flight));
let generation = state.clear_generation;
drop(state);
let response = self.signed_request(
&record,
"POST",
&self.config.endpoints.refresh,
&json!({"install_id":record.install_id}).to_string(),
&BTreeMap::new(),
check,
);
let mut state = self.scope.lock().expect("signed session coordinator lock");
let result = (|| {
let response = response?;
if !(200..300).contains(&response.status) {
return Err(format!("session refresh failed: HTTP {}", response.status));
}
let refreshed = response_record(&Zeroizing::new(response.body))?;
self.check(check)?;
if state.clear_generation != generation {
return Err("signed-session refresh was superseded by session clear".into());
}
let mut latest = self.load()?;
if latest.same_session(&record) {
let mut changed = false;
for (field, value) in [
(&mut latest.session_id, &refreshed.session_id),
(&mut latest.session_secret, &refreshed.session_secret),
(&mut latest.expires_at, &refreshed.expires_at),
] {
if !value.is_empty() && field != value {
field.clone_from(value);
changed = true;
}
}
if changed {
self.store.save(&latest)?;
}
}
Ok(())
})();
state.refresh = None;
flight.finish(result.clone());
return result;
}
}
}
@@ -0,0 +1,141 @@
use super::coordinator::SignedSessionClient;
use super::protocol::{self, ErrorContract};
use serde_json::Value;
use std::collections::BTreeMap;
impl SignedSessionClient {
pub fn signed_fetch(
&self,
method: &str,
path: &str,
body: &str,
headers: &BTreeMap<String, String>,
check: impl Fn() -> Result<(), String>,
) -> Result<Value, String> {
self.check(&check)?;
let method = method.trim().to_uppercase();
let mut record = {
let state = self.scope.lock().expect("signed session coordinator lock");
let mut record = self.load()?;
let error = if record.session_id.is_empty() || record.session_secret.is_empty() {
Some("signed session is not authenticated")
} else if protocol::parse_time(&record.expires_at)
.is_some_and(|expires| self.registry.auth.now() > expires)
{
record.clear();
let _ = self.store.save(&record);
Some("signed session expired")
} else {
None
};
if let Some(error) = error {
drop(state);
let url = self.bootstrap(&check)?;
return if url.is_empty() {
Err(error.into())
} else {
Ok(self.verification_required(url))
};
}
if state.blocked(&record) {
drop(state);
let url = self.bootstrap(&check)?;
if !url.is_empty() {
return Ok(self.verification_required(url));
}
let state = self.scope.lock().expect("signed session coordinator lock");
record = self.load()?;
if !record.usable(self.registry.auth.now()) || state.blocked(&record) {
return Err(
"verification_required: signed-session generation is blocked".into(),
);
}
}
record
};
if record.refresh_due(&self.config, self.registry.auth.now()) {
let _ = self.refresh(&check);
self.check(&check)?;
let state = self.scope.lock().expect("signed session coordinator lock");
let latest = self.load()?;
if !latest.usable(self.registry.auth.now()) || state.blocked(&latest) {
drop(state);
let url = self.bootstrap(&check)?;
return if url.is_empty() {
Err("signed session is not authenticated".into())
} else {
Ok(self.verification_required(url))
};
}
record = latest;
}
let mut session_retries = 0;
let mut provider_retries = 0;
let mut request_auth_retry = false;
loop {
let response = self.signed_request(&record, &method, path, body, headers, &check)?;
let contract = ErrorContract::parse(&response.body);
if contract.provider_retry(response.status) {
if provider_retries >= 2 {
return Ok(protocol::response_value(response, self.registry.auth.now()));
}
provider_retries += 1;
self.wait(
protocol::provider_delay(&response, &contract, self.registry.auth.now()),
&check,
)?;
continue;
}
if contract.request_auth_invalid(response.status) {
let _state = self.scope.lock().expect("signed session coordinator lock");
let latest = self.load()?;
if !request_auth_retry
&& latest.usable(self.registry.auth.now())
&& !latest.same_session(&record)
{
request_auth_retry = true;
record = latest;
continue;
}
return Ok(protocol::response_value(response, self.registry.auth.now()));
}
let action = contract.gateway_action(response.status);
if action.is_empty() {
return Ok(protocol::response_value(response, self.registry.auth.now()));
}
let mut state = self.scope.lock().expect("signed session coordinator lock");
let mut latest = self.load()?;
if latest.usable(self.registry.auth.now()) && !latest.same_session(&record) {
if session_retries >= 1 {
return Err("signed-session retry limit reached".into());
}
session_retries += 1;
record = latest;
continue;
}
if action == "bootstrap_session" && latest.same_session(&record) {
state.blocked_generation.clear();
latest.clear();
self.store.save(&latest)?;
} else if action == "verify" && latest.same_session(&record) {
state.blocked_generation = record.generation();
}
drop(state);
let url = self.bootstrap(&check)?;
if !url.is_empty() {
return Ok(self.verification_required(url));
}
let _state = self.scope.lock().expect("signed session coordinator lock");
let bootstrapped = self.load()?;
if bootstrapped.usable(self.registry.auth.now())
&& !bootstrapped.same_session(&record)
&& session_retries < 1
{
session_retries += 1;
record = bootstrapped;
continue;
}
return Ok(protocol::response_value(response, self.registry.auth.now()));
}
}
}
@@ -0,0 +1,589 @@
use crate::manifest::SignedSession;
use aes_gcm::aead::{OsRng, rand_core::RngCore};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use spotiflac_network::{HttpResponse, query, url::UrlParts};
use std::collections::BTreeMap;
use std::time::{Duration, UNIX_EPOCH};
use time::{Date, Month, OffsetDateTime, PrimitiveDateTime, Time};
use zeroize::{Zeroize, Zeroizing};
pub fn defaults(mut config: SignedSession) -> SignedSession {
for (value, fallback) in [
(&mut config.app_version, "ext-1.0"),
(&mut config.platform, "extension"),
(&mut config.callback_url, "spotiflac://session-grant"),
(&mut config.scheme_label, "SPOTIFLAC-HMAC-V1"),
(&mut config.header_prefix, "X-Sig-"),
(&mut config.endpoints.bootstrap, "/bootstrap"),
(&mut config.endpoints.challenge, "/challenge"),
(&mut config.endpoints.exchange, "/session/exchange"),
] {
if value.is_empty() {
*value = fallback.into();
}
}
if config.time_window_seconds <= 0 {
config.time_window_seconds = 300;
}
config
}
pub fn namespace(value: &str) -> String {
value
.trim()
.to_lowercase()
.chars()
.filter(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ".-_".contains(*ch))
.collect::<String>()
.trim_matches(['.', '-', '_'])
.to_owned()
}
pub fn filename(config: &SignedSession) -> Result<String, String> {
let namespace = namespace(&config.namespace);
if namespace.is_empty() {
return Err("signed session namespace is empty".into());
}
let scope = [
namespace.as_str(),
&config.base_url.trim().to_lowercase(),
&config.app_version.trim().to_lowercase(),
&config.platform.trim().to_lowercase(),
]
.join("\n");
Ok(format!(
"{namespace}-{}.json",
&hex(&Sha256::digest(scope))[..16]
))
}
pub fn endpoint(config: &SignedSession, endpoint: &str) -> Result<String, String> {
let base = UrlParts::parse(&(config.base_url.trim_end_matches('/').to_owned() + "/"))
.filter(|base| base.scheme == "https" && !base.hostname.is_empty())
.ok_or("invalid signed session baseUrl")?;
let endpoint = endpoint.trim();
if endpoint.is_empty() {
return Err("signed session endpoint is empty".into());
}
if endpoint.starts_with("https://") {
return Ok(endpoint.to_owned());
}
base.resolve(endpoint.trim_start_matches('/'))
.map(|url| url.display_url())
.ok_or_else(|| "invalid signed session endpoint".into())
}
pub fn with_query(input: &str, values: &[(&str, &str)]) -> Result<String, String> {
let mut url = UrlParts::parse(input).ok_or("invalid signed session URL")?;
let mut query = query::parse(&url.raw_query);
for (key, value) in values {
query::set(&mut query, key, value);
}
url.raw_query = query::encode(&query);
Ok(url.display_url())
}
pub fn challenge_url(
config: &SignedSession,
challenge: &str,
state: &str,
) -> Result<String, String> {
let callback = with_query(
&config.callback_url,
&[("cb_version", "v2grant"), ("state", state)],
)?;
with_query(
&endpoint(config, &config.endpoints.challenge)?,
&[("id", challenge), ("cb", &callback)],
)
}
pub fn parse_time(value: &str) -> Option<i128> {
// Match Go's RFC3339 fallback parser: uppercase T/Z, one- or two-digit
// hours, comma fractions, truncated nanoseconds, and normalized +24:60.
fn number(value: &str, minimum: usize, maximum: usize) -> Option<u32> {
((minimum..=maximum).contains(&value.len())
&& value.bytes().all(|byte| byte.is_ascii_digit()))
.then(|| value.parse().ok())
.flatten()
}
let (date, clock) = value.trim().split_once('T')?;
let mut date = date.split('-');
let year = number(date.next()?, 4, 4)? as i32;
let month = Month::try_from(number(date.next()?, 2, 2)? as u8).ok()?;
let day = number(date.next()?, 2, 2)? as u8;
if date.next().is_some() {
return None;
}
let zone_start = clock.find(['Z', '+', '-'])?;
let (clock, zone) = clock.split_at(zone_start);
let offset = if zone == "Z" {
0
} else {
let (sign, zone) = zone.split_at(1);
let (hours, minutes) = zone.split_once(':')?;
let hours = number(hours, 2, 2)?;
let minutes = number(minutes, 2, 2)?;
if hours > 24 || minutes > 60 {
return None;
}
i128::from(hours * 3600 + minutes * 60)
* if sign == "-" {
-1
} else if sign == "+" {
1
} else {
return None;
}
};
let mut clock = clock.split(':');
let hours = number(clock.next()?, 1, 2)? as u8;
let minutes = number(clock.next()?, 2, 2)? as u8;
let seconds = clock.next()?;
if clock.next().is_some() {
return None;
}
let (seconds, fraction) = seconds.split_once(['.', ',']).unwrap_or((seconds, ""));
let seconds = number(seconds, 2, 2)? as u8;
let nanos = if fraction.is_empty() {
if value.contains(['.', ',']) {
return None;
}
0
} else {
if !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
let digits = &fraction[..fraction.len().min(9)];
digits.parse::<u32>().ok()? * 10_u32.pow(9 - digits.len() as u32)
};
let date = Date::from_calendar_date(year, month, day).ok()?;
let time = Time::from_hms_nano(hours, minutes, seconds, nanos).ok()?;
Some(
PrimitiveDateTime::new(date, time)
.assume_utc()
.unix_timestamp_nanos()
- offset * 1_000_000_000,
)
}
fn string_or_null<'de, D: serde::Deserializer<'de>>(value: D) -> Result<String, D::Error> {
Ok(Option::<String>::deserialize(value)?.unwrap_or_default())
}
// Go's JSON struct decoder accepts ASCII case-insensitive field names and
// treats null scalars as their zero values. All gateway decisions use this path.
pub(super) fn object_fields(
bytes: &[u8],
strings: &[&str],
booleans: &[&str],
integers: &[&str],
) -> Result<serde_json::Map<String, Value>, String> {
struct Fields<'a>([&'a [&'a str]; 3]);
impl<'de> serde::de::Visitor<'de> for Fields<'_> {
type Value = serde_json::Map<String, Value>;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("a signed session object")
}
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(serde_json::Map::new())
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> Result<Self::Value, A::Error> {
let mut result = serde_json::Map::new();
while let Some(key) = map.next_key::<String>()? {
let key = key.to_ascii_lowercase();
let Some(kind) = self
.0
.iter()
.position(|fields| fields.contains(&key.as_str()))
else {
map.next_value::<serde::de::IgnoredAny>()?;
continue;
};
let value = map.next_value::<Value>()?;
if value.is_null() {
continue;
}
let valid = match kind {
0 => value.is_string(),
1 => value.is_boolean(),
_ => value
.as_i64()
.is_some_and(|value| isize::try_from(value).is_ok()),
};
if !valid {
return Err(serde::de::Error::custom(format!(
"invalid signed session field: {key}"
)));
}
// Preserve encounter order across duplicate/case-folded keys;
// null leaves the preceding scalar untouched, as in Go.
result.insert(key, value);
}
Ok(result)
}
}
let mut decoder = serde_json::Deserializer::from_slice(bytes);
let result =
serde::Deserializer::deserialize_any(&mut decoder, Fields([strings, booleans, integers]))
.map_err(|error| error.to_string())?;
decoder.end().map_err(|error| error.to_string())?;
Ok(result)
}
#[derive(Clone, Default, Deserialize, Serialize)]
pub struct Record {
#[serde(default, deserialize_with = "string_or_null")]
pub install_id: String,
#[serde(
default,
skip_serializing_if = "String::is_empty",
deserialize_with = "string_or_null"
)]
pub session_id: String,
#[serde(
default,
skip_serializing_if = "String::is_empty",
deserialize_with = "string_or_null"
)]
pub session_secret: String,
#[serde(
default,
skip_serializing_if = "String::is_empty",
deserialize_with = "string_or_null"
)]
pub expires_at: String,
#[serde(
default,
skip_serializing_if = "String::is_empty",
deserialize_with = "string_or_null"
)]
pub namespace: String,
#[serde(
default,
skip_serializing_if = "String::is_empty",
deserialize_with = "string_or_null"
)]
pub base_url: String,
#[serde(
default,
skip_serializing_if = "String::is_empty",
deserialize_with = "string_or_null"
)]
pub app_version: String,
#[serde(
default,
skip_serializing_if = "String::is_empty",
deserialize_with = "string_or_null"
)]
pub platform: String,
}
impl Drop for Record {
fn drop(&mut self) {
self.session_secret.zeroize();
}
}
impl Record {
pub fn decode(bytes: &[u8]) -> Result<Self, String> {
serde_json::from_value(Value::Object(object_fields(
bytes,
&[
"install_id",
"session_id",
"session_secret",
"expires_at",
"namespace",
"base_url",
"app_version",
"platform",
],
&[],
&[],
)?))
.map_err(|error| error.to_string())
}
pub fn usable(&self, now: i128) -> bool {
!self.session_id.trim().is_empty()
&& !self.session_secret.trim().is_empty()
&& parse_time(&self.expires_at).is_none_or(|expires| now < expires)
}
pub fn same_session(&self, other: &Self) -> bool {
!self.session_id.is_empty()
&& self.session_id == other.session_id
&& self.session_secret == other.session_secret
}
pub fn generation(&self) -> String {
if self.session_id.is_empty() || self.session_secret.is_empty() {
return String::new();
}
hex(&Sha256::digest(
Zeroizing::new(format!("{}\n{}", self.session_id, self.session_secret)).as_bytes(),
))
}
pub fn clear(&mut self) {
self.session_id.clear();
self.session_secret.zeroize();
self.expires_at.clear();
}
pub fn normalize_scope(&mut self, config: &SignedSession) -> bool {
let namespace = namespace(&config.namespace);
if self.namespace == namespace
&& self.base_url == config.base_url.trim()
&& self.app_version == config.app_version.trim()
&& self.platform == config.platform.trim()
{
return false;
}
if !self.namespace.is_empty()
|| !self.base_url.is_empty()
|| !self.app_version.is_empty()
|| !self.platform.is_empty()
{
self.clear();
}
self.namespace = namespace;
self.base_url = config.base_url.trim().to_owned();
self.app_version = config.app_version.trim().to_owned();
self.platform = config.platform.trim().to_owned();
true
}
pub fn refresh_due(&self, config: &SignedSession, now: i128) -> bool {
!config.endpoints.refresh.is_empty()
&& self.usable(now)
&& parse_time(&self.expires_at)
.is_some_and(|expires| now < expires && expires - now <= 3_600_000_000_000)
}
}
pub fn random_hex(count: usize) -> Result<String, String> {
let mut bytes = Zeroizing::new(vec![0; count]);
OsRng
.try_fill_bytes(&mut bytes)
.map_err(|error| error.to_string())?;
Ok(hex(&bytes))
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write;
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(output, "{byte:02x}");
}
output
}
fn hmac(key: &[u8], message: &[u8]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(message);
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
}
pub fn signed_headers(
config: &SignedSession,
record: &Record,
method: &str,
url: &str,
body: &[u8],
now: i128,
nonce: &str,
) -> Result<BTreeMap<String, String>, String> {
let url = UrlParts::parse(url).ok_or("invalid signed session URL")?;
let time = OffsetDateTime::from_unix_timestamp_nanos(now).map_err(|error| error.to_string())?;
let timestamp = format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
time.year(),
u8::from(time.month()),
time.day(),
time.hour(),
time.minute(),
time.second(),
time.millisecond()
);
let hash = hex(&Sha256::digest(body));
if config.time_window_seconds <= 0 {
return Err("invalid signed session time window".into());
}
let window = time.unix_timestamp() / config.time_window_seconds as i64;
let rolling = Zeroizing::new(hmac(
record.session_secret.as_bytes(),
format!("{window}:{}", record.session_id).as_bytes(),
));
let signing = [
&config.scheme_label,
method,
&url.escaped_path(),
"",
&hash,
&timestamp,
nonce,
&record.session_id,
&config.app_version,
&config.platform,
]
.join("\n");
let signature = hmac(rolling.as_bytes(), signing.as_bytes());
Ok([
("Session", record.session_id.clone()),
("Timestamp", timestamp),
("Nonce", nonce.to_owned()),
("Body-SHA256", hash),
("Signature", signature),
("App-Version", config.app_version.clone()),
("Platform", config.platform.clone()),
]
.into_iter()
.map(|(name, value)| (format!("{}{name}", config.header_prefix), value))
.collect())
}
#[derive(Default, Deserialize, Serialize)]
#[serde(default)]
pub struct ErrorContract {
#[serde(deserialize_with = "string_or_null")]
pub error: String,
#[serde(deserialize_with = "string_or_null")]
pub code: String,
#[serde(deserialize_with = "string_or_null")]
pub origin: String,
#[serde(deserialize_with = "string_or_null")]
pub action: String,
pub retryable: bool,
#[serde(deserialize_with = "string_or_null")]
pub retry_mode: String,
pub retry_after_seconds: isize,
}
impl ErrorContract {
pub fn parse(body: &[u8]) -> Self {
let mut value: Self = object_fields(
body,
&["error", "code", "origin", "action", "retry_mode"],
&["retryable"],
&["retry_after_seconds"],
)
.ok()
.and_then(|fields| serde_json::from_value(Value::Object(fields)).ok())
.unwrap_or_default();
value.error = value.error.trim().to_owned();
value.code = value.code.trim().to_uppercase();
value.origin = value.origin.trim().to_lowercase();
value.action = value.action.trim().to_lowercase();
value.retry_mode = value.retry_mode.trim().to_lowercase();
value.retry_after_seconds = value.retry_after_seconds.max(0);
value
}
pub fn gateway_action(&self, status: u16) -> &str {
match (
status,
self.origin.as_str(),
self.code.as_str(),
self.action.as_str(),
) {
(401, "gateway", "SESSION_INVALID", "bootstrap_session") => "bootstrap_session",
(428, "gateway", "VERIFY_REQUIRED", "verify") => "verify",
_ => "",
}
}
pub fn provider_retry(&self, status: u16) -> bool {
status == 503
&& self.origin == "provider"
&& self.code == "PROVIDER_UNAVAILABLE"
&& self.retryable
&& self.retry_mode == "same_operation"
}
pub fn request_auth_invalid(&self, status: u16) -> bool {
status == 403
&& self.origin == "gateway"
&& self.code == "REQUEST_AUTH_INVALID"
&& self.action.is_empty()
}
}
pub fn retry_after(value: &str, now: i128) -> i64 {
let value = value.trim();
if let Ok(seconds) = value.parse::<isize>() {
return seconds.max(0) as i64;
}
httpdate::parse_http_date(value)
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map_or(0, |time| {
((time.as_nanos() as i128 - now) / 1_000_000_000).max(0) as i64
})
}
pub fn response_retry_after(response: &HttpResponse, now: i128) -> i64 {
response
.headers
.get("Retry-After")
.and_then(|values| values.first())
.map_or(0, |value| retry_after(value, now))
}
pub fn provider_delay(response: &HttpResponse, contract: &ErrorContract, now: i128) -> Duration {
// Go's retry scheduler preserves the fractional part of an HTTP date and
// parses numeric headers without trimming; the response metadata uses a
// separate, whole-second parser above.
if let Some(value) = response
.headers
.get("Retry-After")
.and_then(|values| values.first())
{
let nanos = if let Ok(seconds) = value.parse::<isize>() {
// time.Duration is an int64 in Go, including multiplication overflow.
i128::from((seconds as i64).wrapping_mul(1_000_000_000))
} else {
httpdate::parse_http_date(value)
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map_or(0, |time| time.as_nanos() as i128 - now)
};
if nanos > 0 {
return Duration::from_nanos(nanos.min(120_000_000_000) as u64);
}
}
Duration::from_secs(if contract.retry_after_seconds > 0 {
contract.retry_after_seconds.min(120) as u64
} else {
1
})
}
pub fn response_value(response: HttpResponse, now: i128) -> Value {
let contract = ErrorContract::parse(&response.body);
let retry = response_retry_after(&response, now);
let mut result = json!({"statusCode":response.status,"status":response.status,"ok":(200..300).contains(&response.status),
"url":response.url,"body":crate::host::decode_go_utf8(&response.body),"headers":response.headers.into_iter().map(|(key, values)| {
let value = if values.len() == 1 { json!(values[0]) } else { json!(values) }; (key,value)
}).collect::<BTreeMap<_,_>>(),"retryAfterSeconds":if retry>0 {retry} else {contract.retry_after_seconds as i64}});
if !contract.code.is_empty() || !contract.origin.is_empty() || !contract.action.is_empty() {
for (key, value) in [
("error", json!(contract.error)),
("code", json!(contract.code)),
("origin", json!(contract.origin)),
("action", json!(contract.action)),
("retryable", json!(contract.retryable)),
("retryMode", json!(contract.retry_mode)),
] {
result[key] = value;
}
}
result
}
@@ -0,0 +1,168 @@
use super::protocol::{self, Record};
use crate::manifest::SignedSession;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use zeroize::Zeroizing;
#[derive(Default)]
pub struct RuntimeHints {
default: String,
values: BTreeMap<String, String>,
}
impl<'de> Deserialize<'de> for RuntimeHints {
fn deserialize<D: serde::Deserializer<'de>>(decoder: D) -> Result<Self, D::Error> {
struct Fields;
impl<'de> serde::de::Visitor<'de> for Fields {
type Value = RuntimeHints;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("a runtime state object")
}
fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
Ok(RuntimeHints::default())
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> Result<Self::Value, A::Error> {
let mut result = RuntimeHints::default();
while let Some(key) = map.next_key::<String>()? {
if key.eq_ignore_ascii_case("d") {
if let Some(value) = map.next_value::<Option<String>>()? {
result.default = value;
}
} else if key.eq_ignore_ascii_case("s") {
match map.next_value::<Option<BTreeMap<String, Option<String>>>>()? {
Some(values) => result.values.extend(
values
.into_iter()
.map(|(key, value)| (key, value.unwrap_or_default())),
),
None => result.values.clear(),
}
} else {
map.next_value::<serde::de::IgnoredAny>()?;
}
}
Ok(result)
}
}
decoder.deserialize_any(Fields)
}
}
impl RuntimeHints {
pub fn parse(raw: &str) -> Self {
let mut hints: Self = serde_json::from_str(raw).unwrap_or_default();
hints.default = normalize_hint(&hints.default);
hints.values = hints
.values
.into_iter()
.filter_map(|(path, value)| {
let key = path
.trim()
.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or("")
.to_owned();
let value = normalize_hint(&value);
(!key.is_empty() && key != "." && !value.is_empty()).then_some((key, value))
})
.collect();
hints
}
fn get(&self, path: &Path) -> &str {
path.file_name()
.and_then(|name| name.to_str())
.and_then(|name| self.values.get(name))
.map_or(&self.default, String::as_str)
}
}
fn normalize_hint(value: &str) -> String {
let value = value.trim().to_lowercase();
if value.len() == 32 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
value
} else {
String::new()
}
}
pub struct RecordStore {
path: PathBuf,
config: SignedSession,
}
impl RecordStore {
pub fn open(root: &Path, config: &SignedSession) -> Result<Self, String> {
let directory = root.join("signed_sessions");
if directory
.symlink_metadata()
.is_ok_and(|metadata| !metadata.is_dir())
{
return Err("invalid signed session directory".into());
}
let mut builder = fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder
.create(&directory)
.map_err(|error| error.to_string())?;
let directory = fs::canonicalize(directory).map_err(|error| error.to_string())?;
Ok(Self {
path: directory.join(protocol::filename(config)?),
config: config.clone(),
})
}
pub fn path(&self) -> &Path {
&self.path
}
/// Call while holding this scope's coordinator lock. Go stores this record
/// as private JSON; preserve that format for the eventual ownership cutover.
pub fn load(&self, hints: &RuntimeHints) -> Result<Record, String> {
let mut record = match fs::symlink_metadata(&self.path) {
Ok(metadata) => {
if !metadata.is_file() || metadata.len() > 1024 * 1024 {
return Err("invalid signed session file".into());
}
let bytes =
Zeroizing::new(fs::read(&self.path).map_err(|error| error.to_string())?);
Record::decode(&bytes)
.map_err(|error| format!("invalid signed session record: {error}"))?
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Record::default(),
Err(error) => return Err(error.to_string()),
};
let mut changed = false;
if record.install_id.trim().is_empty() {
record.install_id = hints.get(&self.path).to_owned();
if record.install_id.is_empty() {
record.install_id = protocol::random_hex(16)?;
}
changed = true;
}
if record.normalize_scope(&self.config) {
changed = true;
}
if changed {
self.save(&record)?;
}
Ok(record)
}
pub fn save(&self, record: &Record) -> Result<(), String> {
let bytes =
Zeroizing::new(serde_json::to_vec_pretty(record).map_err(|error| error.to_string())?);
crate::storage::atomic_write(&self.path, &bytes).map_err(|error| error.to_string())
}
}
@@ -0,0 +1,523 @@
//! Go-compatible extension persistence. Each file is serialized across runtimes.
use aes_gcm::aead::{Aead, KeyInit, OsRng, rand_core::RngCore};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::Engine;
use base64::alphabet;
use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig};
use hmac::{Hmac, Mac};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, RwLock, RwLockReadGuard, Weak};
use std::time::SystemTime;
use zeroize::Zeroizing;
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
#[error("extension storage closed")]
Closed,
#[error("extension storage master key must be 32 base64-encoded bytes")]
InvalidKey,
#[error("extension storage master key is not configured")]
MissingKey,
#[error("invalid extension ID")]
InvalidExtensionId,
#[error("extension storage path must be a regular file")]
InvalidPath,
#[error("ciphertext too short")]
ShortCiphertext,
#[error("cipher: message authentication failed")]
Authentication,
#[error("failed to generate nonce: {0}")]
Random(String),
#[error("{0}")]
Io(#[from] io::Error),
#[error("{0}")]
Json(#[from] serde_json::Error),
#[error("extension storage must contain a JSON object")]
InvalidObject,
}
/// The platform supplies this key. Rust never persists it or includes it in Debug.
pub struct StorageMasterKey(Zeroizing<[u8; 32]>);
impl StorageMasterKey {
pub fn from_base64(encoded: &str) -> Result<Self, StorageError> {
let encoding = GeneralPurpose::new(
&alphabet::STANDARD,
GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
);
let decoded = Zeroizing::new(
encoding
.decode(encoded.replace(['\r', '\n'], ""))
.map_err(|_| StorageError::InvalidKey)?,
);
let key: [u8; 32] = decoded
.as_slice()
.try_into()
.map_err(|_| StorageError::InvalidKey)?;
Ok(Self(Zeroizing::new(key)))
}
pub fn derive(&self, extension_id: &str, purpose: &str) -> Zeroizing<[u8; 32]> {
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(self.0.as_ref())
.expect("HMAC accepts any key length");
mac.update(b"SpotiFLAC Mobile extension storage v2\0");
mac.update(purpose.as_bytes());
mac.update(&[0]);
mac.update(extension_id.as_bytes());
Zeroizing::new(mac.finalize().into_bytes().into())
}
}
/// Go's wire format: 12-byte nonce, ciphertext, then the 16-byte authentication tag.
pub fn encrypt(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, StorageError> {
let mut nonce = [0u8; 12];
OsRng
.try_fill_bytes(&mut nonce)
.map_err(|error| StorageError::Random(error.to_string()))?;
let cipher = Aes256Gcm::new(key.into());
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce), plaintext)
.map_err(|_| StorageError::Authentication)?;
let mut result = Vec::with_capacity(nonce.len() + ciphertext.len());
result.extend_from_slice(&nonce);
result.extend_from_slice(&ciphertext);
Ok(result)
}
pub fn decrypt(ciphertext: &[u8], key: &[u8; 32]) -> Result<Zeroizing<Vec<u8>>, StorageError> {
if ciphertext.len() < 12 {
return Err(StorageError::ShortCiphertext);
}
let cipher = Aes256Gcm::new(key.into());
let plaintext = cipher
.decrypt(Nonce::from_slice(&ciphertext[..12]), &ciphertext[12..])
.map_err(|_| StorageError::Authentication)?;
Ok(Zeroizing::new(plaintext))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum StoreKind {
Storage,
Credentials,
Settings,
}
impl StoreKind {
fn filename(self) -> &'static str {
match self {
Self::Storage => "storage.json",
Self::Credentials => ".credentials.enc",
Self::Settings => "settings.enc",
}
}
fn purpose(self) -> &'static str {
match self {
Self::Storage => "",
Self::Credentials => "credentials",
Self::Settings => "settings",
}
}
}
#[derive(PartialEq, Eq)]
struct Identity {
length: u64,
modified: Option<SystemTime>,
#[cfg(unix)]
inode: u64,
}
#[derive(Default)]
struct FileCache {
snapshot: Option<Map<String, Value>>,
identity: Option<Identity>,
key_tag: Option<[u8; 32]>,
}
type SharedFile = Arc<Mutex<FileCache>>;
fn shared_file(path: &Path) -> SharedFile {
static FILES: OnceLock<Mutex<HashMap<PathBuf, Weak<Mutex<FileCache>>>>> = OnceLock::new();
let mut files = FILES
.get_or_init(Mutex::default)
.lock()
.expect("extension file registry lock");
if let Some(file) = files.get(path).and_then(Weak::upgrade) {
return file;
}
files.retain(|_, file| file.strong_count() > 0);
let file = Arc::new(Mutex::new(FileCache::default()));
files.insert(path.to_owned(), Arc::downgrade(&file));
file
}
/// Stores for one canonical data directory. Instances share locks and immutable
/// snapshots, while values returned to callers are independent deep copies.
pub struct ExtensionStore {
directory: PathBuf,
extension_id: String,
master_key: Option<Arc<StorageMasterKey>>,
credentials_tag: OnceLock<Option<[u8; 32]>>,
settings_tag: OnceLock<Option<[u8; 32]>>,
storage: SharedFile,
credentials: SharedFile,
settings: SharedFile,
salt: SharedFile,
closed: RwLock<bool>,
}
impl ExtensionStore {
pub fn open(
directory: &Path,
extension_id: &str,
master_key: Option<Arc<StorageMasterKey>>,
) -> Result<Self, StorageError> {
if !valid_extension_id(extension_id) {
return Err(StorageError::InvalidExtensionId);
}
let mut builder = fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder.create(directory)?;
let directory = fs::canonicalize(directory)?;
Ok(Self {
storage: shared_file(&directory.join(StoreKind::Storage.filename())),
credentials: shared_file(&directory.join(StoreKind::Credentials.filename())),
settings: shared_file(&directory.join(StoreKind::Settings.filename())),
salt: shared_file(&directory.join(".cred_salt")),
directory,
extension_id: extension_id.to_owned(),
master_key,
credentials_tag: OnceLock::new(),
settings_tag: OnceLock::new(),
closed: RwLock::new(false),
})
}
fn file(&self, kind: StoreKind) -> &SharedFile {
match kind {
StoreKind::Storage => &self.storage,
StoreKind::Credentials => &self.credentials,
StoreKind::Settings => &self.settings,
}
}
fn key(&self, kind: StoreKind) -> Result<Zeroizing<[u8; 32]>, StorageError> {
Ok(self
.master_key
.as_ref()
.ok_or(StorageError::MissingKey)?
.derive(&self.extension_id, kind.purpose()))
}
fn cache_tag(&self, kind: StoreKind) -> Option<[u8; 32]> {
let cache = match kind {
StoreKind::Storage => return None,
StoreKind::Credentials => &self.credentials_tag,
StoreKind::Settings => &self.settings_tag,
};
*cache.get_or_init(|| {
self.master_key.as_ref().map(|key| {
Sha256::digest(key.derive(&self.extension_id, kind.purpose()).as_ref()).into()
})
})
}
fn refresh<'a>(
&self,
kind: StoreKind,
cache: &'a mut FileCache,
) -> Result<&'a Map<String, Value>, StorageError> {
let path = self.directory.join(kind.filename());
let identity = file_identity(&path)?;
let key_tag = self.cache_tag(kind);
if cache.snapshot.is_none()
|| cache.identity != identity
|| cache.key_tag != key_tag
|| (kind == StoreKind::Settings && identity.is_none())
{
let snapshot = match read_optional(&path)? {
Some(data) if kind == StoreKind::Storage => parse_object(&data)?,
Some(data) => {
let key = self.key(kind)?;
let plaintext = match decrypt(&data, &key) {
Ok(plaintext) => plaintext,
Err(error) if kind == StoreKind::Credentials => {
let legacy = self.legacy_credentials_key()?;
let plaintext = decrypt(&data, &legacy).map_err(|_| error)?;
// Validate before replacing a legacy file. Corrupt data is preserved.
parse_object(&plaintext)?;
atomic_write(&path, &encrypt(&plaintext, &key)?)?;
plaintext
}
Err(error) => return Err(error),
};
parse_object(&plaintext)?
}
None if kind == StoreKind::Settings => self.migrate_settings(&path)?,
None => Map::new(),
};
cache.identity = file_identity(&path)?;
cache.key_tag = key_tag;
cache.snapshot = Some(snapshot);
}
Ok(cache.snapshot.as_ref().expect("loaded storage snapshot"))
}
fn legacy_credentials_key(&self) -> Result<Zeroizing<[u8; 32]>, StorageError> {
let _guard = self.salt.lock().expect("extension salt lock");
let path = self.directory.join(".cred_salt");
let salt = match read_optional(&path)? {
Some(salt) if salt.len() == 32 => salt,
_ => {
let mut salt = vec![0u8; 32];
OsRng
.try_fill_bytes(&mut salt)
.map_err(|error| StorageError::Random(error.to_string()))?;
atomic_write(&path, &salt)?;
salt
}
};
let mut hash = Sha256::new();
hash.update(self.extension_id.as_bytes());
hash.update(&salt);
Ok(Zeroizing::new(hash.finalize().into()))
}
fn migrate_settings(&self, path: &Path) -> Result<Map<String, Value>, StorageError> {
let legacy = self.directory.join("settings.json");
let Some(data) = read_optional(&legacy)? else {
return Ok(Map::new());
};
let snapshot = parse_object(&data)?;
let key = self.key(StoreKind::Settings)?;
atomic_write(path, &encrypt(&data, &key)?)?;
fs::remove_file(legacy)?;
Ok(snapshot)
}
pub fn get(&self, kind: StoreKind, key: &str) -> Result<Option<Value>, StorageError> {
let _access = self.access()?;
let mut cache = self.file(kind).lock().expect("extension storage lock");
Ok(self.refresh(kind, &mut cache)?.get(key).cloned())
}
pub fn all(&self, kind: StoreKind) -> Result<Map<String, Value>, StorageError> {
let _access = self.access()?;
let mut cache = self.file(kind).lock().expect("extension storage lock");
Ok(self.refresh(kind, &mut cache)?.clone())
}
pub fn set(&self, kind: StoreKind, key: &str, value: Value) -> Result<(), StorageError> {
self.mutate(kind, |snapshot| {
snapshot.insert(key.to_owned(), value);
true
})
}
pub fn remove(&self, kind: StoreKind, key: &str) -> Result<(), StorageError> {
self.mutate(kind, |snapshot| {
let existed = snapshot.remove(key).is_some();
// Go credentials.remove persists even if the key did not exist.
existed || kind != StoreKind::Storage
})
}
pub fn replace(&self, kind: StoreKind, value: Map<String, Value>) -> Result<(), StorageError> {
self.mutate(kind, |snapshot| {
*snapshot = value;
true
})
}
fn mutate(
&self,
kind: StoreKind,
mutate: impl FnOnce(&mut Map<String, Value>) -> bool,
) -> Result<(), StorageError> {
let _access = self.access()?;
let mut cache = self.file(kind).lock().expect("extension storage lock");
let mut snapshot = self.refresh(kind, &mut cache)?.clone();
if !mutate(&mut snapshot) {
return Ok(());
}
let data = Zeroizing::new(serde_json::to_vec(&snapshot)?);
let path = self.directory.join(kind.filename());
if kind == StoreKind::Storage {
atomic_write(&path, &data)?;
} else {
let key = self.key(kind)?;
atomic_write(&path, &encrypt(&data, &key)?)?;
}
cache.identity = file_identity(&path)?;
cache.key_tag = self.cache_tag(kind);
cache.snapshot = Some(snapshot);
Ok(())
}
fn access(&self) -> Result<RwLockReadGuard<'_, bool>, StorageError> {
let guard = self
.closed
.read()
.expect("extension storage lifecycle lock");
if *guard {
Err(StorageError::Closed)
} else {
Ok(guard)
}
}
/// Wait for current reads/writes, then prevent stale handles from accessing
/// a replacement installation at the same data path.
pub(crate) fn close(&self) {
*self
.closed
.write()
.expect("extension storage lifecycle lock") = true;
}
}
pub(crate) fn valid_extension_id(id: &str) -> bool {
(1..=128).contains(&id.len())
&& (id.as_bytes()[0].is_ascii_lowercase() || id.as_bytes()[0].is_ascii_digit())
&& id.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte)
})
}
fn file_identity(path: &Path) -> Result<Option<Identity>, StorageError> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
if !metadata.is_file() {
return Err(StorageError::InvalidPath);
}
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
Ok(Some(Identity {
length: metadata.len(),
modified: metadata.modified().ok(),
#[cfg(unix)]
inode: metadata.ino(),
}))
}
fn read_optional(path: &Path) -> Result<Option<Vec<u8>>, StorageError> {
if file_identity(path)?.is_none() {
return Ok(None);
}
Ok(Some(fs::read(path)?))
}
fn parse_object(data: &[u8]) -> Result<Map<String, Value>, StorageError> {
match serde_json::from_slice(data)? {
Value::Object(value) => Ok(value),
Value::Null => Ok(Map::new()),
_ => Err(StorageError::InvalidObject),
}
}
pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), StorageError> {
file_identity(path)?;
let directory = path.parent().ok_or(StorageError::InvalidPath)?;
let mut file = tempfile::Builder::new()
.prefix(".spotiflac-")
.tempfile_in(directory)?;
file.write_all(data)?;
file.as_file().sync_all()?;
file.persist(path).map_err(|error| error.error)?;
// Directory fsync is unavailable on some filesystems; rename remains atomic.
if let Ok(directory) = File::open(directory) {
let _ = directory.sync_all();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retired_store_cannot_read_or_overwrite_a_reinstalled_extension() {
let directory = tempfile::tempdir().unwrap();
let key = Arc::new(
StorageMasterKey::from_base64("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").unwrap(),
);
let old = ExtensionStore::open(
directory.path(),
"example.extension",
Some(Arc::clone(&key)),
)
.unwrap();
old.set(StoreKind::Storage, "old", true.into()).unwrap();
old.close();
fs::remove_file(directory.path().join("storage.json")).unwrap();
let replacement =
ExtensionStore::open(directory.path(), "example.extension", Some(key)).unwrap();
for kind in [
StoreKind::Storage,
StoreKind::Credentials,
StoreKind::Settings,
] {
replacement.set(kind, "new", true.into()).unwrap();
assert!(matches!(old.get(kind, "new"), Err(StorageError::Closed)));
assert!(matches!(old.all(kind), Err(StorageError::Closed)));
assert!(matches!(
old.set(kind, "stale", true.into()),
Err(StorageError::Closed)
));
assert!(matches!(old.remove(kind, "new"), Err(StorageError::Closed)));
assert_eq!(
replacement.all(kind).unwrap(),
Map::from_iter([("new".into(), true.into())])
);
}
}
#[test]
fn encrypted_snapshots_are_scoped_to_master_key() {
let directory = tempfile::tempdir().unwrap();
let key = Arc::new(
StorageMasterKey::from_base64("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").unwrap(),
);
let other_key = Arc::new(
StorageMasterKey::from_base64("AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=").unwrap(),
);
let store = ExtensionStore::open(
directory.path(),
"example.extension",
Some(Arc::clone(&key)),
)
.unwrap();
store
.set(StoreKind::Credentials, "token", "secret".into())
.unwrap();
store
.set(StoreKind::Settings, "theme", "dark".into())
.unwrap();
let other =
ExtensionStore::open(directory.path(), "example.extension", Some(other_key)).unwrap();
for kind in [StoreKind::Credentials, StoreKind::Settings] {
assert!(matches!(other.all(kind), Err(StorageError::Authentication)));
}
let missing = ExtensionStore::open(directory.path(), "example.extension", None).unwrap();
for kind in [StoreKind::Credentials, StoreKind::Settings] {
assert!(matches!(missing.all(kind), Err(StorageError::MissingKey)));
}
}
}
@@ -0,0 +1,133 @@
//! Manifest-declared download policy. Execution belongs to the download manager.
use serde::Serialize;
use serde_json::{Map, Value};
use std::collections::BTreeSet;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadTransferPolicy {
pub max_attempts: i64,
pub initial_retry_delay_ms: i64,
pub max_retry_delay_ms: i64,
pub resume_policy: String,
pub persistent_checkpoint: bool,
pub refresh_stream_on_status: BTreeSet<i64>,
pub max_parallel_segments: i64,
pub max_concurrent_downloads: i64,
}
impl Default for DownloadTransferPolicy {
fn default() -> Self {
Self {
max_attempts: 3,
initial_retry_delay_ms: 500,
max_retry_delay_ms: 8000,
resume_policy: "none".to_owned(),
persistent_checkpoint: false,
refresh_stream_on_status: BTreeSet::from([401, 403]),
max_parallel_segments: 3,
max_concurrent_downloads: 3,
}
}
}
fn number(value: Option<&Value>, fallback: i64) -> i64 {
value.and_then(Value::as_f64).map_or(fallback, rounded_int)
}
pub(crate) fn rounded_int(value: f64) -> i64 {
let rounded = value.round();
// Go uses the target's conversion: ARM saturates, while x86 returns
// MinInt on overflow. Keep the native Go int width on ARM32 as well.
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
if rounded >= isize::MAX as f64 || rounded < isize::MIN as f64 {
return isize::MIN as i64;
}
(rounded as isize) as i64
}
impl DownloadTransferPolicy {
pub fn from_capabilities(capabilities: &Map<String, Value>) -> Self {
let mut policy = Self::default();
let Some(config) = capabilities
.get("downloadTransfer")
.and_then(Value::as_object)
else {
return policy;
};
policy.max_attempts = number(config.get("maxAttempts"), policy.max_attempts).clamp(1, 8);
policy.initial_retry_delay_ms = number(
config.get("initialRetryDelayMs"),
policy.initial_retry_delay_ms,
)
.clamp(100, 30_000);
policy.max_retry_delay_ms =
number(config.get("maxRetryDelayMs"), policy.max_retry_delay_ms)
.clamp(policy.initial_retry_delay_ms, 120_000);
if let Some(resume) = config.get("resumePolicy").and_then(Value::as_str) {
let resume = resume.trim().to_lowercase();
if matches!(resume.as_str(), "none" | "validated") {
policy.resume_policy = resume;
}
}
policy.persistent_checkpoint = config.get("persistentCheckpoint")
== Some(&Value::Bool(true))
&& policy.resume_policy == "validated";
if let Some(statuses) = config
.get("refreshStreamOnStatus")
.and_then(Value::as_array)
{
let statuses: BTreeSet<_> = statuses
.iter()
.map(|value| number(Some(value), 0))
.filter(|value| (400..=599).contains(value))
.collect();
if !statuses.is_empty() {
policy.refresh_stream_on_status = statuses;
}
}
policy.max_parallel_segments = number(
config.get("maxParallelSegments"),
policy.max_parallel_segments,
)
.clamp(1, 8);
policy.max_concurrent_downloads = number(
config.get("maxConcurrentDownloads"),
policy.max_concurrent_downloads,
)
.clamp(1, 3);
policy
}
}
pub(crate) fn validate(capabilities: &Map<String, Value>) -> Result<(), String> {
let Some(raw) = capabilities.get("downloadTransfer") else {
return Ok(());
};
let config = raw.as_object().ok_or("must be an object")?;
if let Some(resume) = config.get("resumePolicy")
&& !matches!(resume.as_str(), Some("none" | "validated"))
{
return Err("resumePolicy must be 'none' or 'validated'".to_owned());
}
if let Some(checkpoint) = config.get("persistentCheckpoint")
&& !checkpoint.is_boolean()
{
return Err("persistentCheckpoint must be a boolean".to_owned());
}
for key in [
"maxAttempts",
"initialRetryDelayMs",
"maxRetryDelayMs",
"maxParallelSegments",
"maxConcurrentDownloads",
] {
if let Some(value) = config.get(key)
&& number(Some(value), -1) < 0
{
return Err(format!("{key} must be a non-negative number"));
}
}
Ok(())
}
@@ -0,0 +1,123 @@
use crate::runtime::{Control, ExtensionServices};
use rquickjs::{Ctx, Function, Object, Value};
use spotiflac_core::matching;
pub(crate) use spotiflac_network::random_user_agent;
use std::sync::Arc;
pub(crate) fn register<'js>(
ctx: &Ctx<'js>,
host: &Object<'js>,
control: Arc<Control>,
services: &ExtensionServices,
) -> rquickjs::Result<()> {
let logs = Arc::clone(&services.logs);
let id = services.extension_id.clone();
host.set(
"managedConsole",
services.load_mode != crate::runtime::LoadMode::Initialize,
)?;
let auth = services.auth_registry.clone();
let provider_id = services.extension_id.clone();
host.set(
"providerPendingVerification",
Function::new(ctx.clone(), move || {
auth.as_ref()
.filter(|auth| auth.has_fresh_challenge(&provider_id))
.map(|_| provider_id.clone())
})?,
)?;
let progress = Arc::clone(&services.downloads.progress);
let progress_control = Arc::clone(&control);
host.set(
"providerProgress",
Function::new(ctx.clone(), move |percent: i32| {
let id = progress_control.item_id();
if !id.is_empty() && progress_control.check().is_ok() {
let _ = progress.set_progress(&id, f64::from(percent) / 100.0, 0, 0);
}
})?,
)?;
host.set(
"providerInteger",
Function::new(ctx.clone(), crate::provider::integer)?,
)?;
host.set(
"providerTrim",
Function::new(ctx.clone(), |value: String| value.trim().to_owned())?,
)?;
host.set(
"providerAudioTraits",
Function::new(ctx.clone(), crate::provider::audio_traits)?,
)?;
host.set(
"extensionLog",
Function::new(
ctx.clone(),
move |level: String, values: Vec<String>, count: usize| {
logs.extension(&id, &level, values, count)
},
)?,
)?;
host.set(
"logOpaqueType",
Function::new(ctx.clone(), |value: Value<'js>| {
if value.is_proxy() {
"<goja.Proxy>"
} else if value.is_promise() {
"<*goja.Promise>"
} else {
""
}
})?,
)?;
let comparison = Arc::clone(&control);
host.set(
"compareStrings",
Function::new(ctx.clone(), move |first: String, second: String| {
// The call boundary turns cancellation/timeout into its typed error.
matching::compare_strings(&first, &second, &|| {
comparison.check().map_err(|e| e.to_string())
})
.unwrap_or(0.0)
})?,
)?;
host.set(
"normalizeMatching",
Function::new(ctx.clone(), |value: String| matching::normalize(&value))?,
)?;
host.set(
"compareDuration",
Function::new(ctx.clone(), |first: f64, second: f64, tolerance: f64| {
// Goja's ToInteger clips infinities and overflow to the i64 endpoints.
matching::compare_duration(first as i64, second as i64, tolerance as i64)
})?,
)?;
let downloads = Arc::clone(&services.downloads);
let download_control = Arc::clone(&control);
host.set(
"downloadCancelled",
Function::new(ctx.clone(), move || {
let id = download_control.item_id();
!id.is_empty() && downloads.cancellation.is_cancelled(&id).unwrap_or(false)
})?,
)?;
host.set(
"requestCancelled",
Function::new(ctx.clone(), move || control.request_cancelled())?,
)?;
let version = services.app_version.clone();
host.set(
"appVersion",
Function::new(ctx.clone(), move || version.get())?,
)?;
let version = services.app_version.clone();
host.set(
"appUserAgent",
Function::new(ctx.clone(), move || version.user_agent())?,
)?;
host.set(
"randomUserAgent",
Function::new(ctx.clone(), random_user_agent)?,
)?;
Ok(())
}