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)]
254pub struct SocketsAllow {
255 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub host: Option<String>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub cidr: Option<String>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub ports: Option<Vec<u16>>,
265 #[serde(
267 default = "default_socket_protocols",
268 skip_serializing_if = "is_default_protocols"
269 )]
270 pub protocols: Vec<SocketProtocol>,
271}
272
273fn default_socket_protocols() -> Vec<SocketProtocol> {
274 vec![SocketProtocol::Tcp, SocketProtocol::Udp]
275}
276
277fn is_default_protocols(v: &[SocketProtocol]) -> bool {
278 v == [SocketProtocol::Tcp, SocketProtocol::Udp]
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
283#[serde(rename_all = "lowercase")]
284pub enum SocketProtocol {
285 Tcp,
286 Udp,
287}
288
289#[non_exhaustive]
296#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
297pub struct ComponentInfo {
298 #[serde(default)]
300 pub std: StdComponentInfo,
301 #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
303 pub extra: HashMap<String, serde_json::Value>,
304}
305
306#[non_exhaustive]
308#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
309pub struct StdComponentInfo {
310 #[serde(default)]
311 pub name: String,
312 #[serde(default)]
313 pub version: String,
314 #[serde(default)]
315 pub description: String,
316 #[serde(
317 rename = "default-language",
318 default,
319 skip_serializing_if = "Option::is_none"
320 )]
321 pub default_language: Option<String>,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub author: Option<String>,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub license: Option<String>,
334 #[serde(default, skip_serializing_if = "Capabilities::is_empty")]
335 pub capabilities: Capabilities,
336 #[serde(default, skip_serializing_if = "Vec::is_empty")]
339 pub credentials: Vec<StdCredential>,
340}
341
342#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
346pub struct StdCredential {
347 pub key: String,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub description: Option<String>,
350 #[serde(default, skip_serializing_if = "Vec::is_empty")]
351 pub fields: Vec<StdCredentialField>,
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
357pub struct StdCredentialField {
358 pub key: String,
359 #[serde(default)]
360 pub label: String,
361 #[serde(rename = "type", default = "std_string")]
362 pub field_type: String,
363 #[serde(default = "yes")]
364 pub secret: bool,
365 #[serde(default = "yes")]
366 pub required: bool,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub resource: Option<String>,
370 #[serde(default, skip_serializing_if = "Vec::is_empty")]
371 pub scopes: Vec<String>,
372}
373
374fn std_string() -> String {
375 "std:string".to_string()
376}
377
378fn yes() -> bool {
379 true
380}
381
382impl ComponentInfo {
383 pub fn new(
384 name: impl Into<String>,
385 version: impl Into<String>,
386 description: impl Into<String>,
387 ) -> Self {
388 Self {
389 std: StdComponentInfo {
390 name: name.into(),
391 version: version.into(),
392 description: description.into(),
393 ..Default::default()
394 },
395 ..Default::default()
396 }
397 }
398
399 pub fn name(&self) -> &str {
401 &self.std.name
402 }
403 pub fn version(&self) -> &str {
404 &self.std.version
405 }
406 pub fn description(&self) -> &str {
407 &self.std.description
408 }
409}
410
411#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
413#[serde(rename_all = "lowercase")]
414pub enum MountType {
415 #[default]
417 Bind,
418 Root,
420}
421
422#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
428pub struct FilesystemMount {
429 #[serde(rename = "type", default)]
431 pub kind: MountType,
432 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub guest: Option<String>,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub host: Option<String>,
440}
441
442pub fn validate_mounts(mounts: &[FilesystemMount]) -> Result<(), String> {
446 let mut seen = std::collections::BTreeSet::new();
447 for (i, m) in mounts.iter().enumerate() {
448 let guest = match m.kind {
449 MountType::Bind => {
450 let g = m
451 .guest
452 .as_deref()
453 .ok_or_else(|| format!("mounts[{i}]: bind mount requires `guest`"))?;
454 if m.host.as_deref().is_none_or(str::is_empty) {
455 return Err(format!("mounts[{i}]: bind mount requires `host`"));
456 }
457 g
458 }
459 MountType::Root => {
460 if m.host.is_some() {
461 return Err(format!("mounts[{i}]: root mount must not set `host`"));
462 }
463 m.guest.as_deref().unwrap_or("/")
464 }
465 };
466 validate_guest(i, guest)?;
467 if !seen.insert(guest.to_string()) {
468 return Err(format!("mounts[{i}]: duplicate guest path `{guest}`"));
469 }
470 }
471 Ok(())
472}
473
474fn validate_guest(i: usize, guest: &str) -> Result<(), String> {
475 if !guest.starts_with('/') {
476 return Err(format!(
477 "mounts[{i}]: guest `{guest}` must be POSIX-absolute (start with '/')"
478 ));
479 }
480 if guest.contains('\\') || guest.contains(':') {
481 return Err(format!(
482 "mounts[{i}]: guest `{guest}` must not contain a drive letter or backslash"
483 ));
484 }
485 if guest.split('/').any(|c| c == "." || c == "..") {
486 return Err(format!(
487 "mounts[{i}]: guest `{guest}` must not contain '.' or '..' components"
488 ));
489 }
490 Ok(())
491}
492
493#[cfg(test)]
494mod mount_tests {
495 use super::validate_mounts;
496 use super::{FilesystemMount, MountType};
497
498 fn bind(guest: &str, host: &str) -> FilesystemMount {
499 FilesystemMount {
500 kind: MountType::Bind,
501 guest: Some(guest.into()),
502 host: Some(host.into()),
503 }
504 }
505
506 #[test]
507 fn valid_bind_passes() {
508 assert!(validate_mounts(&[bind("/ows", "~/.ows")]).is_ok());
509 }
510
511 #[test]
512 fn bind_without_host_fails() {
513 let m = FilesystemMount {
514 kind: MountType::Bind,
515 guest: Some("/ows".into()),
516 host: None,
517 };
518 assert!(validate_mounts(&[m]).unwrap_err().contains("host"));
519 }
520
521 #[test]
522 fn root_with_host_fails() {
523 let m = FilesystemMount {
524 kind: MountType::Root,
525 guest: Some("/".into()),
526 host: Some("/x".into()),
527 };
528 assert!(validate_mounts(&[m]).unwrap_err().contains("host"));
529 }
530
531 #[test]
532 fn relative_guest_fails() {
533 assert!(
534 validate_mounts(&[bind("ows", "~/.ows")])
535 .unwrap_err()
536 .contains("absolute")
537 );
538 }
539
540 #[test]
541 fn bind_without_guest_fails() {
542 let m = FilesystemMount {
543 kind: MountType::Bind,
544 guest: None,
545 host: Some("~/.ows".into()),
546 };
547 assert!(validate_mounts(&[m]).unwrap_err().contains("guest"));
548 }
549
550 #[test]
551 fn drive_letter_guest_fails() {
552 assert!(
553 validate_mounts(&[bind("/c:/x", "~/.ows")])
554 .unwrap_err()
555 .contains("drive letter or backslash")
556 );
557 }
558
559 #[test]
560 fn dotdot_guest_fails() {
561 assert!(
562 validate_mounts(&[bind("/ows/../etc", "~/.ows")])
563 .unwrap_err()
564 .contains("..")
565 );
566 }
567
568 #[test]
569 fn duplicate_guest_fails() {
570 let e = validate_mounts(&[bind("/ows", "~/a"), bind("/ows", "~/b")]).unwrap_err();
571 assert!(e.contains("duplicate"));
572 }
573
574 #[test]
575 fn bind_is_the_default_type_and_round_trips() {
576 let m: FilesystemMount =
577 serde_json::from_value(serde_json::json!({ "guest": "/ows", "host": "~/.ows" }))
578 .unwrap();
579 assert_eq!(m.kind, MountType::Bind);
580 assert_eq!(m.guest.as_deref(), Some("/ows"));
581 assert_eq!(m.host.as_deref(), Some("~/.ows"));
582
583 let v = serde_json::to_value(&m).unwrap();
584 assert_eq!(v["type"], "bind");
586 assert_eq!(v["guest"], "/ows");
587 assert_eq!(v["host"], "~/.ows");
588 }
589
590 #[test]
591 fn root_parses_with_type_field_and_no_host() {
592 let m: FilesystemMount =
593 serde_json::from_value(serde_json::json!({ "type": "root", "guest": "/" })).unwrap();
594 assert_eq!(m.kind, MountType::Root);
595 assert_eq!(m.host, None);
596 }
597}
598
599#[derive(Debug, Clone)]
603pub struct ActError {
604 pub kind: String,
605 pub message: String,
606}
607
608impl ActError {
609 pub fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
610 Self {
611 kind: kind.into(),
612 message: message.into(),
613 }
614 }
615
616 pub fn not_found(message: impl Into<String>) -> Self {
617 Self::new(ERR_NOT_FOUND, message)
618 }
619
620 pub fn invalid_args(message: impl Into<String>) -> Self {
621 Self::new(ERR_INVALID_ARGS, message)
622 }
623
624 pub fn internal(message: impl Into<String>) -> Self {
625 Self::new(ERR_INTERNAL, message)
626 }
627
628 pub fn timeout(message: impl Into<String>) -> Self {
629 Self::new(ERR_TIMEOUT, message)
630 }
631
632 pub fn capability_denied(message: impl Into<String>) -> Self {
633 Self::new(ERR_CAPABILITY_DENIED, message)
634 }
635
636 pub fn session_not_found(message: impl Into<String>) -> Self {
637 Self::new(ERR_SESSION_NOT_FOUND, message)
638 }
639}
640
641impl std::fmt::Display for ActError {
642 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
643 write!(f, "{}: {}", self.kind, self.message)
644 }
645}
646
647impl std::error::Error for ActError {}
648
649pub type ActResult<T> = Result<T, ActError>;
651
652#[cfg(test)]
653mod tests {
654 use super::*;
655 use serde_json::json;
656 use std::collections::BTreeMap;
657
658 #[test]
659 fn localized_string_plain() {
660 let ls = LocalizedString::plain("hello");
661 assert_eq!(ls.resolve("en"), "hello");
662 assert_eq!(ls.any_text(), "hello");
663 }
664
665 #[test]
666 fn localized_string_from_str() {
667 let ls = LocalizedString::from("hello");
668 assert_eq!(ls.any_text(), "hello");
669 }
670
671 #[test]
672 fn localized_string_default() {
673 let ls = LocalizedString::default();
674 assert_eq!(ls.any_text(), "");
675 }
676
677 #[test]
678 fn localized_string_resolve_by_lang() {
679 let mut map = std::collections::HashMap::new();
680 map.insert("en".to_string(), "hello".to_string());
681 map.insert("ru".to_string(), "привет".to_string());
682 let ls = LocalizedString::Localized(map);
683 assert_eq!(ls.resolve("ru"), "привет");
684 assert_eq!(ls.resolve("en"), "hello");
685 assert!(!ls.resolve("fr").is_empty());
687 }
688
689 #[test]
690 fn localized_string_resolve_prefix() {
691 let mut map = HashMap::new();
692 map.insert("zh-Hans".to_string(), "你好".to_string());
693 map.insert("en".to_string(), "hello".to_string());
694 let ls = LocalizedString::Localized(map);
695 assert_eq!(ls.resolve("zh"), "你好");
696 }
697
698 #[test]
699 fn localized_string_get() {
700 let ls = LocalizedString::new("en", "hello");
701 assert_eq!(ls.get("en"), Some("hello"));
702 assert_eq!(ls.get("ru"), None);
703 }
704
705 #[test]
706 fn localized_string_from_vec() {
707 let v = vec![("en".to_string(), "hi".to_string())];
708 let ls = LocalizedString::from(v);
709 assert_eq!(ls.resolve("en"), "hi");
710 }
711
712 #[test]
713 fn metadata_insert_and_get() {
714 let mut m = Metadata::new();
715 m.insert("std:read-only", true);
716 assert_eq!(m.get("std:read-only"), Some(&json!(true)));
717 assert_eq!(m.get_as::<bool>("std:read-only"), Some(true));
718 }
719
720 #[test]
721 fn metadata_to_json_empty() {
722 let json: serde_json::Value = Metadata::new().into();
723 assert_eq!(json, json!({}));
724 }
725
726 #[test]
727 fn metadata_to_json_with_values() {
728 let mut m = Metadata::new();
729 m.insert("std:read-only", true);
730 let json: serde_json::Value = m.into();
731 assert_eq!(json["std:read-only"], json!(true));
732 }
733
734 #[test]
735 fn metadata_from_vec() {
736 let v = vec![("key".to_string(), cbor::to_cbor(&42u32))];
737 let m = Metadata::from(v);
738 assert_eq!(m.get("key"), Some(&json!(42)));
739 assert_eq!(m.get_as::<u32>("key"), Some(42));
740 }
741
742 #[test]
743 fn author_license_present_roundtrip_and_omitted_when_none() {
744 let mut info = ComponentInfo::new("test", "0.1.0", "test component");
746 info.std.author = Some("Ada Lovelace <ada@example.com>".to_string());
747 info.std.license = Some("Apache-2.0".to_string());
748
749 let json = serde_json::to_value(&info).unwrap();
750 assert_eq!(json["std"]["author"], "Ada Lovelace <ada@example.com>");
751 assert_eq!(json["std"]["license"], "Apache-2.0");
752
753 let mut buf = Vec::new();
754 ciborium::into_writer(&info, &mut buf).unwrap();
755 let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
756 assert_eq!(
757 decoded.std.author.as_deref(),
758 Some("Ada Lovelace <ada@example.com>")
759 );
760 assert_eq!(decoded.std.license.as_deref(), Some("Apache-2.0"));
761
762 let bare = ComponentInfo::new("test", "0.1.0", "test");
764 assert!(bare.std.author.is_none());
765 assert!(bare.std.license.is_none());
766 let bare_json = serde_json::to_value(&bare).unwrap();
767 assert!(bare_json["std"].get("author").is_none());
768 assert!(bare_json["std"].get("license").is_none());
769 }
770
771 #[test]
772 fn old_component_without_author_license_still_parses() {
773 let old = serde_json::json!({
775 "std": { "name": "legacy", "version": "0.1.0", "description": "old" }
776 });
777 let info: ComponentInfo = serde_json::from_value(old).unwrap();
778 assert_eq!(info.std.name, "legacy");
779 assert!(info.std.author.is_none());
780 assert!(info.std.license.is_none());
781 }
782
783 #[test]
784 fn capabilities_cbor_roundtrip() {
785 use crate::CapabilityRequest;
786 let mut info = ComponentInfo::new("test", "0.1.0", "test component");
787 info.std
788 .capabilities
789 .0
790 .insert("wasi:http".into(), CapabilityRequest::default());
791 info.std.capabilities.0.insert(
792 "wasi:filesystem".into(),
793 CapabilityRequest {
794 params: BTreeMap::from([("mount-root".into(), json!("/data"))]),
795 ..Default::default()
796 },
797 );
798
799 let mut buf = Vec::new();
800 ciborium::into_writer(&info, &mut buf).unwrap();
801 let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
802
803 assert!(decoded.std.capabilities.has("wasi:http"));
804 assert!(decoded.std.capabilities.has("wasi:filesystem"));
805 assert!(!decoded.std.capabilities.has("wasi:sockets"));
806 assert_eq!(decoded.std.capabilities.fs_mount_root(), Some("/data"));
807 }
808
809 #[test]
810 fn capabilities_empty_roundtrip() {
811 let info = ComponentInfo::new("test", "0.1.0", "test");
812 let mut buf = Vec::new();
813 ciborium::into_writer(&info, &mut buf).unwrap();
814 let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
815 assert!(decoded.std.capabilities.is_empty());
816 }
817
818 #[test]
819 fn capabilities_fs_no_params_roundtrip() {
820 use crate::CapabilityRequest;
821 let mut info = ComponentInfo::new("test", "0.1.0", "test");
822 info.std
823 .capabilities
824 .0
825 .insert("wasi:filesystem".into(), CapabilityRequest::default());
826 let mut buf = Vec::new();
827 ciborium::into_writer(&info, &mut buf).unwrap();
828 let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
829 assert!(decoded.std.capabilities.has("wasi:filesystem"));
830 assert_eq!(decoded.std.capabilities.fs_mount_root(), None);
831 }
832
833 #[test]
834 fn capabilities_unknown_preserved() {
835 use crate::CapabilityRequest;
836 let mut info = ComponentInfo::new("test", "0.1.0", "test");
837 info.std.capabilities.0.insert(
838 "acme:gpu".into(),
839 CapabilityRequest {
840 constraints: vec![json!({ "cores": 8 })],
841 ..Default::default()
842 },
843 );
844 let mut buf = Vec::new();
845 ciborium::into_writer(&info, &mut buf).unwrap();
846 let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
847 assert!(decoded.std.capabilities.has("acme:gpu"));
848 assert_eq!(
849 decoded
850 .std
851 .capabilities
852 .get("acme:gpu")
853 .unwrap()
854 .constraints[0]["cores"],
855 8
856 );
857 }
858
859 #[test]
860 fn filesystem_cap_with_allow_roundtrips() {
861 let toml_input = r#"
862[std.capabilities."wasi:filesystem"]
863description = "test"
864
865[[std.capabilities."wasi:filesystem".allow]]
866path = "/etc/**"
867mode = "ro"
868
869[[std.capabilities."wasi:filesystem".allow]]
870path = "/tmp/**"
871mode = "rw"
872"#;
873 #[derive(serde::Deserialize)]
874 struct Wrap {
875 std: Std,
876 }
877 #[derive(serde::Deserialize)]
878 struct Std {
879 capabilities: Capabilities,
880 }
881 let w: Wrap = toml::from_str(toml_input).expect("parses");
882 let fs = w
883 .std
884 .capabilities
885 .get("wasi:filesystem")
886 .expect("fs declared");
887 let allow = fs
888 .constraints_as::<crate::FilesystemAllow>()
889 .expect("parse");
890 assert_eq!(allow.len(), 2);
891 assert_eq!(allow[0].path, "/etc/**");
892 assert_eq!(allow[1].path, "/tmp/**");
893 }
894
895 #[test]
896 fn filesystem_cap_requires_path_and_mode_on_each_entry() {
897 let toml_input = r#"
899[std.capabilities."wasi:filesystem"]
900
901[[std.capabilities."wasi:filesystem".allow]]
902path = "/tmp/**"
903"#;
904 #[derive(serde::Deserialize)]
905 struct Wrap {
906 std: Std,
907 }
908 #[derive(serde::Deserialize)]
909 struct Std {
910 capabilities: Capabilities,
911 }
912 let w: Wrap = toml::from_str(toml_input).expect("toml parses");
913 let fs = w
914 .std
915 .capabilities
916 .get("wasi:filesystem")
917 .expect("fs declared");
918 assert!(
919 fs.constraints_as::<FilesystemAllow>().is_err(),
920 "missing mode must fail"
921 );
922 }
923
924 #[test]
925 fn http_cap_with_allow_roundtrips() {
926 let toml_input = r#"
927[std.capabilities."wasi:http"]
928description = "Calls OpenAI + GitHub"
929
930[[std.capabilities."wasi:http".allow]]
931host = "api.openai.com"
932scheme = "https"
933methods = ["GET", "POST"]
934
935[[std.capabilities."wasi:http".allow]]
936host = "*.github.com"
937scheme = "https"
938"#;
939 #[derive(serde::Deserialize)]
940 struct Wrap {
941 std: Std,
942 }
943 #[derive(serde::Deserialize)]
944 struct Std {
945 capabilities: Capabilities,
946 }
947 let w: Wrap = toml::from_str(toml_input).expect("parses");
948 let http = w.std.capabilities.get("wasi:http").expect("http declared");
949 let allow = http.constraints_as::<HttpAllow>().expect("parse");
950 assert_eq!(allow.len(), 2);
951 assert_eq!(allow[0].host, "api.openai.com");
952 assert_eq!(allow[0].scheme.as_deref(), Some("https"));
953 assert_eq!(
954 allow[0].methods.as_deref(),
955 Some(&["GET".to_string(), "POST".to_string()][..])
956 );
957 assert_eq!(allow[1].host, "*.github.com");
958 }
959
960 #[test]
961 fn http_cap_requires_host_on_each_entry() {
962 let toml_input = r#"
964[std.capabilities."wasi:http"]
965
966[[std.capabilities."wasi:http".allow]]
967scheme = "https"
968"#;
969 #[derive(serde::Deserialize)]
970 struct Wrap {
971 std: Std,
972 }
973 #[derive(serde::Deserialize)]
974 struct Std {
975 capabilities: Capabilities,
976 }
977 let w: Wrap = toml::from_str(toml_input).expect("toml parses");
978 let http = w.std.capabilities.get("wasi:http").expect("http declared");
979 assert!(
980 http.constraints_as::<HttpAllow>().is_err(),
981 "missing host must fail"
982 );
983 }
984
985 #[test]
986 fn http_cap_wildcard_host() {
987 let toml_input = r#"
988[[std.capabilities."wasi:http".allow]]
989host = "*"
990"#;
991 #[derive(serde::Deserialize)]
992 struct Wrap {
993 std: Std,
994 }
995 #[derive(serde::Deserialize)]
996 struct Std {
997 capabilities: Capabilities,
998 }
999 let w: Wrap = toml::from_str(toml_input).expect("parses");
1000 let http = w.std.capabilities.get("wasi:http").expect("http declared");
1001 let allow = http.constraints_as::<HttpAllow>().expect("parse");
1002 assert_eq!(allow[0].host, "*");
1003 }
1004
1005 #[test]
1006 fn sockets_cap_with_allow_roundtrips() {
1007 let toml_input = r#"
1008[std.capabilities."wasi:sockets"]
1009
1010[[std.capabilities."wasi:sockets".allow]]
1011host = "vnc.example.com"
1012ports = [5900]
1013protocols = ["tcp"]
1014
1015[[std.capabilities."wasi:sockets".allow]]
1016cidr = "10.0.0.0/8"
1017ports = [80, 443]
1018"#;
1019 #[derive(serde::Deserialize)]
1020 struct Wrap {
1021 std: Std,
1022 }
1023 #[derive(serde::Deserialize)]
1024 struct Std {
1025 capabilities: Capabilities,
1026 }
1027 let w: Wrap = toml::from_str(toml_input).expect("parses");
1028 let allow = w
1029 .std
1030 .capabilities
1031 .get("wasi:sockets")
1032 .expect("sockets declared")
1033 .constraints_as::<crate::SocketsAllow>()
1034 .expect("parse");
1035 assert_eq!(allow.len(), 2);
1036 let b = &allow[1];
1037 assert_eq!(b.host, None);
1038 assert_eq!(b.cidr.as_deref(), Some("10.0.0.0/8"));
1039 assert_eq!(b.ports, Some(vec![80, 443]));
1040 assert_eq!(b.protocols, vec![SocketProtocol::Tcp, SocketProtocol::Udp]);
1042 }
1043
1044 #[test]
1045 fn sockets_cap_has_string() {
1046 use crate::CapabilityRequest;
1047 let mut c = Capabilities::default();
1048 assert!(!c.has(crate::constants::CAP_SOCKETS));
1049 c.0.insert(
1050 crate::constants::CAP_SOCKETS.into(),
1051 CapabilityRequest::default(),
1052 );
1053 assert!(c.has(crate::constants::CAP_SOCKETS));
1054 }
1055
1056 #[test]
1057 fn sockets_allow_default_protocols_not_emitted() {
1058 let toml_input = r#"
1062[[allow]]
1063host = "vnc.example.com"
1064ports = [5900]
1065"#;
1066 #[derive(serde::Serialize, serde::Deserialize)]
1067 struct W {
1068 allow: Vec<SocketsAllow>,
1069 }
1070 let w: W = toml::from_str(toml_input).unwrap();
1071 assert_eq!(
1072 w.allow[0].protocols,
1073 vec![SocketProtocol::Tcp, SocketProtocol::Udp]
1074 );
1075
1076 let re = toml::to_string(&w).unwrap();
1077 assert!(
1078 !re.contains("protocols"),
1079 "default protocols leaked into re-serialized output: {re}"
1080 );
1081
1082 let w2: W = toml::from_str(&re).unwrap();
1084 assert_eq!(
1085 w2.allow[0].protocols,
1086 vec![SocketProtocol::Tcp, SocketProtocol::Udp]
1087 );
1088 }
1089
1090 #[test]
1091 fn credentials_round_trip_and_are_absent_by_default() {
1092 let mut info = StdComponentInfo::default();
1093 assert!(info.credentials.is_empty(), "absent by default");
1094
1095 let old: StdComponentInfo = serde_json::from_str(r#"{"name":"c","version":"1"}"#).unwrap();
1097 assert!(old.credentials.is_empty());
1098
1099 info.credentials.push(StdCredential {
1100 key: "default".into(),
1101 description: Some("Acme".into()),
1102 fields: vec![StdCredentialField {
1103 key: "acme:tenant".into(),
1104 label: "Tenant".into(),
1105 field_type: "std:string".into(),
1106 secret: false,
1107 required: true,
1108 resource: None,
1109 scopes: vec![],
1110 }],
1111 });
1112 let json = serde_json::to_string(&info).unwrap();
1113 let back: StdComponentInfo = serde_json::from_str(&json).unwrap();
1114 assert_eq!(back.credentials, info.credentials);
1115 assert_eq!(back.credentials[0].fields[0].field_type, "std:string");
1116 }
1117}