use onerom_config::chip::ChipType as OraChipType;
use onerom_config::fw::FirmwareVersion;
use onerom_gen::{ChipConfig, ChipSetConfig, ChipSetType, SizeHandling};
use serde::Deserialize;
use crate::Error;
const PLUGIN_SITE_BASE: &str = "https://images.onerom.org/plugins";
const PLUGIN_MAX_SIZE: usize = 64 * 1024;
const PLUGIN_TYPE_SYSTEM: &str = "system_plugin";
const PLUGIN_TYPE_USER: &str = "user_plugin";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
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 std::fmt::Display for PluginVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::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 serde::Serialize for PluginVersion {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.to_string())
}
}
impl<'de> serde::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(format!("invalid plugin version '{s}'")))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginType {
System,
User,
}
impl std::fmt::Display for PluginType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.short())
}
}
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 serde::Serialize for PluginType {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.canonical())
}
}
impl<'de> serde::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(format!("unrecognised plugin type '{s}'")))
}
}
const PLUGIN_SPEC_KEYS: &[&str] = &["version"];
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PluginsManifest {
pub version: u32,
pub plugins: Vec<PluginEntry>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PluginEntry {
pub name: String,
#[serde(rename = "type")]
pub plugin_type: PluginType,
pub path: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PluginReleasesManifest {
pub version: u32,
pub display_name: String,
pub description: String,
pub latest: Option<PluginVersion>,
pub releases: Vec<PluginRelease>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PluginRelease {
pub version: PluginVersion,
pub path: String,
pub filename: String,
pub sha256: String,
pub api_version: u32,
pub plugin_type: PluginType,
#[serde(deserialize_with = "deserialize_fw_version")]
pub min_fw_version: 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)
}
impl PluginRelease {
pub fn binary_url(&self, plugin_type: PluginType, plugin_name: &str) -> String {
format!(
"{}/{}/{}/{}/{}",
PLUGIN_SITE_BASE,
plugin_type.short(),
plugin_name,
self.path,
self.filename
)
}
pub fn compatible_with_firmware(&self, fw: &FirmwareVersion) -> bool {
fw >= &self.min_fw_version
}
}
pub async fn fetch_plugins_manifest() -> Result<PluginsManifest, Error> {
let url = format!("{}/plugins.json", PLUGIN_SITE_BASE);
fetch_json(&url).await
}
pub async fn fetch_plugin_releases(
plugin_type: PluginType,
plugin_name: &str,
) -> Result<PluginReleasesManifest, Error> {
let url = format!(
"{}/{}/{}/releases.json",
PLUGIN_SITE_BASE,
plugin_type.short(),
plugin_name
);
fetch_json(&url).await
}
async fn fetch_json<T: serde::de::DeserializeOwned>(url: &str) -> Result<T, Error> {
log::debug!("Fetching {url}");
let response = reqwest::get(url)
.await
.map_err(|e| Error::Network(url.to_string(), e.to_string()))?;
if !response.status().is_success() {
return Err(Error::Http(url.to_string(), response.status().as_u16()));
}
response
.json::<T>()
.await
.map_err(|e| Error::Json(url.to_string(), e.to_string()))
}
#[derive(Debug, Clone)]
pub enum PluginSpec {
Named {
name: String,
plugin_type: Option<PluginType>,
version: Option<PluginVersion>,
},
File { path: String },
}
fn parse_plugin(s: &str) -> Result<PluginSpec, Error> {
if let Some(path) = s.strip_prefix("file=") {
if path.is_empty() {
return Err(Error::InvalidArgument(
"--plugin".to_string(),
format!("file path must not be empty\n --plugin '{s}'"),
));
}
return Ok(PluginSpec::File {
path: path.to_string(),
});
}
let mut parts = s.splitn(2, ',');
let name_part = parts.next().unwrap();
let kv_part = parts.next();
let mut version = None;
if let Some(kv) = kv_part {
let mut seen = std::collections::HashSet::new();
for kv in kv.split(',') {
let (key, value) = kv.split_once('=').ok_or_else(|| {
Error::InvalidArgument(
"--plugin".to_string(),
format!(
"Plugin option '{kv}' is missing a value - expected '{kv}=<value>'\n --plugin '{s}'"
),
)
})?;
if !seen.insert(key) {
return Err(Error::InvalidArgument(
"--plugin".to_string(),
format!("Duplicate plugin option '{key}'\n --plugin '{s}'"),
));
}
match key {
"version" => {
version = Some(PluginVersion::try_from_str(value).ok_or_else(|| {
Error::InvalidArgument(
"--plugin".to_string(),
format!("Invalid plugin version format '{value}'\n --plugin '{s}'"),
)
})?);
}
other => {
let supported = PLUGIN_SPEC_KEYS.join(", ");
return Err(Error::InvalidArgument(
"--plugin".to_string(),
format!(
"Unrecognised plugin option '{other}'\n --plugin '{s}'\n Supported options: {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(|| {
Error::InvalidArgument(
"--plugin".to_string(),
format!(
"Invalid plugin type '{type_str}': expected 'system' or 'user'\n --plugin '{s}'"
),
)
})?;
if name_str.is_empty() {
return Err(Error::InvalidArgument(
"--plugin".to_string(),
format!("Plugin name must not be empty\n --plugin '{s}'"),
));
}
(Some(pt), name_str.to_string())
} else {
if name_part.is_empty() {
return Err(Error::InvalidArgument(
"--plugin".to_string(),
format!("Plugin name must not be empty\n --plugin '{s}'"),
));
}
(None, name_part.to_string())
};
Ok(PluginSpec::Named {
name,
plugin_type,
version,
})
}
pub fn parse_plugins(plugins: &[String]) -> Result<Vec<PluginSpec>, Error> {
let specs: Vec<PluginSpec> = plugins
.iter()
.map(|s| parse_plugin(s))
.collect::<Result<_, _>>()?;
let any_unknown = specs.iter().any(|s| {
matches!(
s,
PluginSpec::Named {
plugin_type: None,
..
} | PluginSpec::File { .. }
)
});
if !any_unknown {
let mut seen_system = false;
let mut seen_user = false;
for spec in &specs {
if let PluginSpec::Named {
plugin_type: Some(t),
..
} = spec
{
match t {
PluginType::System => {
if seen_system {
return Err(Error::DuplicatePlugin(PluginType::System));
}
seen_system = true;
}
PluginType::User => {
if seen_user {
return Err(Error::DuplicatePlugin(PluginType::User));
}
seen_user = true;
}
}
}
}
if seen_user && !seen_system {
return Err(Error::UserPluginWithoutSystem);
}
}
Ok(specs)
}
pub fn validate_resolved_plugin_types(plugin_types: &[PluginType]) -> Result<(), Error> {
let system_count = plugin_types
.iter()
.filter(|&&t| t == PluginType::System)
.count();
let user_count = plugin_types
.iter()
.filter(|&&t| t == PluginType::User)
.count();
if system_count > 1 {
return Err(Error::DuplicatePlugin(PluginType::System));
}
if user_count > 1 {
return Err(Error::DuplicatePlugin(PluginType::User));
}
if user_count > 0 && system_count == 0 {
return Err(Error::UserPluginWithoutSystem);
}
Ok(())
}
pub fn plugin_size_handling(size: usize) -> Result<SizeHandling, Error> {
if size > PLUGIN_MAX_SIZE {
return Err(Error::PluginTooLarge(size, PLUGIN_MAX_SIZE));
}
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, Error> {
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: vec![ChipConfig {
file: file.to_string(),
license: None,
description: None,
chip_type,
cs1: None,
cs2: None,
cs3: None,
size_handling,
extract: None,
label: None,
location: None,
}],
serve_alg: None,
firmware_overrides: None,
})
}
const ORA_PLUGIN_MAGIC: u32 = 0x2041524F;
const ORA_PLUGIN_HEADER_SIZE: usize = 256;
struct PluginHeader {
version: PluginVersion,
plugin_type: PluginType,
min_fw: FirmwareVersion,
}
fn parse_plugin_header(data: &[u8], source: &str) -> Result<PluginHeader, Error> {
if data.len() < ORA_PLUGIN_HEADER_SIZE {
return Err(Error::PluginBinaryTooSmall(
source.to_string(),
data.len(),
ORA_PLUGIN_HEADER_SIZE,
));
}
let magic = u32::from_le_bytes(data[0..4].try_into().unwrap());
if magic != ORA_PLUGIN_MAGIC {
return Err(Error::PluginInvalidMagic(
source.to_string(),
magic,
ORA_PLUGIN_MAGIC,
));
}
let plugin_type = match data[20] {
0 => PluginType::System,
1 => PluginType::User,
2 => return Err(Error::PluginPioNotSupported(source.to_string())),
other => return Err(Error::PluginUnknownBinaryType(source.to_string(), other)),
};
Ok(PluginHeader {
version: PluginVersion::new(
u16::from_le_bytes(data[8..10].try_into().unwrap()),
u16::from_le_bytes(data[10..12].try_into().unwrap()),
u16::from_le_bytes(data[12..14].try_into().unwrap()),
u16::from_le_bytes(data[14..16].try_into().unwrap()),
),
plugin_type,
min_fw: FirmwareVersion::new(
u16::from_le_bytes(data[24..26].try_into().unwrap()),
u16::from_le_bytes(data[26..28].try_into().unwrap()),
u16::from_le_bytes(data[28..30].try_into().unwrap()),
0,
),
})
}
fn check_header_min_fw(
header: &PluginHeader,
plugin_version: PluginVersion,
fw_version: &FirmwareVersion,
source: &str,
) -> Result<(), Error> {
if fw_version < &header.min_fw {
return Err(Error::PluginIncompatible(
source.to_string(),
plugin_version,
header.min_fw,
*fw_version,
));
}
Ok(())
}
fn verify_sha256(data: &[u8], expected_hex: &str, source: &str) -> Result<(), Error> {
use sha2::{Digest, Sha256};
let actual = hex::encode(Sha256::digest(data));
if actual != expected_hex.to_lowercase() {
return Err(Error::PluginSha256Mismatch(
source.to_string(),
expected_hex.to_string(),
actual,
));
}
Ok(())
}
pub struct ResolvedPlugin {
pub plugin_type: PluginType,
pub name: String,
pub file: String,
pub size: usize,
pub version: PluginVersion,
}
pub async fn resolve_plugins(
specs: &[PluginSpec],
fw_version: Option<FirmwareVersion>,
) -> Result<Vec<ResolvedPlugin>, Error> {
if specs.is_empty() {
return Ok(vec![]);
}
let manifest = if specs.iter().any(|s| {
matches!(
s,
PluginSpec::Named {
plugin_type: None,
..
}
)
}) {
Some(fetch_plugins_manifest().await?)
} else {
None
};
let mut resolved = Vec::with_capacity(specs.len());
for spec in specs {
resolved.push(resolve_plugin(spec, manifest.as_ref(), fw_version.as_ref()).await?);
}
let types: Vec<PluginType> = resolved.iter().map(|r| r.plugin_type).collect();
validate_resolved_plugin_types(&types)?;
Ok(resolved)
}
async fn resolve_plugin(
spec: &PluginSpec,
manifest: Option<&PluginsManifest>,
fw_version: Option<&FirmwareVersion>,
) -> Result<ResolvedPlugin, Error> {
match spec {
PluginSpec::Named {
name,
plugin_type,
version,
} => resolve_named_plugin(name, *plugin_type, *version, manifest, fw_version).await,
PluginSpec::File { path } => resolve_file_plugin(path, fw_version).await,
}
}
async fn resolve_named_plugin(
name: &str,
known_type: Option<PluginType>,
version: Option<PluginVersion>,
manifest: Option<&PluginsManifest>,
fw_version: Option<&FirmwareVersion>,
) -> Result<ResolvedPlugin, Error> {
let plugin_type: PluginType = if let Some(kt) = known_type {
kt
} else {
let m = manifest.expect("manifest must be fetched before resolving bare-name specs");
let entry = m
.plugins
.iter()
.find(|p| p.name == name)
.ok_or_else(|| Error::PluginNotFound(name.to_string()))?;
entry.plugin_type
};
let releases = fetch_plugin_releases(plugin_type, name).await?;
let release = if let Some(v) = version {
releases
.releases
.iter()
.find(|r| r.version == v)
.ok_or_else(|| Error::PluginVersionNotFound(name.to_string(), v.to_string()))?
} else {
releases
.releases
.first()
.ok_or_else(|| Error::PluginNotFound(name.to_string()))?
};
if let Some(fw) = fw_version
&& !release.compatible_with_firmware(fw)
{
return Err(Error::PluginIncompatible(
name.to_string(),
release.version,
release.min_fw_version,
*fw,
));
}
let url = release.binary_url(plugin_type, name);
let (data, _) = onerom_fw::net::fetch_rom_file_async(&url, &[], None, false)
.await
.map_err(Error::from)?;
verify_sha256(&data, &release.sha256, &url)?;
let header = parse_plugin_header(&data, &url)?;
if header.version != release.version {
return Err(Error::PluginVersionMismatch(
name.to_string(),
release.version,
header.version,
));
}
if header.plugin_type != plugin_type {
return Err(Error::PluginTypeMismatch(
name.to_string(),
plugin_type.canonical().to_string(),
header.plugin_type.canonical().to_string(),
));
}
if let Some(fw) = fw_version {
check_header_min_fw(&header, header.version, fw, &url)?;
}
Ok(ResolvedPlugin {
plugin_type,
name: name.to_string(),
file: url,
size: data.len(),
version: header.version,
})
}
async fn resolve_file_plugin(
path: &str,
fw_version: Option<&FirmwareVersion>,
) -> Result<ResolvedPlugin, Error> {
let (data, _) = onerom_fw::net::fetch_rom_file_async(path, &[], None, false)
.await
.map_err(Error::from)?;
let header = parse_plugin_header(&data, path)?;
if let Some(fw) = fw_version {
check_header_min_fw(&header, header.version, fw, path)?;
}
let name = std::path::Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(path)
.to_string();
Ok(ResolvedPlugin {
plugin_type: header.plugin_type,
name,
file: path.to_string(),
size: data.len(),
version: header.version,
})
}