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