1use std::collections::HashMap;
2
3use crate::cbor;
4
5#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
12#[serde(untagged)]
13pub enum LocalizedString {
14 Plain(String),
16 Localized(HashMap<String, String>),
18}
19
20impl Default for LocalizedString {
21 fn default() -> Self {
22 Self::Plain(String::new())
23 }
24}
25
26impl LocalizedString {
27 pub fn plain(text: impl Into<String>) -> Self {
29 Self::Plain(text.into())
30 }
31
32 pub fn new(lang: impl Into<String>, text: impl Into<String>) -> Self {
34 let mut map = HashMap::new();
35 map.insert(lang.into(), text.into());
36 Self::Localized(map)
37 }
38
39 pub fn get(&self, lang: &str) -> Option<&str> {
44 match self {
45 Self::Plain(text) => Some(text.as_str()),
46 Self::Localized(map) => map.get(lang).map(|s| s.as_str()),
47 }
48 }
49
50 pub fn resolve(&self, lang: &str) -> &str {
55 match self {
56 Self::Plain(text) => text.as_str(),
57 Self::Localized(map) => {
58 if let Some(text) = map.get(lang) {
60 return text.as_str();
61 }
62 if let Some(text) = map
64 .iter()
65 .find(|(tag, _)| tag.starts_with(lang) || lang.starts_with(tag.as_str()))
66 .map(|(_, text)| text.as_str())
67 {
68 return text;
69 }
70 map.values().next().map(|s| s.as_str()).unwrap_or("")
72 }
73 }
74 }
75
76 pub fn any_text(&self) -> &str {
79 match self {
80 Self::Plain(text) => text.as_str(),
81 Self::Localized(map) => map.values().next().map(|s| s.as_str()).unwrap_or(""),
82 }
83 }
84}
85
86impl From<String> for LocalizedString {
87 fn from(s: String) -> Self {
88 Self::Plain(s)
89 }
90}
91
92impl From<&str> for LocalizedString {
93 fn from(s: &str) -> Self {
94 Self::Plain(s.to_string())
95 }
96}
97
98impl From<Vec<(String, String)>> for LocalizedString {
99 fn from(v: Vec<(String, String)>) -> Self {
100 Self::Localized(v.into_iter().collect())
101 }
102}
103
104impl From<HashMap<String, String>> for LocalizedString {
105 fn from(map: HashMap<String, String>) -> Self {
106 Self::Localized(map)
107 }
108}
109
110#[derive(Debug, Clone, Default)]
116pub struct Metadata(HashMap<String, serde_json::Value>);
117
118impl Metadata {
119 pub fn new() -> Self {
120 Self(HashMap::new())
121 }
122
123 pub fn insert(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) {
125 self.0.insert(key.into(), value.into());
126 }
127
128 pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
130 self.0.get(key)
131 }
132
133 pub fn get_as<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
135 self.0
136 .get(key)
137 .and_then(|v| serde_json::from_value(v.clone()).ok())
138 }
139
140 pub fn contains_key(&self, key: &str) -> bool {
142 self.0.contains_key(key)
143 }
144
145 pub fn is_empty(&self) -> bool {
147 self.0.is_empty()
148 }
149
150 pub fn iter(&self) -> impl Iterator<Item = (&String, &serde_json::Value)> {
152 self.0.iter()
153 }
154
155 pub fn len(&self) -> usize {
157 self.0.len()
158 }
159
160 pub fn extend(&mut self, other: Metadata) {
162 self.0.extend(other.0);
163 }
164}
165
166impl From<serde_json::Value> for Metadata {
168 fn from(value: serde_json::Value) -> Self {
169 match value {
170 serde_json::Value::Object(map) => Self(map.into_iter().collect()),
171 _ => Self::new(),
172 }
173 }
174}
175
176impl From<Metadata> for serde_json::Value {
178 fn from(m: Metadata) -> Self {
179 serde_json::Value::Object(m.0.into_iter().collect())
180 }
181}
182
183impl From<Vec<(String, Vec<u8>)>> for Metadata {
185 fn from(v: Vec<(String, Vec<u8>)>) -> Self {
186 Self(
187 v.into_iter()
188 .filter_map(|(k, cbor_bytes)| {
189 let val = cbor::cbor_to_json(&cbor_bytes).ok()?;
190 Some((k, val))
191 })
192 .collect(),
193 )
194 }
195}
196
197impl From<Metadata> for Vec<(String, Vec<u8>)> {
199 fn from(m: Metadata) -> Self {
200 m.0.into_iter()
201 .map(|(k, v)| (k, cbor::to_cbor(&v)))
202 .collect()
203 }
204}
205
206use crate::capability::Capabilities;
207use crate::constants::*;
208
209#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
214pub struct FilesystemAllow {
215 pub path: String,
217 pub mode: FsMode,
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
223#[serde(rename_all = "lowercase")]
224pub enum FsMode {
225 Ro,
227 Rw,
229}
230
231#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
237pub struct HttpAllow {
238 pub host: String,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub scheme: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub methods: Option<Vec<String>>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub ports: Option<Vec<u16>>,
245}
246
247#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
253pub struct SocketsAllow {
254 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub host: Option<String>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub cidr: Option<String>,
261 pub ports: Vec<u16>,
263 #[serde(
265 default = "default_socket_protocols",
266 skip_serializing_if = "is_default_protocols"
267 )]
268 pub protocols: Vec<SocketProtocol>,
269}
270
271fn default_socket_protocols() -> Vec<SocketProtocol> {
272 vec![SocketProtocol::Tcp, SocketProtocol::Udp]
273}
274
275fn is_default_protocols(v: &[SocketProtocol]) -> bool {
276 v == [SocketProtocol::Tcp, SocketProtocol::Udp]
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
281#[serde(rename_all = "lowercase")]
282pub enum SocketProtocol {
283 Tcp,
284 Udp,
285}
286
287#[non_exhaustive]
294#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
295pub struct ComponentInfo {
296 #[serde(default)]
298 pub std: StdComponentInfo,
299 #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
301 pub extra: HashMap<String, serde_json::Value>,
302}
303
304#[non_exhaustive]
306#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
307pub struct StdComponentInfo {
308 #[serde(default)]
309 pub name: String,
310 #[serde(default)]
311 pub version: String,
312 #[serde(default)]
313 pub description: String,
314 #[serde(
315 rename = "default-language",
316 default,
317 skip_serializing_if = "Option::is_none"
318 )]
319 pub default_language: Option<String>,
320 #[serde(default, skip_serializing_if = "Capabilities::is_empty")]
321 pub capabilities: Capabilities,
322}
323
324impl ComponentInfo {
325 pub fn new(
326 name: impl Into<String>,
327 version: impl Into<String>,
328 description: impl Into<String>,
329 ) -> Self {
330 Self {
331 std: StdComponentInfo {
332 name: name.into(),
333 version: version.into(),
334 description: description.into(),
335 ..Default::default()
336 },
337 ..Default::default()
338 }
339 }
340
341 pub fn name(&self) -> &str {
343 &self.std.name
344 }
345 pub fn version(&self) -> &str {
346 &self.std.version
347 }
348 pub fn description(&self) -> &str {
349 &self.std.description
350 }
351}
352
353#[derive(Debug, Clone)]
357pub struct ActError {
358 pub kind: String,
359 pub message: String,
360}
361
362impl ActError {
363 pub fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
364 Self {
365 kind: kind.into(),
366 message: message.into(),
367 }
368 }
369
370 pub fn not_found(message: impl Into<String>) -> Self {
371 Self::new(ERR_NOT_FOUND, message)
372 }
373
374 pub fn invalid_args(message: impl Into<String>) -> Self {
375 Self::new(ERR_INVALID_ARGS, message)
376 }
377
378 pub fn internal(message: impl Into<String>) -> Self {
379 Self::new(ERR_INTERNAL, message)
380 }
381
382 pub fn timeout(message: impl Into<String>) -> Self {
383 Self::new(ERR_TIMEOUT, message)
384 }
385
386 pub fn capability_denied(message: impl Into<String>) -> Self {
387 Self::new(ERR_CAPABILITY_DENIED, message)
388 }
389
390 pub fn session_not_found(message: impl Into<String>) -> Self {
391 Self::new(ERR_SESSION_NOT_FOUND, message)
392 }
393}
394
395impl std::fmt::Display for ActError {
396 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397 write!(f, "{}: {}", self.kind, self.message)
398 }
399}
400
401impl std::error::Error for ActError {}
402
403pub type ActResult<T> = Result<T, ActError>;
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use serde_json::json;
410 use std::collections::BTreeMap;
411
412 #[test]
413 fn localized_string_plain() {
414 let ls = LocalizedString::plain("hello");
415 assert_eq!(ls.resolve("en"), "hello");
416 assert_eq!(ls.any_text(), "hello");
417 }
418
419 #[test]
420 fn localized_string_from_str() {
421 let ls = LocalizedString::from("hello");
422 assert_eq!(ls.any_text(), "hello");
423 }
424
425 #[test]
426 fn localized_string_default() {
427 let ls = LocalizedString::default();
428 assert_eq!(ls.any_text(), "");
429 }
430
431 #[test]
432 fn localized_string_resolve_by_lang() {
433 let mut map = std::collections::HashMap::new();
434 map.insert("en".to_string(), "hello".to_string());
435 map.insert("ru".to_string(), "привет".to_string());
436 let ls = LocalizedString::Localized(map);
437 assert_eq!(ls.resolve("ru"), "привет");
438 assert_eq!(ls.resolve("en"), "hello");
439 assert!(!ls.resolve("fr").is_empty());
441 }
442
443 #[test]
444 fn localized_string_resolve_prefix() {
445 let mut map = HashMap::new();
446 map.insert("zh-Hans".to_string(), "你好".to_string());
447 map.insert("en".to_string(), "hello".to_string());
448 let ls = LocalizedString::Localized(map);
449 assert_eq!(ls.resolve("zh"), "你好");
450 }
451
452 #[test]
453 fn localized_string_get() {
454 let ls = LocalizedString::new("en", "hello");
455 assert_eq!(ls.get("en"), Some("hello"));
456 assert_eq!(ls.get("ru"), None);
457 }
458
459 #[test]
460 fn localized_string_from_vec() {
461 let v = vec![("en".to_string(), "hi".to_string())];
462 let ls = LocalizedString::from(v);
463 assert_eq!(ls.resolve("en"), "hi");
464 }
465
466 #[test]
467 fn metadata_insert_and_get() {
468 let mut m = Metadata::new();
469 m.insert("std:read-only", true);
470 assert_eq!(m.get("std:read-only"), Some(&json!(true)));
471 assert_eq!(m.get_as::<bool>("std:read-only"), Some(true));
472 }
473
474 #[test]
475 fn metadata_to_json_empty() {
476 let json: serde_json::Value = Metadata::new().into();
477 assert_eq!(json, json!({}));
478 }
479
480 #[test]
481 fn metadata_to_json_with_values() {
482 let mut m = Metadata::new();
483 m.insert("std:read-only", true);
484 let json: serde_json::Value = m.into();
485 assert_eq!(json["std:read-only"], json!(true));
486 }
487
488 #[test]
489 fn metadata_from_vec() {
490 let v = vec![("key".to_string(), cbor::to_cbor(&42u32))];
491 let m = Metadata::from(v);
492 assert_eq!(m.get("key"), Some(&json!(42)));
493 assert_eq!(m.get_as::<u32>("key"), Some(42));
494 }
495
496 #[test]
497 fn capabilities_cbor_roundtrip() {
498 use crate::CapabilityRequest;
499 let mut info = ComponentInfo::new("test", "0.1.0", "test component");
500 info.std
501 .capabilities
502 .0
503 .insert("wasi:http".into(), CapabilityRequest::default());
504 info.std.capabilities.0.insert(
505 "wasi:filesystem".into(),
506 CapabilityRequest {
507 params: BTreeMap::from([("mount-root".into(), json!("/data"))]),
508 ..Default::default()
509 },
510 );
511
512 let mut buf = Vec::new();
513 ciborium::into_writer(&info, &mut buf).unwrap();
514 let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
515
516 assert!(decoded.std.capabilities.has("wasi:http"));
517 assert!(decoded.std.capabilities.has("wasi:filesystem"));
518 assert!(!decoded.std.capabilities.has("wasi:sockets"));
519 assert_eq!(decoded.std.capabilities.fs_mount_root(), Some("/data"));
520 }
521
522 #[test]
523 fn capabilities_empty_roundtrip() {
524 let info = ComponentInfo::new("test", "0.1.0", "test");
525 let mut buf = Vec::new();
526 ciborium::into_writer(&info, &mut buf).unwrap();
527 let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
528 assert!(decoded.std.capabilities.is_empty());
529 }
530
531 #[test]
532 fn capabilities_fs_no_params_roundtrip() {
533 use crate::CapabilityRequest;
534 let mut info = ComponentInfo::new("test", "0.1.0", "test");
535 info.std
536 .capabilities
537 .0
538 .insert("wasi:filesystem".into(), CapabilityRequest::default());
539 let mut buf = Vec::new();
540 ciborium::into_writer(&info, &mut buf).unwrap();
541 let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
542 assert!(decoded.std.capabilities.has("wasi:filesystem"));
543 assert_eq!(decoded.std.capabilities.fs_mount_root(), None);
544 }
545
546 #[test]
547 fn capabilities_unknown_preserved() {
548 use crate::CapabilityRequest;
549 let mut info = ComponentInfo::new("test", "0.1.0", "test");
550 info.std.capabilities.0.insert(
551 "acme:gpu".into(),
552 CapabilityRequest {
553 constraints: vec![json!({ "cores": 8 })],
554 ..Default::default()
555 },
556 );
557 let mut buf = Vec::new();
558 ciborium::into_writer(&info, &mut buf).unwrap();
559 let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
560 assert!(decoded.std.capabilities.has("acme:gpu"));
561 assert_eq!(
562 decoded
563 .std
564 .capabilities
565 .get("acme:gpu")
566 .unwrap()
567 .constraints[0]["cores"],
568 8
569 );
570 }
571
572 #[test]
573 fn filesystem_cap_with_allow_roundtrips() {
574 let toml_input = r#"
575[std.capabilities."wasi:filesystem"]
576description = "test"
577
578[[std.capabilities."wasi:filesystem".allow]]
579path = "/etc/**"
580mode = "ro"
581
582[[std.capabilities."wasi:filesystem".allow]]
583path = "/tmp/**"
584mode = "rw"
585"#;
586 #[derive(serde::Deserialize)]
587 struct Wrap {
588 std: Std,
589 }
590 #[derive(serde::Deserialize)]
591 struct Std {
592 capabilities: Capabilities,
593 }
594 let w: Wrap = toml::from_str(toml_input).expect("parses");
595 let fs = w
596 .std
597 .capabilities
598 .get("wasi:filesystem")
599 .expect("fs declared");
600 let allow = fs
601 .constraints_as::<crate::FilesystemAllow>()
602 .expect("parse");
603 assert_eq!(allow.len(), 2);
604 assert_eq!(allow[0].path, "/etc/**");
605 assert_eq!(allow[1].path, "/tmp/**");
606 }
607
608 #[test]
609 fn filesystem_cap_requires_path_and_mode_on_each_entry() {
610 let toml_input = r#"
612[std.capabilities."wasi:filesystem"]
613
614[[std.capabilities."wasi:filesystem".allow]]
615path = "/tmp/**"
616"#;
617 #[derive(serde::Deserialize)]
618 struct Wrap {
619 std: Std,
620 }
621 #[derive(serde::Deserialize)]
622 struct Std {
623 capabilities: Capabilities,
624 }
625 let w: Wrap = toml::from_str(toml_input).expect("toml parses");
626 let fs = w
627 .std
628 .capabilities
629 .get("wasi:filesystem")
630 .expect("fs declared");
631 assert!(
632 fs.constraints_as::<FilesystemAllow>().is_err(),
633 "missing mode must fail"
634 );
635 }
636
637 #[test]
638 fn http_cap_with_allow_roundtrips() {
639 let toml_input = r#"
640[std.capabilities."wasi:http"]
641description = "Calls OpenAI + GitHub"
642
643[[std.capabilities."wasi:http".allow]]
644host = "api.openai.com"
645scheme = "https"
646methods = ["GET", "POST"]
647
648[[std.capabilities."wasi:http".allow]]
649host = "*.github.com"
650scheme = "https"
651"#;
652 #[derive(serde::Deserialize)]
653 struct Wrap {
654 std: Std,
655 }
656 #[derive(serde::Deserialize)]
657 struct Std {
658 capabilities: Capabilities,
659 }
660 let w: Wrap = toml::from_str(toml_input).expect("parses");
661 let http = w.std.capabilities.get("wasi:http").expect("http declared");
662 let allow = http.constraints_as::<HttpAllow>().expect("parse");
663 assert_eq!(allow.len(), 2);
664 assert_eq!(allow[0].host, "api.openai.com");
665 assert_eq!(allow[0].scheme.as_deref(), Some("https"));
666 assert_eq!(
667 allow[0].methods.as_deref(),
668 Some(&["GET".to_string(), "POST".to_string()][..])
669 );
670 assert_eq!(allow[1].host, "*.github.com");
671 }
672
673 #[test]
674 fn http_cap_requires_host_on_each_entry() {
675 let toml_input = r#"
677[std.capabilities."wasi:http"]
678
679[[std.capabilities."wasi:http".allow]]
680scheme = "https"
681"#;
682 #[derive(serde::Deserialize)]
683 struct Wrap {
684 std: Std,
685 }
686 #[derive(serde::Deserialize)]
687 struct Std {
688 capabilities: Capabilities,
689 }
690 let w: Wrap = toml::from_str(toml_input).expect("toml parses");
691 let http = w.std.capabilities.get("wasi:http").expect("http declared");
692 assert!(
693 http.constraints_as::<HttpAllow>().is_err(),
694 "missing host must fail"
695 );
696 }
697
698 #[test]
699 fn http_cap_wildcard_host() {
700 let toml_input = r#"
701[[std.capabilities."wasi:http".allow]]
702host = "*"
703"#;
704 #[derive(serde::Deserialize)]
705 struct Wrap {
706 std: Std,
707 }
708 #[derive(serde::Deserialize)]
709 struct Std {
710 capabilities: Capabilities,
711 }
712 let w: Wrap = toml::from_str(toml_input).expect("parses");
713 let http = w.std.capabilities.get("wasi:http").expect("http declared");
714 let allow = http.constraints_as::<HttpAllow>().expect("parse");
715 assert_eq!(allow[0].host, "*");
716 }
717
718 #[test]
719 fn sockets_cap_with_allow_roundtrips() {
720 let toml_input = r#"
721[std.capabilities."wasi:sockets"]
722
723[[std.capabilities."wasi:sockets".allow]]
724host = "vnc.example.com"
725ports = [5900]
726protocols = ["tcp"]
727
728[[std.capabilities."wasi:sockets".allow]]
729cidr = "10.0.0.0/8"
730ports = [80, 443]
731"#;
732 #[derive(serde::Deserialize)]
733 struct Wrap {
734 std: Std,
735 }
736 #[derive(serde::Deserialize)]
737 struct Std {
738 capabilities: Capabilities,
739 }
740 let w: Wrap = toml::from_str(toml_input).expect("parses");
741 let allow = w
742 .std
743 .capabilities
744 .get("wasi:sockets")
745 .expect("sockets declared")
746 .constraints_as::<crate::SocketsAllow>()
747 .expect("parse");
748 assert_eq!(allow.len(), 2);
749 let b = &allow[1];
750 assert_eq!(b.host, None);
751 assert_eq!(b.cidr.as_deref(), Some("10.0.0.0/8"));
752 assert_eq!(b.ports, vec![80, 443]);
753 assert_eq!(b.protocols, vec![SocketProtocol::Tcp, SocketProtocol::Udp]);
755 }
756
757 #[test]
758 fn sockets_cap_has_string() {
759 use crate::CapabilityRequest;
760 let mut c = Capabilities::default();
761 assert!(!c.has(crate::constants::CAP_SOCKETS));
762 c.0.insert(
763 crate::constants::CAP_SOCKETS.into(),
764 CapabilityRequest::default(),
765 );
766 assert!(c.has(crate::constants::CAP_SOCKETS));
767 }
768
769 #[test]
770 fn sockets_allow_default_protocols_not_emitted() {
771 let toml_input = r#"
775[[allow]]
776host = "vnc.example.com"
777ports = [5900]
778"#;
779 #[derive(serde::Serialize, serde::Deserialize)]
780 struct W {
781 allow: Vec<SocketsAllow>,
782 }
783 let w: W = toml::from_str(toml_input).unwrap();
784 assert_eq!(
785 w.allow[0].protocols,
786 vec![SocketProtocol::Tcp, SocketProtocol::Udp]
787 );
788
789 let re = toml::to_string(&w).unwrap();
790 assert!(
791 !re.contains("protocols"),
792 "default protocols leaked into re-serialized output: {re}"
793 );
794
795 let w2: W = toml::from_str(&re).unwrap();
797 assert_eq!(
798 w2.allow[0].protocols,
799 vec![SocketProtocol::Tcp, SocketProtocol::Udp]
800 );
801 }
802}