use std::collections::{BTreeMap, HashMap};
use crate::cbor;
#[derive(Debug, Clone)]
pub enum LocalizedString {
Plain(String),
Localized(HashMap<String, String>),
}
impl Default for LocalizedString {
fn default() -> Self {
Self::Plain(String::new())
}
}
impl LocalizedString {
pub fn plain(text: impl Into<String>) -> Self {
Self::Plain(text.into())
}
pub fn new(lang: impl Into<String>, text: impl Into<String>) -> Self {
let mut map = HashMap::new();
map.insert(lang.into(), text.into());
Self::Localized(map)
}
pub fn get(&self, lang: &str) -> Option<&str> {
match self {
Self::Plain(text) => Some(text.as_str()),
Self::Localized(map) => map.get(lang).map(|s| s.as_str()),
}
}
pub fn resolve(&self, lang: &str) -> &str {
match self {
Self::Plain(text) => text.as_str(),
Self::Localized(map) => {
if let Some(text) = map.get(lang) {
return text.as_str();
}
if let Some(text) = map
.iter()
.find(|(tag, _)| tag.starts_with(lang) || lang.starts_with(tag.as_str()))
.map(|(_, text)| text.as_str())
{
return text;
}
map.values().next().map(|s| s.as_str()).unwrap_or("")
}
}
}
pub fn any_text(&self) -> &str {
match self {
Self::Plain(text) => text.as_str(),
Self::Localized(map) => map.values().next().map(|s| s.as_str()).unwrap_or(""),
}
}
}
impl From<String> for LocalizedString {
fn from(s: String) -> Self {
Self::Plain(s)
}
}
impl From<&str> for LocalizedString {
fn from(s: &str) -> Self {
Self::Plain(s.to_string())
}
}
impl From<Vec<(String, String)>> for LocalizedString {
fn from(v: Vec<(String, String)>) -> Self {
Self::Localized(v.into_iter().collect())
}
}
impl From<HashMap<String, String>> for LocalizedString {
fn from(map: HashMap<String, String>) -> Self {
Self::Localized(map)
}
}
#[derive(Debug, Clone, Default)]
pub struct Metadata(HashMap<String, serde_json::Value>);
impl Metadata {
pub fn new() -> Self {
Self(HashMap::new())
}
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) {
self.0.insert(key.into(), value.into());
}
pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
self.0.get(key)
}
pub fn get_as<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
self.0
.get(key)
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
pub fn contains_key(&self, key: &str) -> bool {
self.0.contains_key(key)
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &serde_json::Value)> {
self.0.iter()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn extend(&mut self, other: Metadata) {
self.0.extend(other.0);
}
}
impl From<serde_json::Value> for Metadata {
fn from(value: serde_json::Value) -> Self {
match value {
serde_json::Value::Object(map) => Self(map.into_iter().collect()),
_ => Self::new(),
}
}
}
impl From<Metadata> for serde_json::Value {
fn from(m: Metadata) -> Self {
serde_json::Value::Object(m.0.into_iter().collect())
}
}
impl From<Vec<(String, Vec<u8>)>> for Metadata {
fn from(v: Vec<(String, Vec<u8>)>) -> Self {
Self(
v.into_iter()
.filter_map(|(k, cbor_bytes)| {
let val = cbor::cbor_to_json(&cbor_bytes).ok()?;
Some((k, val))
})
.collect(),
)
}
}
impl From<Metadata> for Vec<(String, Vec<u8>)> {
fn from(m: Metadata) -> Self {
m.0.into_iter()
.map(|(k, v)| (k, cbor::to_cbor(&v)))
.collect()
}
}
use crate::constants::*;
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct FilesystemCap {
#[serde(
rename = "mount-root",
default,
skip_serializing_if = "Option::is_none"
)]
pub mount_root: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow: Vec<FilesystemAllow>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct FilesystemAllow {
pub path: String,
pub mode: FsMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FsMode {
Ro,
Rw,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct HttpCap {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow: Vec<HttpAllow>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct HttpAllow {
pub host: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scheme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub methods: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ports: Option<Vec<u16>>,
}
#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
pub struct SocketsCap {}
#[serde_with::skip_serializing_none]
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Capabilities {
#[serde(rename = "wasi:filesystem")]
pub filesystem: Option<FilesystemCap>,
#[serde(rename = "wasi:http")]
pub http: Option<HttpCap>,
#[serde(rename = "wasi:sockets")]
pub sockets: Option<SocketsCap>,
#[serde(flatten)]
pub other: BTreeMap<String, serde_json::Value>,
}
impl Capabilities {
pub fn is_empty(&self) -> bool {
self.http.is_none()
&& self.filesystem.is_none()
&& self.sockets.is_none()
&& self.other.is_empty()
}
pub fn has(&self, id: &str) -> bool {
match id {
CAP_HTTP => self.http.is_some(),
CAP_FILESYSTEM => self.filesystem.is_some(),
CAP_SOCKETS => self.sockets.is_some(),
other => self.other.contains_key(other),
}
}
pub fn fs_mount_root(&self) -> Option<&str> {
self.filesystem.as_ref()?.mount_root.as_deref()
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct ComponentInfo {
#[serde(default)]
pub std: StdComponentInfo,
#[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
pub extra: HashMap<String, serde_json::Value>,
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct StdComponentInfo {
#[serde(default)]
pub name: String,
#[serde(default)]
pub version: String,
#[serde(default)]
pub description: String,
#[serde(
rename = "default-language",
default,
skip_serializing_if = "Option::is_none"
)]
pub default_language: Option<String>,
#[serde(default, skip_serializing_if = "Capabilities::is_empty")]
pub capabilities: Capabilities,
}
impl ComponentInfo {
pub fn new(
name: impl Into<String>,
version: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
std: StdComponentInfo {
name: name.into(),
version: version.into(),
description: description.into(),
..Default::default()
},
..Default::default()
}
}
pub fn name(&self) -> &str {
&self.std.name
}
pub fn version(&self) -> &str {
&self.std.version
}
pub fn description(&self) -> &str {
&self.std.description
}
}
#[derive(Debug, Clone)]
pub struct ActError {
pub kind: String,
pub message: String,
}
impl ActError {
pub fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
Self {
kind: kind.into(),
message: message.into(),
}
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(ERR_NOT_FOUND, message)
}
pub fn invalid_args(message: impl Into<String>) -> Self {
Self::new(ERR_INVALID_ARGS, message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(ERR_INTERNAL, message)
}
pub fn timeout(message: impl Into<String>) -> Self {
Self::new(ERR_TIMEOUT, message)
}
pub fn capability_denied(message: impl Into<String>) -> Self {
Self::new(ERR_CAPABILITY_DENIED, message)
}
pub fn session_not_found(message: impl Into<String>) -> Self {
Self::new(ERR_SESSION_NOT_FOUND, message)
}
}
impl std::fmt::Display for ActError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.kind, self.message)
}
}
impl std::error::Error for ActError {}
pub type ActResult<T> = Result<T, ActError>;
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn localized_string_plain() {
let ls = LocalizedString::plain("hello");
assert_eq!(ls.resolve("en"), "hello");
assert_eq!(ls.any_text(), "hello");
}
#[test]
fn localized_string_from_str() {
let ls = LocalizedString::from("hello");
assert_eq!(ls.any_text(), "hello");
}
#[test]
fn localized_string_default() {
let ls = LocalizedString::default();
assert_eq!(ls.any_text(), "");
}
#[test]
fn localized_string_resolve_by_lang() {
let mut map = std::collections::HashMap::new();
map.insert("en".to_string(), "hello".to_string());
map.insert("ru".to_string(), "привет".to_string());
let ls = LocalizedString::Localized(map);
assert_eq!(ls.resolve("ru"), "привет");
assert_eq!(ls.resolve("en"), "hello");
assert!(!ls.resolve("fr").is_empty());
}
#[test]
fn localized_string_resolve_prefix() {
let mut map = HashMap::new();
map.insert("zh-Hans".to_string(), "你好".to_string());
map.insert("en".to_string(), "hello".to_string());
let ls = LocalizedString::Localized(map);
assert_eq!(ls.resolve("zh"), "你好");
}
#[test]
fn localized_string_get() {
let ls = LocalizedString::new("en", "hello");
assert_eq!(ls.get("en"), Some("hello"));
assert_eq!(ls.get("ru"), None);
}
#[test]
fn localized_string_from_vec() {
let v = vec![("en".to_string(), "hi".to_string())];
let ls = LocalizedString::from(v);
assert_eq!(ls.resolve("en"), "hi");
}
#[test]
fn metadata_insert_and_get() {
let mut m = Metadata::new();
m.insert("std:read-only", true);
assert_eq!(m.get("std:read-only"), Some(&json!(true)));
assert_eq!(m.get_as::<bool>("std:read-only"), Some(true));
}
#[test]
fn metadata_to_json_empty() {
let json: serde_json::Value = Metadata::new().into();
assert_eq!(json, json!({}));
}
#[test]
fn metadata_to_json_with_values() {
let mut m = Metadata::new();
m.insert("std:read-only", true);
let json: serde_json::Value = m.into();
assert_eq!(json["std:read-only"], json!(true));
}
#[test]
fn metadata_from_vec() {
let v = vec![("key".to_string(), cbor::to_cbor(&42u32))];
let m = Metadata::from(v);
assert_eq!(m.get("key"), Some(&json!(42)));
assert_eq!(m.get_as::<u32>("key"), Some(42));
}
#[test]
fn capabilities_cbor_roundtrip() {
let mut info = ComponentInfo::new("test", "0.1.0", "test component");
info.std.capabilities.http = Some(HttpCap::default());
info.std.capabilities.filesystem = Some(FilesystemCap {
mount_root: Some("/data".to_string()),
..Default::default()
});
let mut buf = Vec::new();
ciborium::into_writer(&info, &mut buf).unwrap();
let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
assert!(decoded.std.capabilities.http.is_some());
assert!(decoded.std.capabilities.filesystem.is_some());
assert!(decoded.std.capabilities.sockets.is_none());
assert_eq!(decoded.std.capabilities.fs_mount_root(), Some("/data"));
}
#[test]
fn capabilities_empty_roundtrip() {
let info = ComponentInfo::new("test", "0.1.0", "test");
let mut buf = Vec::new();
ciborium::into_writer(&info, &mut buf).unwrap();
let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
assert!(decoded.std.capabilities.is_empty());
}
#[test]
fn capabilities_fs_no_params_roundtrip() {
let mut info = ComponentInfo::new("test", "0.1.0", "test");
info.std.capabilities.filesystem = Some(FilesystemCap::default());
let mut buf = Vec::new();
ciborium::into_writer(&info, &mut buf).unwrap();
let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
assert!(decoded.std.capabilities.filesystem.is_some());
assert_eq!(decoded.std.capabilities.fs_mount_root(), None);
}
#[test]
fn capabilities_unknown_preserved() {
let mut info = ComponentInfo::new("test", "0.1.0", "test");
info.std
.capabilities
.other
.insert("acme:gpu".to_string(), json!({"cores": 8}));
let mut buf = Vec::new();
ciborium::into_writer(&info, &mut buf).unwrap();
let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
assert!(decoded.std.capabilities.has("acme:gpu"));
assert_eq!(decoded.std.capabilities.other["acme:gpu"]["cores"], 8);
}
#[test]
fn filesystem_cap_with_allow_roundtrips() {
let toml_input = r#"
[std.capabilities."wasi:filesystem"]
description = "test"
[[std.capabilities."wasi:filesystem".allow]]
path = "/etc/**"
mode = "ro"
[[std.capabilities."wasi:filesystem".allow]]
path = "/tmp/**"
mode = "rw"
"#;
#[derive(serde::Deserialize)]
struct Wrap {
std: Std,
}
#[derive(serde::Deserialize)]
struct Std {
capabilities: Capabilities,
}
let w: Wrap = toml::from_str(toml_input).expect("parses");
let fs = w.std.capabilities.filesystem.expect("fs declared");
assert_eq!(fs.allow.len(), 2);
assert_eq!(fs.allow[0].path, "/etc/**");
assert!(matches!(fs.allow[0].mode, FsMode::Ro));
assert_eq!(fs.allow[1].path, "/tmp/**");
assert!(matches!(fs.allow[1].mode, FsMode::Rw));
}
#[test]
fn filesystem_cap_requires_path_and_mode_on_each_entry() {
let bad = r#"
[[std.capabilities."wasi:filesystem".allow]]
path = "/tmp/**"
"#;
#[derive(serde::Deserialize)]
struct Wrap {
std: Std,
}
#[derive(serde::Deserialize)]
struct Std {
capabilities: Capabilities,
}
assert!(
toml::from_str::<Wrap>(bad).is_err(),
"missing mode must fail"
);
}
#[test]
fn http_cap_with_allow_roundtrips() {
let toml_input = r#"
[std.capabilities."wasi:http"]
description = "Calls OpenAI + GitHub"
[[std.capabilities."wasi:http".allow]]
host = "api.openai.com"
scheme = "https"
methods = ["GET", "POST"]
[[std.capabilities."wasi:http".allow]]
host = "*.github.com"
scheme = "https"
"#;
#[derive(serde::Deserialize)]
struct Wrap {
std: Std,
}
#[derive(serde::Deserialize)]
struct Std {
capabilities: Capabilities,
}
let w: Wrap = toml::from_str(toml_input).expect("parses");
let http = w.std.capabilities.http.expect("http declared");
assert_eq!(http.allow.len(), 2);
assert_eq!(http.allow[0].host, "api.openai.com");
assert_eq!(http.allow[0].scheme.as_deref(), Some("https"));
assert_eq!(
http.allow[0].methods.as_deref(),
Some(&["GET".to_string(), "POST".to_string()][..])
);
assert_eq!(http.allow[1].host, "*.github.com");
}
#[test]
fn http_cap_requires_host_on_each_entry() {
let bad = r#"
[[std.capabilities."wasi:http".allow]]
scheme = "https"
"#;
#[derive(serde::Deserialize)]
struct Wrap {
std: Std,
}
#[derive(serde::Deserialize)]
struct Std {
capabilities: Capabilities,
}
assert!(
toml::from_str::<Wrap>(bad).is_err(),
"missing host must fail"
);
}
#[test]
fn http_cap_wildcard_host() {
let toml_input = r#"
[[std.capabilities."wasi:http".allow]]
host = "*"
"#;
#[derive(serde::Deserialize)]
struct Wrap {
std: Std,
}
#[derive(serde::Deserialize)]
struct Std {
capabilities: Capabilities,
}
let w: Wrap = toml::from_str(toml_input).expect("parses");
assert_eq!(w.std.capabilities.http.unwrap().allow[0].host, "*");
}
}