//! GitHub-native plugin sources. Discovery reads metadata, never package bytes. use crate::{jobs::Cancellation, model::*}; use anyhow::{ensure, Context, Result}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::{ collections::{BTreeMap, BTreeSet}, fs, io::Write, path::{Path, PathBuf}, sync::Mutex, time::Duration, }; const TOPIC: &str = "topic:framely-plugin archived:false fork:false"; const TTL: u64 = 600_000; const MAX_PAGES: usize = 10; static REQUESTS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); struct RequestPermit; impl RequestPermit { fn acquire(cancel: &Cancellation) -> Result { use std::sync::atomic::Ordering; loop { cancel.check()?; if REQUESTS .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| { (n < 6).then_some(n + 1) }) .is_ok() { return Ok(Self); } std::thread::sleep(Duration::from_millis(10)); } } } impl Drop for RequestPermit { fn drop(&mut self) { REQUESTS.fetch_sub(1, std::sync::atomic::Ordering::Release); } } #[derive(Clone, Deserialize, Serialize)] struct Asset { name: String, browser_download_url: String, size: u64, #[serde(default)] digest: Option, } #[derive(Deserialize)] struct Release { tag_name: String, draft: bool, prerelease: bool, assets: Vec, } #[derive(Deserialize, Serialize)] struct Cached { at: u64, etag: Option, body: String, } pub struct Client<'a> { proxy: &'a ProxySettings, cancel: &'a Cancellation, root: PathBuf, refresh: bool, pub warnings: Mutex>, } impl<'a> Client<'a> { pub fn system( proxy: &'a ProxySettings, cancel: &'a Cancellation, refresh: bool, ) -> Result { Ok(Self::new( proxy, cancel, crate::steam::home()?.join(".cache/framely/github-plugins"), refresh, )) } fn new( proxy: &'a ProxySettings, cancel: &'a Cancellation, root: PathBuf, refresh: bool, ) -> Self { Self { proxy, cancel, root, refresh, warnings: Mutex::default(), } } fn warn(&self, message: String) { self.warnings.lock().unwrap().push(message); } fn text(&self, url: &str, limit: usize) -> Result { self.cancel.check()?; let path = self .root .join(format!("{}.json", crate::package::digest(url.as_bytes()))); let saved = fs::metadata(&path) .ok() .filter(|m| m.is_file() && m.len() <= (limit as u64 * 6 + 4096)) .and_then(|_| fs::read(&path).ok()) .and_then(|v| serde_json::from_slice::(&v).ok()) .filter(|v| v.body.len() <= limit); if !self.refresh { if let Some(value) = &saved { if crate::service::now_ms().saturating_sub(value.at) < TTL { return Ok(value.body.clone()); } } } let _permit = RequestPermit::acquire(self.cancel)?; let mut headers = vec![ ("User-Agent", "Framely"), ("Accept", "application/vnd.github+json"), ]; if let Some(etag) = saved .as_ref() .and_then(|v| v.etag.as_deref()) .filter(|v| v.len() <= 1024 && !v.contains(['\r', '\n'])) { headers.push(("If-None-Match", etag)); } let mut retryable = false; let result = crate::http::with_github_fallback(url, &headers, self.proxy, |proxy| { let attempt = (|| { let response = crate::http::get_with_proxy_cancel( url, false, Duration::from_secs(15), &headers, proxy, self.cancel, )?; if response.status() == 304 { let value = saved.as_ref().context("Unexpected GitHub 304")?; return Ok(Cached { at: crate::service::now_ms(), etag: value.etag.clone(), body: value.body.clone(), }); } let etag = response.header("ETag").map(str::to_owned); let bytes = crate::http::read_cancel(response, limit, self.cancel)?; Ok(Cached { at: crate::service::now_ms(), etag, body: String::from_utf8(bytes)?, }) })(); if let Err(error) = &attempt { retryable = crate::http::github_fallback::retryable(error); } attempt }); self.cancel.check()?; match result { Ok(value) => { // Cache failures do not prevent browsing; write atomically. let _ = (|| -> Result<()> { fs::create_dir_all(&self.root)?; let mut file = tempfile::NamedTempFile::new_in(&self.root)?; file.write_all(&serde_json::to_vec(&value)?)?; file.as_file().sync_all()?; file.persist(&path)?; Ok(()) })(); Ok(value.body) } Err(error) => { // Never conceal changed/missing releases, certificates or bad // JSON behind old metadata. Only network failures use cache. if retryable { if let Some(value) = saved { self.warn(format!( "GitHub: {} (cached)", crate::http::github_fallback::diagnostic(&error) )); return Ok(value.body); } } Err(error) } } } fn json(&self, url: &str, limit: usize) -> Result { Ok(serde_json::from_str(&self.text(url, limit)?)?) } pub fn discover(&self) -> Result> { let mut sources = vec![]; let mut seen = BTreeSet::new(); for page in 1..=MAX_PAGES { self.cancel.check()?; let mut url = url::Url::parse("https://api.github.com/search/repositories")?; url.query_pairs_mut() .append_pair("q", TOPIC) .append_pair("sort", "stars") .append_pair("order", "desc") .append_pair("per_page", "100") .append_pair("page", &page.to_string()); let value = self.json(url.as_str(), 4 * 1024 * 1024)?; ensure!( value["incomplete_results"] != true, "Incomplete GitHub plugin search" ); let items = value["items"] .as_array() .context("Invalid GitHub plugin search")?; ensure!(items.len() <= 100, "Invalid GitHub search page size"); for item in items { if item["archived"] != false || item["fork"] != false || !item["topics"] .as_array() .is_some_and(|v| v.iter().any(|t| t == "framely-plugin")) { continue; } if let Some(repo) = item["full_name"].as_str() { if let Ok(source) = source(repo) { if seen.insert(source.id.clone()) { sources.push(source); } } } } let total = value["total_count"].as_u64().unwrap_or(0); if items.len() < 100 || (page * 100) as u64 >= total { return Ok(sources); } } self.warn("GitHub search reached its 1000-repository limit".into()); Ok(sources) } pub fn entries(&self, repo: &str) -> Result> { source(repo)?; let mut releases = vec![]; for page in 1..=MAX_PAGES { let values = self.json( &format!("https://api.github.com/repos/{repo}/releases?per_page=100&page={page}"), 8 * 1024 * 1024, )?; let values = values.as_array().context("Invalid GitHub releases")?; ensure!(values.len() <= 100, "Invalid GitHub release page size"); for value in values { let release: Release = serde_json::from_value(value.clone())?; if !release.draft { releases.push(release); } } if values.len() < 100 { break; } ensure!( page < MAX_PAGES, "GitHub release history exceeds 1000 entries" ); } let mut entries = vec![]; for chunk in releases.chunks(6) { self.cancel.check()?; let results = std::thread::scope(|scope| { chunk .iter() .map(|release| scope.spawn(move || self.release(repo, release))) .collect::>() .into_iter() .map(|h| { h.join().unwrap_or_else(|_| { Err(anyhow::anyhow!("Release metadata worker failed")) }) }) .collect::>() }); self.cancel.check()?; for (release, result) in chunk.iter().zip(results) { match result { Ok(mut values) => entries.append(&mut values), Err(error) => self.warn(format!( "{repo} {}: {}", release.tag_name, crate::http::github_fallback::diagnostic(&error) )), } } } entries.sort_by(|a, b| { version_cmp(&b.version, &a.version) .then_with(|| a.id.cmp(&b.id)) .then_with(|| a.sha256.cmp(&b.sha256)) }); entries.dedup_by(|a, b| a.id == b.id && a.version == b.version && a.sha256 == b.sha256); Ok(entries) } fn release(&self, repo: &str, release: &Release) -> Result> { let mut names = BTreeSet::new(); ensure!( release.assets.iter().all(|a| names.insert(&a.name)), "Duplicate release asset names" ); if !release.assets.iter().any(|a| a.name.ends_with(".framely")) { return Ok(vec![]); } for asset in release.assets.iter().filter(|a| { a.name.ends_with(".framely") || matches!(a.name.as_str(), "release.json" | "SHA256SUMS") }) { asset.validate(repo, &release.tag_name)?; } let checksums = if let Some(asset) = release.assets.iter().find(|a| a.name == "SHA256SUMS") { Some(self.text(&asset.browser_download_url, 1024 * 1024)?) } else { None }; if let Some(asset) = release.assets.iter().find(|a| a.name == "release.json") { let value = self.json(&asset.browser_download_url, 2 * 1024 * 1024)?; return parse_descriptor(value, repo, release, checksums.as_deref()); } // Old releases are bound to the tag's resolved commit, never HEAD. let encoded: String = url::form_urlencoded::byte_serialize(release.tag_name.as_bytes()).collect(); let commit = self.json( &format!("https://api.github.com/repos/{repo}/commits/{encoded}"), 2 * 1024 * 1024, )?; let sha = commit["sha"].as_str().context("Missing release commit")?; ensure!( sha.len() == 40 && sha.bytes().all(|c| c.is_ascii_hexdigit()), "Invalid release commit" ); let manifest = self.json( &format!("https://raw.githubusercontent.com/{repo}/{sha}/manifest.json"), 2 * 1024 * 1024, )?; let version = manifest["version"] .as_str() .context("Missing legacy version")?; ensure!( release .tag_name .strip_prefix('v') .unwrap_or(&release.tag_name) == version, "Legacy release tag/version mismatch" ); let id = manifest["id"].as_str().context("Missing plugin ID")?; let filename = format!("{id}-{version}.framely"); let asset = if let Some(url) = manifest["downloadUrl"].as_str() { release .assets .iter() .find(|a| a.browser_download_url == url) } else { release.assets.iter().find(|a| a.name == filename) } .context("Missing legacy package asset")?; ensure!( asset.name.ends_with(".framely"), "Legacy downloadUrl does not select a plugin package" ); asset.validate(repo, &release.tag_name)?; let digest = asset_hash(asset, checksums.as_deref())?.context("Legacy release has no SHA256")?; if let Some(declared) = manifest["downloadSha256"].as_str() { ensure!( declared.eq_ignore_ascii_case(&digest), "Legacy SHA256 mismatch" ); } let mut value = manifest.clone(); value["runAs"] = if manifest.get("backend").is_some_and(|b| !b.is_null()) || manifest.get("lifecycle").is_some_and(|b| !b.is_null()) { json!(manifest["backend"]["runAs"] .as_str() .or_else(|| manifest["lifecycle"]["runAs"].as_str()) .unwrap_or("steamos")) } else { Value::Null }; value["url"] = json!(asset.browser_download_url); value["sha256"] = json!(digest); value["icon"] = match manifest["publish"]["icon"].as_str() { Some(url) => json!(url), None => manifest["icon"] .as_str() .map(|p| pinned_image(repo, sha, p)) .transpose()? .map_or(Value::Null, |s| json!(s)), }; value["screenshots"] = manifest["publish"]["screenshots"] .as_array() .cloned() .map_or(json!([]), |v| json!(v)); let mut entry: CatalogEntry = serde_json::from_value(value)?; validate_entry(&mut entry, release.prerelease)?; Ok(vec![entry]) } } impl Asset { fn validate(&self, repo: &str, tag: &str) -> Result<()> { ensure!( !self.name.is_empty() && !self.name.contains(['/', '\\', '\r', '\n']) && self.size > 0 && self.size <= 2 * 1024 * 1024 * 1024, "Invalid release asset" ); let url = url::Url::parse(&self.browser_download_url)?; let prefix = format!("/{repo}/releases/download/{tag}/"); let path = percent_decode(url.path())?; ensure!( url.scheme() == "https" && url.host_str() == Some("github.com") && url.port_or_known_default() == Some(443) && url.username().is_empty() && url.password().is_none() && url.query().is_none() && url.fragment().is_none() && path == format!("{prefix}{}", self.name), "Release asset is outside its release" ); Ok(()) } } fn percent_decode(value: &str) -> Result { // form_urlencoded decodes '+' as space; preserve literal '+' in URL paths. let encoded = format!("p={}", value.replace('+', "%2B")); Ok(url::form_urlencoded::parse(encoded.as_bytes()) .next() .context("Invalid URL path")? .1 .into_owned()) } fn pinned_image(repo: &str, commit: &str, path: &str) -> Result { crate::model::safe_path(path)?; Ok(format!( "https://raw.githubusercontent.com/{repo}/{commit}/{path}" )) } pub fn repository(source: &Source) -> Option { if source.allow_http { return None; } let url = url::Url::parse(&source.url).ok()?; if url.scheme() != "https" || url.host_str() != Some("github.com") || url.port_or_known_default() != Some(443) || !url.username().is_empty() || url.password().is_some() || url.query().is_some() || url.fragment().is_some() { return None; } let repo = url.path().trim_matches('/'); valid_repo(repo).ok()?; Some(repo.to_owned()) } fn valid_repo(repo: &str) -> Result<()> { let parts: Vec<_> = repo.split('/').collect(); ensure!( parts.len() == 2 && parts.iter().all(|p| !p.is_empty() && p.len() <= 100 && *p != "." && *p != ".." && p.bytes() .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c))), "Invalid GitHub plugin repository" ); Ok(()) } pub fn source(repo: &str) -> Result { valid_repo(repo)?; Ok(Source { id: format!( "github-{}", &crate::package::digest(repo.to_ascii_lowercase().as_bytes())[..20] ), name: repo.chars().take(120).collect(), url: format!("https://github.com/{repo}"), enabled: true, allow_http: false, }) } fn hex_hash(value: &str) -> Result { ensure!( value.len() == 64 && value.bytes().all(|c| c.is_ascii_hexdigit()), "Invalid release SHA256" ); Ok(value.to_ascii_lowercase()) } fn asset_hash(asset: &Asset, checksums: Option<&str>) -> Result> { let digest = asset .digest .as_deref() .map(|v| { hex_hash( v.strip_prefix("sha256:") .context("Unsupported asset digest")?, ) }) .transpose()?; let mut checksum = None; if let Some(text) = checksums { for line in text.lines().filter(|l| !l.trim().is_empty()) { ensure!( line.len() > 66 && line.is_char_boundary(64) && line.is_char_boundary(66), "Invalid SHA256SUMS" ); let bytes = line.as_bytes(); ensure!( bytes[64] == b' ' && matches!(bytes[65], b' ' | b'*'), "Invalid SHA256SUMS" ); let hash = hex_hash(&line[..64])?; if line[66..] == asset.name { ensure!(checksum.is_none(), "Duplicate SHA256SUMS entry"); checksum = Some(hash); } } ensure!(checksum.is_some(), "Package missing from SHA256SUMS"); } if let (Some(a), Some(b)) = (&digest, &checksum) { ensure!(a == b, "Asset digest/SHA256SUMS mismatch"); } Ok(digest.or(checksum)) } fn validate_entry(entry: &mut CatalogEntry, prerelease: bool) -> Result<()> { let version = semver::Version::parse(&entry.version)?; entry.validate(false)?; entry.compatible = Some( entry.api_version == API_VERSION && entry .engines .as_ref() .is_none_or(|e| e.matches(env!("CARGO_PKG_VERSION"))), ); // GitHub's prerelease flag is retained separately from SemVer, including // releases tagged stable but marked prerelease in the API. entry.prerelease = prerelease || !version.pre.is_empty(); Ok(()) } fn parse_descriptor( value: Value, repo: &str, release: &Release, checksums: Option<&str>, ) -> Result> { ensure!( value["schemaVersion"] == 1, "Unsupported release.json schema" ); let plugins = value["plugins"] .as_array() .context("Missing release.json plugins")?; ensure!( !plugins.is_empty() && plugins.len() <= 100, "Invalid release.json plugin count" ); let mut ids = BTreeSet::new(); let mut entries = vec![]; for plugin in plugins { ensure!( plugin.get("runAs").is_some(), "release.json must declare runAs" ); let asset_name = plugin["asset"].as_str().context("Missing package asset")?; ensure!( asset_name.ends_with(".framely"), "Invalid plugin package extension" ); let asset = release .assets .iter() .find(|a| a.name == asset_name) .context("release.json package missing")?; asset.validate(repo, &release.tag_name)?; ensure!( plugin["size"].as_u64() == Some(asset.size), "release.json package size mismatch" ); let hash = hex_hash(plugin["sha256"].as_str().context("Missing SHA256")?)?; if let Some(actual) = asset_hash(asset, checksums)? { ensure!(actual == hash, "release.json SHA256 mismatch"); } let image = |value: &str| -> Result { if let Some(asset) = release.assets.iter().find(|a| a.name == value) { asset.validate(repo, &release.tag_name)?; return Ok(asset.browser_download_url.clone()); } validate_url(value, false)?; Ok(value.into()) }; let mut normalized = plugin.clone(); normalized["url"] = json!(asset.browser_download_url); normalized["sha256"] = json!(hash); if let Some(icon) = plugin["icon"].as_str() { normalized["icon"] = json!(image(icon)?); } if let Some(screenshots) = plugin["screenshots"].as_array() { normalized["screenshots"] = json!(screenshots .iter() .map(|v| image(v.as_str().context("Invalid screenshot")?)) .collect::>>()?); } let mut entry: CatalogEntry = serde_json::from_value(normalized)?; ensure!( ids.insert(entry.id.clone()), "Duplicate release.json plugin ID" ); validate_entry(&mut entry, release.prerelease)?; entries.push(entry); } Ok(entries) } pub fn version_cmp(a: &str, b: &str) -> std::cmp::Ordering { match (semver::Version::parse(a), semver::Version::parse(b)) { (Ok(a), Ok(b)) => a.cmp_precedence(&b), _ => a.cmp(b), } } pub fn catalog(name: &str, entries: &[CatalogEntry]) -> Catalog { let mut groups = BTreeMap::>::new(); for entry in entries { groups .entry(entry.id.clone()) .or_default() .push(entry.clone()); } Catalog { schema_version: 1, name: name.into(), plugins: groups .into_values() .map(|versions| { versions .iter() .find(|v| v.compatible == Some(true)) .unwrap_or(&versions[0]) .clone() }) .collect(), } } pub fn check_package(entry: &CatalogEntry, manifest: &Manifest) -> Result<()> { ensure!( entry.id == manifest.id && entry.version == manifest.version && entry.api_version == manifest.api_version && serde_json::to_value(&entry.engines)? == serde_json::to_value(&manifest.engines)? && entry.run_as == (manifest.backend.is_some() || manifest.lifecycle.is_some()) .then(|| manifest.run_as()) && serde_json::to_value(entry.relations())? == serde_json::to_value(manifest.relations())?, "Plugin package declarations differ from GitHub release metadata" ); Ok(()) } #[cfg(test)] mod tests { use super::*; struct Mock { routes: crate::http::github_fallback::TestRoutes, responses: std::sync::Arc>>, seen: std::sync::Arc>>, stop: std::sync::Arc, worker: Option>, } impl Mock { fn new(routes: Vec<(String, u16, String)>) -> Self { let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); let address = format!("http://{}", server.server_addr()); let responses = std::sync::Arc::new(Mutex::new(BTreeMap::new())); let mut urls = vec![]; for (i, (url, status, body)) in routes.into_iter().enumerate() { let path = format!("/{i}"); responses .lock() .unwrap() .insert(path.clone(), (status, body)); urls.push((url, format!("{address}{path}"))); } let seen = std::sync::Arc::new(Mutex::new(vec![])); let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let worker = { let responses = responses.clone(); let seen = seen.clone(); let stop = stop.clone(); std::thread::spawn(move || { while !stop.load(std::sync::atomic::Ordering::Acquire) { if let Some(request) = server.recv_timeout(Duration::from_millis(20)).unwrap() { let conditional = request.headers().iter().any(|h| { h.field.equiv("If-None-Match") && h.value.as_str() == "\"test\"" }); seen.lock() .unwrap() .push((request.url().into(), conditional)); let (status, body) = responses .lock() .unwrap() .get(request.url()) .unwrap() .clone(); request .respond( tiny_http::Response::from_string(body) .with_status_code(status) .with_header( tiny_http::Header::from_bytes("ETag", "\"test\"") .unwrap(), ), ) .unwrap(); } } }) }; Self { routes: crate::http::github_fallback::TestRoutes::new(urls), responses, seen, stop, worker: Some(worker), } } } impl Drop for Mock { fn drop(&mut self) { self.stop.store(true, std::sync::atomic::Ordering::Release); self.worker.take().unwrap().join().unwrap(); let _ = &self.routes; } } fn asset(repo: &str, tag: &str, name: &str, hash: Option<&str>) -> Asset { Asset { name: name.into(), browser_download_url: format!( "https://github.com/{repo}/releases/download/{tag}/{name}" ), size: 123, digest: hash.map(|h| format!("sha256:{h}")), } } fn plugin(version: &str) -> Value { json!({"id":"example.test","name":"Test","version":version,"apiVersion":1,"author":"Author","description":"Test plugin", "asset":"plugin.framely","size":123,"sha256":"a".repeat(64),"runAs":null}) } #[test] fn release_descriptor_validates_identity_hash_size_and_image_assets() { let release = Release { tag_name: "v1.0.0".into(), draft: false, prerelease: false, assets: vec![ asset( "example/test", "v1.0.0", "plugin.framely", Some(&"a".repeat(64)), ), asset("example/test", "v1.0.0", "icon.png", None), ], }; let mut p = plugin("1.0.0"); p["icon"] = json!("icon.png"); let entry = parse_descriptor( json!({"schemaVersion":1,"plugins":[p]}), "example/test", &release, None, ) .unwrap() .remove(0); assert!(entry.icon.unwrap().ends_with("/v1.0.0/icon.png")); assert_eq!(entry.compatible, Some(true)); for change in [ json!({"size":122}), json!({"sha256":"b".repeat(64)}), json!({"asset":"missing.framely"}), json!({"icon":"../icon.png"}), json!({"version":"invalid"}), ] { let mut p = plugin("1.0.0"); for (k, v) in change.as_object().unwrap() { p[k] = v.clone(); } assert!(parse_descriptor( json!({"schemaVersion":1,"plugins":[p]}), "example/test", &release, None ) .is_err()); } let mut p = plugin("1.0.0"); p.as_object_mut().unwrap().remove("runAs"); assert!(parse_descriptor( json!({"schemaVersion":1,"plugins":[p]}), "example/test", &release, None ) .is_err()); assert!(parse_descriptor( json!({"schemaVersion":1,"plugins":[plugin("1.0.0"),plugin("1.0.0")]}), "example/test", &release, None ) .is_err()); assert!(parse_descriptor( json!({"schemaVersion":2,"plugins":[plugin("1.0.0")]}), "example/test", &release, None ) .is_err()); assert!(parse_descriptor( json!({"schemaVersion":1,"plugins":[plugin("1.0.0")]}), "example/test", &release, Some(&format!("{} plugin.framely\n", "b".repeat(64))) ) .is_err()); assert!(source("../escape").is_err()); assert!(source("owner/repo?token=x").is_err()); assert!(repository(&Source { url: "https://github.com.evil/owner/repo".into(), ..source("owner/repo").unwrap() }) .is_none()); } #[test] fn release_version_precedence_and_package_metadata_checks() { assert!(version_cmp("1.0.0-preview.10", "1.0.0-preview.9").is_gt()); assert!(version_cmp("1.0.0", "1.0.0-preview.10").is_gt()); let release = Release { tag_name: "v1.0.0".into(), draft: false, prerelease: true, assets: vec![asset("example/test", "v1.0.0", "plugin.framely", None)], }; let entry = parse_descriptor( json!({"schemaVersion":1,"plugins":[plugin("1.0.0")]}), "example/test", &release, None, ) .unwrap() .remove(0); assert!(entry.prerelease); let mut manifest: Manifest=serde_json::from_value(json!({"schemaVersion":1,"apiVersion":1,"id":"example.test","name":"Test","version":"1.0.0","author":"Author","files":{},"ui":{}})).unwrap(); check_package(&entry, &manifest).unwrap(); manifest.api_version = 2; assert!(check_package(&entry, &manifest).is_err()); manifest.api_version = 1; manifest.dependencies.insert( "example.dep".into(), crate::relations::Dependency::Version("^1.0.0".into()), ); assert!(check_package(&entry, &manifest).is_err()); } #[test] fn github_metadata_cache_revalidates_and_only_network_failures_use_stale_data() { let url = "https://api.github.com/repos/cache/test/releases?per_page=100&page=1"; let mock = Mock::new(vec![(url.into(), 200, "[]".into())]); let root = tempfile::tempdir().unwrap(); let cancel = Cancellation::default(); let proxy = ProxySettings { github_auto_fallback: false, ..Default::default() }; let client = Client::new(&proxy, &cancel, root.path().into(), false); assert_eq!(client.text(url, 100).unwrap(), "[]"); assert_eq!(client.text(url, 100).unwrap(), "[]"); assert_eq!(mock.seen.lock().unwrap().len(), 1); mock.responses .lock() .unwrap() .insert("/0".into(), (304, String::new())); let refresh = Client::new(&proxy, &cancel, root.path().into(), true); assert_eq!(refresh.text(url, 100).unwrap(), "[]"); assert!(mock.seen.lock().unwrap()[1].1); mock.responses .lock() .unwrap() .insert("/0".into(), (503, "unavailable".into())); assert_eq!(refresh.text(url, 100).unwrap(), "[]"); assert_eq!(refresh.warnings.lock().unwrap().len(), 1); mock.responses .lock() .unwrap() .insert("/0".into(), (404, "missing".into())); assert!(refresh.text(url, 100).is_err()); cancel.stop(); assert!(client.text(url, 100).is_err()); } #[test] fn github_discovery_paginates_and_filters_forks_archives_and_missing_topics() { let url = |page: usize| { let mut url = url::Url::parse("https://api.github.com/search/repositories").unwrap(); url.query_pairs_mut() .append_pair("q", TOPIC) .append_pair("sort", "stars") .append_pair("order", "desc") .append_pair("per_page", "100") .append_pair("page", &page.to_string()); url.to_string() }; let item = |name: String| json!({"full_name":name,"archived":false,"fork":false,"topics":["framely-plugin"]}); let first: Vec<_> = (0..100) .map(|i| item(format!("fixture/plugin-{i}"))) .collect(); let mut fork = item("fixture/fork".into()); fork["fork"] = json!(true); let mut archived = item("fixture/archive".into()); archived["archived"] = json!(true); let mut other = item("fixture/other".into()); other["topics"] = json!([]); let mock=Mock::new(vec![(url(1),200,json!({"total_count":104,"incomplete_results":false,"items":first}).to_string()), (url(2),200,json!({"total_count":104,"incomplete_results":false,"items":[fork,archived,other,item("fixture/final".into())]}).to_string())]); let root = tempfile::tempdir().unwrap(); let cancel = Cancellation::default(); let proxy = ProxySettings::default(); let results = Client::new(&proxy, &cancel, root.path().into(), false) .discover() .unwrap(); assert_eq!(results.len(), 101); assert_eq!(results.last().unwrap().name, "fixture/final"); assert_eq!(mock.seen.lock().unwrap().len(), 2); assert_eq!( source("Fixture/Final").unwrap().id, results.last().unwrap().id ); } #[test] fn github_releases_read_unsorted_new_and_legacy_metadata_without_downloading_packages() { let repo = "fixture/releases"; let commit = "c".repeat(40); let hash = "a".repeat(64); let releases = ["1.0.0-preview.9", "1.0.0-preview.10", "1.0.0"]; let mut pages = vec![]; let mut routes = vec![]; for version in releases { let tag = format!("v{version}"); let name = format!("example.test-{version}.framely"); let package = asset(repo, &tag, &name, Some(&hash)); let mut assets = vec![package]; if version == "1.0.0" { let manifest = json!({"schemaVersion":1,"apiVersion":1,"id":"example.test","name":"Test","version":version,"author":"Author","description":"Test plugin","icon":"icon.png","files":{},"ui":{}}); routes.push(( format!("https://api.github.com/repos/{repo}/commits/{tag}"), 200, json!({"sha":commit}).to_string(), )); routes.push(( format!("https://raw.githubusercontent.com/{repo}/{commit}/manifest.json"), 200, manifest.to_string(), )); } else { let descriptor = asset(repo, &tag, "release.json", None); let mut p = plugin(version); p["asset"] = json!(name); routes.push(( descriptor.browser_download_url.clone(), 200, json!({"schemaVersion":1,"plugins":[p]}).to_string(), )); assets.push(descriptor); } pages.push( json!({"tag_name":tag,"draft":false,"prerelease":version!="1.0.0","assets":assets}), ); } routes.push(( format!("https://api.github.com/repos/{repo}/releases?per_page=100&page=1"), 200, json!(pages).to_string(), )); let mock = Mock::new(routes); let root = tempfile::tempdir().unwrap(); let cancel = Cancellation::default(); let proxy = ProxySettings { github_auto_fallback: false, ..Default::default() }; let client = Client::new(&proxy, &cancel, root.path().into(), false); let entries = client.entries(repo).unwrap(); assert_eq!( entries .iter() .map(|p| p.version.as_str()) .collect::>(), vec!["1.0.0", "1.0.0-preview.10", "1.0.0-preview.9"] ); assert!(entries[0].icon.as_ref().unwrap().contains(&commit)); assert!(client.warnings.lock().unwrap().is_empty()); assert_eq!(mock.seen.lock().unwrap().len(), 5); assert_eq!(catalog("Test", &entries).plugins.len(), 1); } #[test] fn invalid_new_release_never_falls_back_to_legacy_manifest() { let repo = "fixture/invalid"; let release = Release { tag_name: "v1.0.0".into(), draft: false, prerelease: false, assets: vec![ asset(repo, "v1.0.0", "plugin.framely", None), asset(repo, "v1.0.0", "release.json", None), ], }; let mock = Mock::new(vec![( release.assets[1].browser_download_url.clone(), 200, "{broken".into(), )]); let root = tempfile::tempdir().unwrap(); let cancel = Cancellation::default(); let proxy = ProxySettings::default(); assert!(Client::new(&proxy, &cancel, root.path().into(), false) .release(repo, &release) .is_err()); assert_eq!(mock.seen.lock().unwrap().len(), 1); assert!(asset_hash(&release.assets[0], None).unwrap().is_none()); assert!(asset_hash( &release.assets[0], Some(&format!("{} plugin.framely", "a".repeat(64))) ) .unwrap() .is_some()); assert!(asset_hash( &release.assets[0], Some(&format!("{}é plugin.framely", "a".repeat(63))) ) .is_err()); } #[test] fn generated_release_uses_verified_packages_and_sources_wait_for_confirmation() { let root = tempfile::tempdir().unwrap(); crate::tests::fixture(root.path(), "example.test", "1.0.0", 0, None); let manifest_path = root.path().join("manifest-1.0.0-0.json"); let mut manifest: Value = serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap(); manifest["icon"] = json!("icon.png"); manifest["screenshots"] = json!(["icon.png"]); fs::write(&manifest_path, serde_json::to_vec(&manifest).unwrap()).unwrap(); let icon = fs::read("assets/branding/icons/64.png").unwrap(); fs::write(root.path().join("payload-1.0.0-0/icon.png"), &icon).unwrap(); let output = root.path().join("1.0.0-0.framely"); crate::package::pack( &manifest_path, &root.path().join("payload-1.0.0-0"), &output, ) .unwrap(); let bytes = fs::read(output).unwrap(); let value = release_json(root.path()).unwrap(); assert_eq!( value["plugins"][0]["sha256"], crate::package::digest(&bytes) ); assert_eq!(value["plugins"][0]["size"], bytes.len()); assert!(value["plugins"][0]["runAs"].is_null()); assert_eq!(value["plugins"][0]["icon"], "example.test-1.0.0-icon.png"); assert_eq!( fs::read(root.path().join("example.test-1.0.0-icon.png")).unwrap(), icon ); assert_eq!( fs::read(root.path().join("example.test-1.0.0-screen-0.png")).unwrap(), icon ); let source = source("example/test").unwrap(); let mut db = Database::default(); let staged = crate::package::Staged::from_bytes(&bytes).unwrap(); let prepared = crate::planner::prepare_staged_sources( &db, staged.clone(), Some(source.id.clone()), Default::default(), Default::default(), vec![source.clone()], ) .unwrap(); assert_eq!(prepared.plan.add_sources.len(), 1); assert!(db.sources.is_empty()); db.sources.push(Source { enabled: false, ..source.clone() }); assert!(crate::planner::prepare_staged_sources( &db, staged, Some(source.id.clone()), Default::default(), Default::default(), vec![source] ) .is_err()); } } pub fn release_json(packages: &Path) -> Result { let mut plugins = vec![]; let mut ids = BTreeSet::new(); let mut paths = fs::read_dir(packages)? .map(|p| p.map(|e| e.path())) .collect::>>()?; paths.sort(); for path in paths .into_iter() .filter(|p| p.extension().is_some_and(|e| e == "framely")) { let bytes = fs::read(&path)?; let verified = crate::package::verify(&bytes)?; let manifest = verified.manifest; ensure!( ids.insert(manifest.id.clone()), "Duplicate plugin ID in release" ); semver::Version::parse(&manifest.version)?; let mut value = serde_json::to_value(&manifest)?; for key in [ "schemaVersion", "backend", "ui", "files", "publish", "downloadUrl", "lifecycle", "icon", "screenshots", ] { value.as_object_mut().unwrap().remove(key); } value["asset"] = json!(path .file_name() .context("Missing asset name")? .to_str() .context("Invalid asset name")?); value["size"] = json!(bytes.len()); value["sha256"] = json!(crate::package::digest(&bytes)); value["runAs"] = json!((manifest.backend.is_some() || manifest.lifecycle.is_some()) .then(|| manifest.run_as())); let export_image = |label: &str, path: &str| -> Result { let data = verified.files.get(path).context("Missing packaged image")?; let ext = Path::new(path) .extension() .and_then(|v| v.to_str()) .context("Missing image extension")?; let name = format!("{}-{}-{label}.{ext}", manifest.id, manifest.version); let output = packages.join(&name); if output.exists() { ensure!( fs::read(&output)? == *data, "Refusing to replace a different release image: {name}" ); } else { let mut file = tempfile::NamedTempFile::new_in(packages)?; file.write_all(data)?; file.as_file().sync_all()?; file.persist_noclobber(&output)?; } Ok(name) }; let publish = manifest.publish.as_ref(); if let Some(icon) = publish.and_then(|p| p.icon.clone()) { value["icon"] = json!(icon); } else if let Some(icon) = &manifest.icon { value["icon"] = json!(export_image("icon", icon)?); } value["screenshots"] = if let Some(images) = publish.map(|p| &p.screenshots).filter(|s| !s.is_empty()) { json!(images) } else { json!(manifest .screenshots .iter() .enumerate() .map(|(i, p)| export_image(&format!("screen-{i}"), p)) .collect::>>()?) }; plugins.push(value); } ensure!(!plugins.is_empty(), "No .framely packages found"); Ok(json!({"schemaVersion":1,"plugins":plugins})) }