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