//! 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> = 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>, stores: BTreeMap>, lyrics_nodes: BTreeMap, } /// 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, ffmpeg: Arc, downloads: Arc, data_directory: PathBuf, data_directory_alias: PathBuf, master_key: Arc, app_version: AppVersion, network: Arc, auth: Arc, sessions: Arc, files: Arc, isrc: Arc, closed: Arc, state: Mutex, idle: Condvar, shutdown_lock: Mutex<()>, manager_owned: AtomicBool, calls: CallGraph, lyrics: OnceLock>, } impl ExtensionEnvironment { pub fn new( data_directory: &Path, master_key: &str, app_version: &str, ) -> Result { // 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, ) -> Result { 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, 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, 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, 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 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, EnvironmentError> { let _operation = self.enter()?; Ok(Arc::clone(&self.network)) } pub fn get_app_version(&self) -> Result { let _operation = self.enter()?; Ok(self.app_version.get()) } 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, ) -> 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, 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>, ) -> Result, 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, 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>, ) -> Result, 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) { 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 { 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, 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 { 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 { Arc::clone(&self.logs) } pub fn repository( &self, directory: &Path, ) -> Result { 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 { Arc::clone(&self.ffmpeg) } pub fn download_state(&self) -> Arc { 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, EnvironmentError> { let _operation = self.enter()?; Ok(self.auth.pending(id.trim())) } pub fn resolve_callback_state( &self, state: &str, consume: bool, ) -> Result { 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) } } fn network_timeout(value: Option<&Value>) -> Duration { let seconds = match value { Some(Value::String(value)) => value.trim().parse::().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, ) -> 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::() .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 + '_ { 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; } } }