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