use alloc::collections::BTreeSet;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::fmt;
use onerom_config::chip::ChipType as OraChipType;
use onerom_config::fw::FirmwareVersion;
use onerom_gen::{ChipConfig, ChipSetConfig, ChipSetType, SizeHandling};
use serde::{Deserialize, Serialize};
use crate::error::{Error, PluginError};
const PLUGIN_SITE_BASE: &str = "https://images.onerom.org/plugins";
const PLUGIN_TYPE_SYSTEM: &str = "system_plugin";
const PLUGIN_TYPE_USER: &str = "user_plugin";
const PLUGIN_SPEC_KEYS: &[&str] = &["version"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct PluginVersion {
pub major: u16,
pub minor: u16,
pub patch: u16,
pub build: u16,
}
impl PluginVersion {
pub fn new(major: u16, minor: u16, patch: u16, build: u16) -> Self {
Self {
major,
minor,
patch,
build,
}
}
pub fn try_from_str(s: &str) -> Option<Self> {
let parts: Vec<&str> = s.split('.').collect();
match parts.as_slice() {
[major, minor, patch] => Some(Self {
major: major.parse().ok()?,
minor: minor.parse().ok()?,
patch: patch.parse().ok()?,
build: 0,
}),
[major, minor, patch, build] => Some(Self {
major: major.parse().ok()?,
minor: minor.parse().ok()?,
patch: patch.parse().ok()?,
build: build.parse().ok()?,
}),
_ => None,
}
}
}
impl fmt::Display for PluginVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.build == 0 {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
} else {
write!(f, "{}.{}.{}.{}", self.major, self.minor, self.patch, self.build)
}
}
}
impl<'de> Deserialize<'de> for PluginVersion {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
PluginVersion::try_from_str(&s)
.ok_or_else(|| serde::de::Error::custom(alloc::format!("invalid plugin version '{s}'")))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum PluginType {
System,
User,
}
impl PluginType {
pub fn try_from_str(s: &str) -> Option<Self> {
match s {
"system" | "system_plugin" => Some(PluginType::System),
"user" | "user_plugin" => Some(PluginType::User),
_ => None,
}
}
pub fn canonical(self) -> &'static str {
match self {
PluginType::System => PLUGIN_TYPE_SYSTEM,
PluginType::User => PLUGIN_TYPE_USER,
}
}
pub fn short(self) -> &'static str {
match self {
PluginType::System => "system",
PluginType::User => "user",
}
}
pub fn slot_index(self) -> usize {
match self {
PluginType::System => 0,
PluginType::User => 1,
}
}
}
impl fmt::Display for PluginType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.short())
}
}
impl Serialize for PluginType {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.canonical())
}
}
impl<'de> Deserialize<'de> for PluginType {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
PluginType::try_from_str(&s)
.ok_or_else(|| serde::de::Error::custom(alloc::format!("unrecognised plugin type '{s}'")))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginSpec {
Named {
name: String,
plugin_type: Option<PluginType>,
version: Option<PluginVersion>,
},
File {
path: String,
},
}
fn parse_plugin(s: &str) -> Result<PluginSpec, PluginError> {
if let Some(path) = s.strip_prefix("file=") {
if path.is_empty() {
return Err(PluginError::SpecSyntax(alloc::format!(
"file path must not be empty in '{s}'"
)));
}
return Ok(PluginSpec::File {
path: path.to_string(),
});
}
let mut parts = s.splitn(2, ',');
let name_part = parts.next().unwrap_or("");
let kv_part = parts.next();
let mut version = None;
if let Some(kv) = kv_part {
let mut seen = BTreeSet::new();
for kv in kv.split(',') {
let (key, value) = kv.split_once('=').ok_or_else(|| {
PluginError::SpecSyntax(alloc::format!(
"option '{kv}' is missing a value (expected '{kv}=<value>') in '{s}'"
))
})?;
if !seen.insert(key) {
return Err(PluginError::SpecSyntax(alloc::format!(
"duplicate option '{key}' in '{s}'"
)));
}
match key {
"version" => {
version = Some(PluginVersion::try_from_str(value).ok_or_else(|| {
PluginError::SpecSyntax(alloc::format!(
"invalid version '{value}' in '{s}'"
))
})?);
}
other => {
let supported = PLUGIN_SPEC_KEYS.join(", ");
return Err(PluginError::SpecSyntax(alloc::format!(
"unrecognised option '{other}' in '{s}' (supported: {supported})"
)));
}
}
}
}
let (plugin_type, name) = if let Some((type_str, name_str)) = name_part.split_once('/') {
let pt = PluginType::try_from_str(type_str).ok_or_else(|| {
PluginError::SpecSyntax(alloc::format!(
"invalid type '{type_str}' (expected 'system' or 'user') in '{s}'"
))
})?;
if name_str.is_empty() {
return Err(PluginError::SpecSyntax(alloc::format!(
"plugin name must not be empty in '{s}'"
)));
}
(Some(pt), name_str.to_string())
} else {
if name_part.is_empty() {
return Err(PluginError::SpecSyntax(alloc::format!(
"plugin name must not be empty in '{s}'"
)));
}
(None, name_part.to_string())
};
Ok(PluginSpec::Named {
name,
plugin_type,
version,
})
}
pub fn parse_plugins(specs: &[String]) -> Result<Vec<PluginSpec>, PluginError> {
let parsed: Vec<PluginSpec> = specs
.iter()
.map(|s| parse_plugin(s))
.collect::<Result<_, _>>()?;
let any_unknown = parsed.iter().any(|s| {
matches!(
s,
PluginSpec::Named {
plugin_type: None,
..
} | PluginSpec::File { .. }
)
});
if !any_unknown {
let types: Vec<PluginType> = parsed
.iter()
.filter_map(|s| match s {
PluginSpec::Named {
plugin_type: Some(t),
..
} => Some(*t),
_ => None,
})
.collect();
validate_plugin_type_set(&types)?;
}
Ok(parsed)
}
pub(crate) fn validate_plugin_type_set(types: &[PluginType]) -> Result<(), PluginError> {
let system = types.iter().filter(|&&t| t == PluginType::System).count();
let user = types.iter().filter(|&&t| t == PluginType::User).count();
if system > 1 {
return Err(PluginError::DuplicatePlugin(PluginType::System));
}
if user > 1 {
return Err(PluginError::DuplicatePlugin(PluginType::User));
}
if user > 0 && system == 0 {
return Err(PluginError::UserPluginWithoutSystem);
}
Ok(())
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct PluginsManifestWire {
#[allow(dead_code)]
pub version: u32,
pub plugins: Vec<PluginEntryWire>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct PluginEntryWire {
pub name: String,
#[serde(rename = "type")]
pub plugin_type: PluginType,
#[allow(dead_code)]
pub path: String,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct PluginReleasesManifestWire {
#[allow(dead_code)]
pub version: u32,
pub display_name: String,
pub description: String,
pub releases: Vec<ReleaseWire>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct ReleaseWire {
pub version: PluginVersion,
pub path: String,
pub filename: String,
pub sha256: String,
pub api_version: u32,
#[serde(deserialize_with = "deserialize_fw_version")]
pub min_fw_version: FirmwareVersion,
#[serde(default, deserialize_with = "deserialize_opt_fw_version")]
pub incompatible_from: Option<FirmwareVersion>,
}
fn deserialize_fw_version<'de, D>(d: D) -> Result<FirmwareVersion, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(d)?;
FirmwareVersion::try_from_str(&s).map_err(serde::de::Error::custom)
}
fn deserialize_opt_fw_version<'de, D>(d: D) -> Result<Option<FirmwareVersion>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt = Option::<String>::deserialize(d)?;
opt.map(|s| FirmwareVersion::try_from_str(&s))
.transpose()
.map_err(serde::de::Error::custom)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Release {
pub version: PluginVersion,
pub path: String,
pub filename: String,
pub sha256: String,
pub api_version: u32,
pub min_fw_version: FirmwareVersion,
pub incompatible_from: Option<FirmwareVersion>,
}
impl Release {
fn from_wire(w: ReleaseWire) -> Self {
Self {
version: w.version,
path: w.path,
filename: w.filename,
sha256: w.sha256,
api_version: w.api_version,
min_fw_version: w.min_fw_version,
incompatible_from: w.incompatible_from,
}
}
pub fn is_compatible(&self, fw: &FirmwareVersion) -> bool {
release_incompat(self, fw).is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Plugin {
pub name: String,
pub plugin_type: PluginType,
pub display_name: Option<String>,
pub description: Option<String>,
pub releases: Vec<Release>,
}
impl Plugin {
fn from_entry(e: PluginEntryWire) -> Self {
Self {
name: e.name,
plugin_type: e.plugin_type,
display_name: None,
description: None,
releases: Vec::new(),
}
}
pub fn releases_manifest_url(&self) -> String {
alloc::format!(
"{PLUGIN_SITE_BASE}/{}/{}/releases.json",
self.plugin_type.short(),
self.name
)
}
pub fn binary_url(&self, release: &Release) -> String {
binary_url(self.plugin_type, &self.name, release)
}
}
pub(crate) fn binary_url(plugin_type: PluginType, name: &str, release: &Release) -> String {
alloc::format!(
"{PLUGIN_SITE_BASE}/{}/{}/{}/{}",
plugin_type.short(),
name,
release.path,
release.filename
)
}
pub(crate) fn plugins_manifest_url() -> String {
alloc::format!("{PLUGIN_SITE_BASE}/plugins.json")
}
#[derive(Debug, Clone, Default)]
pub struct Catalogue {
pub(crate) plugins: Vec<Plugin>,
}
impl Catalogue {
pub(crate) fn from_wire(w: PluginsManifestWire) -> Self {
Self {
plugins: w.plugins.into_iter().map(Plugin::from_entry).collect(),
}
}
pub fn plugins(&self) -> &[Plugin] {
&self.plugins
}
pub fn plugin_by_name(&self, name: &str) -> Option<&Plugin> {
self.plugins.iter().find(|p| p.name == name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedPlugin {
pub plugin_type: PluginType,
pub name: String,
pub version: PluginVersion,
pub size: usize,
pub source: ResolvedSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedSource {
Named {
release: Release,
},
File {
path: String,
},
}
impl ResolvedPlugin {
pub fn file(&self) -> String {
match &self.source {
ResolvedSource::Named { release } => {
binary_url(self.plugin_type, &self.name, release)
}
ResolvedSource::File { path } => path.clone(),
}
}
pub fn to_chip_set_config(&self) -> Result<ChipSetConfig, PluginError> {
plugin_to_chip_set_config(&self.file(), self.plugin_type, self.size)
}
}
#[trait_variant::make(PluginFetch: Send)]
pub trait LocalPluginFetch {
type Error;
async fn fetch(&self, source: &str) -> Result<Vec<u8>, Self::Error>;
}
pub enum VerifyTarget<'a> {
Release {
release: &'a Release,
expected_type: PluginType,
},
Header,
}
const ORA_PLUGIN_MAGIC: u32 = 0x2041_524F;
const ORA_PLUGIN_HEADER_SIZE: usize = 256;
const PLUGIN_MAX_SIZE: usize = 64 * 1024;
struct PluginHeader {
plugin_type: PluginType,
version: PluginVersion,
}
fn parse_plugin_header(data: &[u8], source: &str) -> Result<PluginHeader, PluginError> {
if data.len() < ORA_PLUGIN_HEADER_SIZE {
return Err(PluginError::BinaryTooSmall(
source.into(),
data.len(),
ORA_PLUGIN_HEADER_SIZE,
));
}
let magic = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
if magic != ORA_PLUGIN_MAGIC {
return Err(PluginError::InvalidMagic(source.into(), magic, ORA_PLUGIN_MAGIC));
}
let plugin_type = match data[20] {
0 => PluginType::System,
1 => PluginType::User,
2 => return Err(PluginError::PioNotSupported(source.into())),
other => return Err(PluginError::UnknownBinaryType(source.into(), other)),
};
let version = PluginVersion::new(
u16::from_le_bytes([data[8], data[9]]),
u16::from_le_bytes([data[10], data[11]]),
u16::from_le_bytes([data[12], data[13]]),
u16::from_le_bytes([data[14], data[15]]),
);
Ok(PluginHeader {
plugin_type,
version,
})
}
fn sha256_hex(data: &[u8]) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(data))
}
pub fn verify_binary(
data: &[u8],
target: VerifyTarget<'_>,
source: &str,
) -> Result<PluginVerification, PluginError> {
if data.len() > PLUGIN_MAX_SIZE {
return Err(PluginError::TooLarge(data.len(), PLUGIN_MAX_SIZE));
}
let header = parse_plugin_header(data, source)?;
if let VerifyTarget::Release { release, expected_type } = target {
let actual = sha256_hex(data);
if actual != release.sha256.to_lowercase() {
return Err(PluginError::Sha256Mismatch {
binary: source.into(),
expected: release.sha256.clone(),
got: actual,
});
}
if header.plugin_type != expected_type {
return Err(PluginError::TypeMismatch(
source.into(),
expected_type,
header.plugin_type,
));
}
}
Ok(PluginVerification {
plugin_type: header.plugin_type,
version: header.version,
size: data.len(),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PluginVerification {
pub plugin_type: PluginType,
pub version: PluginVersion,
pub size: usize,
}
enum FwIncompat {
TooOld,
TooNew(FirmwareVersion),
}
fn release_incompat(release: &Release, fw: &FirmwareVersion) -> Option<FwIncompat> {
if fw < &release.min_fw_version {
Some(FwIncompat::TooOld)
} else if let Some(from) = release.incompatible_from
&& fw >= &from
{
Some(FwIncompat::TooNew(from))
} else {
None
}
}
pub fn compatible_releases<'a>(plugin: &'a Plugin, fw: &FirmwareVersion) -> Vec<&'a Release> {
plugin
.releases
.iter()
.filter(|r| release_incompat(r, fw).is_none())
.collect()
}
pub fn newest_compatible<'a>(plugin: &'a Plugin, fw: &FirmwareVersion) -> Option<&'a Release> {
plugin
.releases
.iter()
.find(|r| release_incompat(r, fw).is_none())
}
fn incompat_error(name: &str, release: &Release, reason: FwIncompat, fw: &FirmwareVersion) -> PluginError {
match reason {
FwIncompat::TooOld => PluginError::Incompatible {
name: name.into(),
version: release.version,
min_fw: release.min_fw_version,
fw: *fw,
},
FwIncompat::TooNew(from) => PluginError::IncompatibleNewer {
name: name.into(),
version: release.version,
from,
fw: *fw,
},
}
}
pub fn validate_resolved_plugin_types(types: &[PluginType]) -> Result<(), PluginError> {
validate_plugin_type_set(types)
}
fn plugin_size_handling(size: usize) -> Result<SizeHandling, PluginError> {
if size > PLUGIN_MAX_SIZE {
Err(PluginError::TooLarge(size, PLUGIN_MAX_SIZE))
} else if size == PLUGIN_MAX_SIZE {
Ok(SizeHandling::None)
} else {
Ok(SizeHandling::Pad)
}
}
pub fn plugin_to_chip_set_config(
file: &str,
plugin_type: PluginType,
size: usize,
) -> Result<ChipSetConfig, PluginError> {
let size_handling = plugin_size_handling(size)?;
let chip_type = match plugin_type {
PluginType::System => OraChipType::SystemPlugin,
PluginType::User => OraChipType::UserPlugin,
};
Ok(ChipSetConfig {
set_type: ChipSetType::Single,
description: None,
chips: alloc::vec![ChipConfig {
file: file.into(),
license: None,
description: None,
chip_type,
cs1: None,
cs2: None,
cs3: None,
ce: None,
oe: None,
size_handling,
extract: None,
label: None,
location: None,
allow_cs_ignore: false,
}],
serve_alg: None,
firmware_overrides: None,
})
}
async fn fetch_json<F, T>(url: &str, fetch: &F) -> Result<T, Error<F::Error>>
where
F: LocalPluginFetch,
T: serde::de::DeserializeOwned,
{
let bytes = fetch
.fetch(url)
.await
.map_err(|e| Error::fetch(url, e))?;
serde_json::from_slice(&bytes)
.map_err(|e| PluginError::ManifestJson(url.into(), alloc::format!("{e}")).into())
}
async fn fetch_catalogue<F: LocalPluginFetch>(fetch: &F) -> Result<Catalogue, Error<F::Error>> {
let wire: PluginsManifestWire = fetch_json(&plugins_manifest_url(), fetch).await?;
Ok(Catalogue::from_wire(wire))
}
pub async fn fetch_releases<F: LocalPluginFetch>(
plugin: &mut Plugin,
fetch: &F,
) -> Result<(), Error<F::Error>> {
let url = plugin.releases_manifest_url();
let wire: PluginReleasesManifestWire = fetch_json(&url, fetch).await?;
plugin.display_name = Some(wire.display_name);
plugin.description = Some(wire.description);
plugin.releases = wire.releases.into_iter().map(Release::from_wire).collect();
Ok(())
}
impl Catalogue {
pub async fn fetch<F: LocalPluginFetch>(fetch: &F) -> Result<Self, Error<F::Error>> {
fetch_catalogue(fetch).await
}
pub async fn load_all_releases<F: LocalPluginFetch>(
&mut self,
fetch: &F,
) -> Result<(), Error<F::Error>> {
for plugin in &mut self.plugins {
fetch_releases(plugin, fetch).await?;
}
Ok(())
}
pub async fn load_all_releases_resilient<F: LocalPluginFetch>(
&mut self,
fetch: &F,
) -> Vec<(String, Error<F::Error>)> {
let mut failures = Vec::new();
for plugin in &mut self.plugins {
if let Err(e) = fetch_releases(plugin, fetch).await {
failures.push((plugin.name.clone(), e));
}
}
failures
}
}
pub async fn resolve_plugins<F: LocalPluginFetch>(
specs: &[PluginSpec],
fw: &FirmwareVersion,
fetch: &F,
) -> Result<Vec<ResolvedPlugin>, Error<F::Error>> {
if specs.is_empty() {
return Ok(Vec::new());
}
let needs_catalogue = specs.iter().any(|s| {
matches!(
s,
PluginSpec::Named {
plugin_type: None,
..
}
)
});
let catalogue = if needs_catalogue {
Some(fetch_catalogue(fetch).await?)
} else {
None
};
let mut resolved = Vec::with_capacity(specs.len());
for spec in specs {
resolved.push(resolve_one(spec, catalogue.as_ref(), fw, fetch).await?);
}
let types: Vec<PluginType> = resolved.iter().map(|r| r.plugin_type).collect();
validate_resolved_plugin_types(&types)?;
Ok(resolved)
}
async fn resolve_one<F: LocalPluginFetch>(
spec: &PluginSpec,
catalogue: Option<&Catalogue>,
fw: &FirmwareVersion,
fetch: &F,
) -> Result<ResolvedPlugin, Error<F::Error>> {
match spec {
PluginSpec::Named {
name,
plugin_type,
version,
} => resolve_named(name, *plugin_type, *version, catalogue, fw, fetch).await,
PluginSpec::File { path } => resolve_file(path, fetch).await,
}
}
async fn resolve_named<F: LocalPluginFetch>(
name: &str,
known_type: Option<PluginType>,
pinned: Option<PluginVersion>,
catalogue: Option<&Catalogue>,
fw: &FirmwareVersion,
fetch: &F,
) -> Result<ResolvedPlugin, Error<F::Error>> {
let plugin_type = if let Some(t) = known_type {
t
} else {
let cat = catalogue.expect("catalogue must be fetched before resolving bare-name specs");
cat.plugin_by_name(name)
.ok_or_else(|| PluginError::NotFound(name.into()))?
.plugin_type
};
let mut plugin = Plugin {
name: name.into(),
plugin_type,
display_name: None,
description: None,
releases: Vec::new(),
};
fetch_releases(&mut plugin, fetch).await?;
let release = select_release(&plugin, name, pinned, fw)?;
let url = plugin.binary_url(release);
let data = fetch.fetch(&url).await.map_err(|e| Error::fetch(&url, e))?;
let verification = verify_binary(
&data,
VerifyTarget::Release {
release,
expected_type: plugin_type,
},
&url,
)?;
if verification.version != release.version {
return Err(PluginError::VersionMismatch(
name.into(),
release.version,
verification.version,
)
.into());
}
Ok(ResolvedPlugin {
plugin_type,
name: name.into(),
version: release.version,
size: verification.size,
source: ResolvedSource::Named {
release: release.clone(),
},
})
}
fn select_release<'a>(
plugin: &'a Plugin,
name: &str,
pinned: Option<PluginVersion>,
fw: &FirmwareVersion,
) -> Result<&'a Release, PluginError> {
if let Some(v) = pinned {
let release = plugin
.releases
.iter()
.find(|r| r.version == v)
.ok_or_else(|| PluginError::VersionNotFound(name.into(), v))?;
if let Some(reason) = release_incompat(release, fw) {
return Err(incompat_error(name, release, reason, fw));
}
Ok(release)
} else {
newest_compatible(plugin, fw).ok_or_else(|| match plugin.releases.first() {
Some(newest) => match release_incompat(newest, fw) {
Some(reason) => incompat_error(name, newest, reason, fw),
None => PluginError::NotFound(name.into()),
},
None => PluginError::NotFound(name.into()),
})
}
}
async fn resolve_file<F: LocalPluginFetch>(
path: &str,
fetch: &F,
) -> Result<ResolvedPlugin, Error<F::Error>> {
let data = fetch.fetch(path).await.map_err(|e| Error::fetch(path, e))?;
let verification = verify_binary(&data, VerifyTarget::Header, path)?;
Ok(ResolvedPlugin {
plugin_type: verification.plugin_type,
name: file_stem(path),
version: verification.version,
size: verification.size,
source: ResolvedSource::File { path: path.into() },
})
}
fn file_stem(path: &str) -> String {
let last = path.rsplit(['/', '\\']).next().unwrap_or(path);
match last.rsplit_once('.') {
Some((stem, _)) if !stem.is_empty() => stem.into(),
_ => last.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::string::ToString;
use alloc::vec;
fn fw(major: u16, minor: u16, patch: u16) -> FirmwareVersion {
FirmwareVersion::new(major, minor, patch, 0)
}
fn pv(major: u16, minor: u16, patch: u16) -> PluginVersion {
PluginVersion::new(major, minor, patch, 0)
}
fn release(
version: PluginVersion,
min_fw: FirmwareVersion,
incompatible_from: Option<FirmwareVersion>,
sha256: &str,
) -> Release {
Release {
version,
path: alloc::format!("v{version}"),
filename: "plugin.bin".to_string(),
sha256: sha256.to_string(),
api_version: 1,
min_fw_version: min_fw,
incompatible_from,
}
}
fn plugin(name: &str, plugin_type: PluginType, releases: Vec<Release>) -> Plugin {
Plugin {
name: name.to_string(),
plugin_type,
display_name: None,
description: None,
releases,
}
}
fn header(magic_ok: bool, type_byte: u8, ver: (u16, u16, u16, u16)) -> Vec<u8> {
let mut buf = vec![0u8; ORA_PLUGIN_HEADER_SIZE];
let magic = if magic_ok { ORA_PLUGIN_MAGIC } else { 0xDEAD_BEEF };
buf[0..4].copy_from_slice(&magic.to_le_bytes());
buf[8..10].copy_from_slice(&ver.0.to_le_bytes());
buf[10..12].copy_from_slice(&ver.1.to_le_bytes());
buf[12..14].copy_from_slice(&ver.2.to_le_bytes());
buf[14..16].copy_from_slice(&ver.3.to_le_bytes());
buf[20] = type_byte;
buf
}
fn sha_of(data: &[u8]) -> String {
sha256_hex(data)
}
#[test]
fn plugin_version_parses_three_and_four_components() {
assert_eq!(PluginVersion::try_from_str("0.1.2"), Some(pv(0, 1, 2)));
assert_eq!(
PluginVersion::try_from_str("1.2.3.4"),
Some(PluginVersion::new(1, 2, 3, 4))
);
assert_eq!(PluginVersion::try_from_str("1.2"), None);
assert_eq!(PluginVersion::try_from_str("1.2.x"), None);
}
#[test]
fn plugin_version_display_omits_zero_build() {
assert_eq!(pv(0, 1, 2).to_string(), "0.1.2");
assert_eq!(PluginVersion::new(0, 1, 2, 5).to_string(), "0.1.2.5");
}
#[test]
fn plugin_version_orders_correctly() {
assert!(pv(0, 1, 0) < pv(0, 2, 0));
assert!(pv(1, 0, 0) > pv(0, 9, 9));
}
#[test]
fn plugin_type_parses_short_and_canonical() {
assert_eq!(PluginType::try_from_str("system"), Some(PluginType::System));
assert_eq!(
PluginType::try_from_str("system_plugin"),
Some(PluginType::System)
);
assert_eq!(PluginType::try_from_str("user"), Some(PluginType::User));
assert_eq!(
PluginType::try_from_str("user_plugin"),
Some(PluginType::User)
);
assert_eq!(PluginType::try_from_str("pio"), None);
}
#[test]
fn plugin_type_slot_indices_are_fixed() {
assert_eq!(PluginType::System.slot_index(), 0);
assert_eq!(PluginType::User.slot_index(), 1);
}
#[test]
fn parse_bare_name() {
let specs = parse_plugins(&["usb".to_string()]).unwrap();
assert_eq!(
specs[0],
PluginSpec::Named {
name: "usb".to_string(),
plugin_type: None,
version: None,
}
);
}
#[test]
fn parse_typed_name() {
let specs = parse_plugins(&["system/usb".to_string()]).unwrap();
assert_eq!(
specs[0],
PluginSpec::Named {
name: "usb".to_string(),
plugin_type: Some(PluginType::System),
version: None,
}
);
}
#[test]
fn parse_pinned_version() {
let specs = parse_plugins(&["usb,version=0.1.2".to_string()]).unwrap();
assert_eq!(
specs[0],
PluginSpec::Named {
name: "usb".to_string(),
plugin_type: None,
version: Some(pv(0, 1, 2)),
}
);
}
#[test]
fn parse_file_spec() {
let specs = parse_plugins(&["file=/tmp/p.bin".to_string()]).unwrap();
assert_eq!(
specs[0],
PluginSpec::File {
path: "/tmp/p.bin".to_string()
}
);
}
#[test]
fn parse_rejects_malformed() {
assert!(matches!(
parse_plugins(&["file=".to_string()]),
Err(PluginError::SpecSyntax(_))
));
assert!(matches!(
parse_plugins(&["usb,version".to_string()]),
Err(PluginError::SpecSyntax(_))
));
assert!(matches!(
parse_plugins(&["usb,foo=bar".to_string()]),
Err(PluginError::SpecSyntax(_))
));
assert!(matches!(
parse_plugins(&["usb,version=x".to_string()]),
Err(PluginError::SpecSyntax(_))
));
assert!(matches!(
parse_plugins(&["pio/usb".to_string()]),
Err(PluginError::SpecSyntax(_))
));
assert!(matches!(
parse_plugins(&["system/".to_string()]),
Err(PluginError::SpecSyntax(_))
));
assert!(matches!(
parse_plugins(&["usb,version=0.1.0,version=0.1.1".to_string()]),
Err(PluginError::SpecSyntax(_))
));
}
#[test]
fn parse_eager_validation_catches_duplicates_when_typed() {
let err = parse_plugins(&["system/usb".to_string(), "system/foo".to_string()]);
assert!(matches!(
err,
Err(PluginError::DuplicatePlugin(PluginType::System))
));
}
#[test]
fn parse_eager_validation_catches_user_without_system_when_typed() {
let err = parse_plugins(&["user/rgb".to_string()]);
assert!(matches!(err, Err(PluginError::UserPluginWithoutSystem)));
}
#[test]
fn parse_defers_validation_when_any_type_unknown() {
let specs = parse_plugins(&["user/rgb".to_string(), "usb".to_string()]).unwrap();
assert_eq!(specs.len(), 2);
}
#[test]
fn compatibility_window_is_half_open() {
let r = release(pv(1, 0, 0), fw(0, 6, 0), Some(fw(0, 8, 0)), "aa");
assert!(release_incompat(&r, &fw(0, 5, 9)).is_some());
assert!(release_incompat(&r, &fw(0, 6, 0)).is_none());
assert!(release_incompat(&r, &fw(0, 7, 0)).is_none());
assert!(release_incompat(&r, &fw(0, 8, 0)).is_some());
assert!(release_incompat(&r, &fw(0, 9, 0)).is_some());
}
#[test]
fn compatibility_no_upper_bound() {
let r = release(pv(1, 0, 0), fw(0, 6, 0), None, "aa");
assert!(release_incompat(&r, &fw(9, 9, 9)).is_none());
}
#[test]
fn compatible_releases_preserves_newest_first_order() {
let p = plugin(
"usb",
PluginType::System,
vec![
release(pv(0, 3, 0), fw(0, 7, 0), None, "c3"), release(pv(0, 2, 0), fw(0, 6, 0), None, "c2"),
release(pv(0, 1, 0), fw(0, 5, 0), Some(fw(0, 6, 0)), "c1"), ],
);
let compat = compatible_releases(&p, &fw(0, 6, 0));
assert_eq!(compat.len(), 1);
assert_eq!(compat[0].version, pv(0, 2, 0));
assert_eq!(newest_compatible(&p, &fw(0, 6, 0)).unwrap().version, pv(0, 2, 0));
}
#[test]
fn newest_compatible_none_when_all_incompatible() {
let p = plugin(
"usb",
PluginType::System,
vec![release(pv(0, 1, 0), fw(0, 9, 0), None, "c1")],
);
assert!(newest_compatible(&p, &fw(0, 6, 0)).is_none());
}
#[test]
fn validate_types_enforces_invariants() {
assert!(validate_resolved_plugin_types(&[PluginType::System]).is_ok());
assert!(
validate_resolved_plugin_types(&[PluginType::System, PluginType::User]).is_ok()
);
assert!(matches!(
validate_resolved_plugin_types(&[PluginType::User]),
Err(PluginError::UserPluginWithoutSystem)
));
assert!(matches!(
validate_resolved_plugin_types(&[PluginType::System, PluginType::System]),
Err(PluginError::DuplicatePlugin(PluginType::System))
));
}
#[test]
fn size_handling_boundaries() {
assert!(matches!(plugin_size_handling(1), Ok(SizeHandling::Pad)));
assert!(matches!(
plugin_size_handling(PLUGIN_MAX_SIZE),
Ok(SizeHandling::None)
));
assert!(matches!(
plugin_size_handling(PLUGIN_MAX_SIZE + 1),
Err(PluginError::TooLarge(_, _))
));
}
#[test]
fn verify_header_only_reads_type_and_version() {
let bin = header(true, 1, (0, 4, 2, 0));
let v = verify_binary(&bin, VerifyTarget::Header, "p.bin").unwrap();
assert_eq!(v.plugin_type, PluginType::User);
assert_eq!(v.version, pv(0, 4, 2));
assert_eq!(v.size, ORA_PLUGIN_HEADER_SIZE);
}
#[test]
fn verify_rejects_bad_magic() {
let bin = header(false, 0, (0, 1, 0, 0));
assert!(matches!(
verify_binary(&bin, VerifyTarget::Header, "p.bin"),
Err(PluginError::InvalidMagic(_, _, _))
));
}
#[test]
fn verify_rejects_pio_and_unknown_type() {
let pio = header(true, 2, (0, 1, 0, 0));
assert!(matches!(
verify_binary(&pio, VerifyTarget::Header, "p.bin"),
Err(PluginError::PioNotSupported(_))
));
let unknown = header(true, 9, (0, 1, 0, 0));
assert!(matches!(
verify_binary(&unknown, VerifyTarget::Header, "p.bin"),
Err(PluginError::UnknownBinaryType(_, 9))
));
}
#[test]
fn verify_rejects_too_small() {
let bin = vec![0u8; 10];
assert!(matches!(
verify_binary(&bin, VerifyTarget::Header, "p.bin"),
Err(PluginError::BinaryTooSmall(_, 10, ORA_PLUGIN_HEADER_SIZE))
));
}
#[test]
fn verify_rejects_oversize() {
let bin = vec![0u8; PLUGIN_MAX_SIZE + 1];
assert!(matches!(
verify_binary(&bin, VerifyTarget::Header, "p.bin"),
Err(PluginError::TooLarge(_, _))
));
}
#[test]
fn verify_release_checks_sha_and_type() {
let bin = header(true, 0, (0, 1, 0, 0)); let digest = sha_of(&bin);
let rel = release(pv(0, 1, 0), fw(0, 6, 0), None, &digest);
let v = verify_binary(
&bin,
VerifyTarget::Release {
release: &rel,
expected_type: PluginType::System,
},
"u.bin",
)
.unwrap();
assert_eq!(v.plugin_type, PluginType::System);
assert!(matches!(
verify_binary(
&bin,
VerifyTarget::Release {
release: &rel,
expected_type: PluginType::User,
},
"u.bin",
),
Err(PluginError::TypeMismatch(_, PluginType::User, PluginType::System))
));
}
#[test]
fn verify_release_rejects_sha_mismatch() {
let bin = header(true, 0, (0, 1, 0, 0));
let rel = release(pv(0, 1, 0), fw(0, 6, 0), None, "deadbeef");
assert!(matches!(
verify_binary(
&bin,
VerifyTarget::Release {
release: &rel,
expected_type: PluginType::System,
},
"u.bin",
),
Err(PluginError::Sha256Mismatch { .. })
));
}
#[test]
fn plugin_config_maps_type_and_pads() {
let cfg = plugin_to_chip_set_config("http://x/p.bin", PluginType::System, 1024).unwrap();
assert_eq!(cfg.chips.len(), 1);
let chip = &cfg.chips[0];
assert_eq!(chip.chip_type, OraChipType::SystemPlugin);
assert_eq!(chip.file, "http://x/p.bin");
assert!(matches!(chip.size_handling, SizeHandling::Pad));
assert!(chip.cs1.is_none() && chip.ce.is_none() && chip.oe.is_none());
assert!(!chip.allow_cs_ignore);
let user = plugin_to_chip_set_config("f", PluginType::User, PLUGIN_MAX_SIZE).unwrap();
assert_eq!(user.chips[0].chip_type, OraChipType::UserPlugin);
assert!(matches!(user.chips[0].size_handling, SizeHandling::None));
}
#[test]
fn urls_are_built_from_type_name_and_release() {
let p = plugin(
"usb",
PluginType::System,
vec![release(pv(0, 1, 2), fw(0, 6, 0), None, "aa")],
);
assert_eq!(
p.releases_manifest_url(),
"https://images.onerom.org/plugins/system/usb/releases.json"
);
assert_eq!(
p.binary_url(&p.releases[0]),
"https://images.onerom.org/plugins/system/usb/v0.1.2/plugin.bin"
);
assert_eq!(
plugins_manifest_url(),
"https://images.onerom.org/plugins/plugins.json"
);
}
#[test]
fn file_stem_strips_directory_and_extension() {
assert_eq!(file_stem("/tmp/foo.bin"), "foo");
assert_eq!(file_stem("https://x/y/rgb.bin"), "rgb");
assert_eq!(file_stem("plain"), "plain");
assert_eq!(file_stem("a\\b\\c.rom"), "c");
assert_eq!(file_stem(".bin"), ".bin");
}
#[test]
fn resolved_named_derives_binary_url() {
let rel = release(pv(0, 1, 2), fw(0, 6, 0), None, "aa");
let rp = ResolvedPlugin {
plugin_type: PluginType::System,
name: "usb".to_string(),
version: pv(0, 1, 2),
size: 1024,
source: ResolvedSource::Named { release: rel },
};
assert_eq!(
rp.file(),
"https://images.onerom.org/plugins/system/usb/v0.1.2/plugin.bin"
);
}
#[test]
fn resolved_file_uses_path() {
let rp = ResolvedPlugin {
plugin_type: PluginType::User,
name: "custom".to_string(),
version: pv(0, 1, 0),
size: 512,
source: ResolvedSource::File {
path: "/tmp/custom.bin".to_string(),
},
};
assert_eq!(rp.file(), "/tmp/custom.bin");
}
}