Skip to main content

act_types/
types.rs

1use std::collections::HashMap;
2
3use crate::cbor;
4
5// ── LocalizedString ──
6
7/// A localizable text value, matching the WIT `localized-string` variant.
8///
9/// - `Plain` — a single string in the component's `default-language`.
10/// - `Localized` — a map of BCP 47 language tags to text.
11#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
12#[serde(untagged)]
13pub enum LocalizedString {
14    /// A single string assumed to be in the component's `default-language`.
15    Plain(String),
16    /// Language tag → text map. MUST include the component's `default-language`.
17    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    /// Create a plain (non-localized) string.
28    pub fn plain(text: impl Into<String>) -> Self {
29        Self::Plain(text.into())
30    }
31
32    /// Create a localized string with a single language entry.
33    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    /// Look up text for a specific language tag.
40    ///
41    /// For `Plain`, always returns the text (it is assumed to match any language).
42    /// For `Localized`, performs exact key lookup.
43    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    /// Resolve to text for the given language, with fallback chain.
51    ///
52    /// - `Plain` → returns the plain string (assumed to be in `default_language`).
53    /// - `Localized` → exact match → prefix match → any entry.
54    pub fn resolve(&self, lang: &str) -> &str {
55        match self {
56            Self::Plain(text) => text.as_str(),
57            Self::Localized(map) => {
58                // 1. Exact match
59                if let Some(text) = map.get(lang) {
60                    return text.as_str();
61                }
62                // 2. Prefix match (e.g. "zh" matches "zh-Hans")
63                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                // 3. Any entry
71                map.values().next().map(|s| s.as_str()).unwrap_or("")
72            }
73        }
74    }
75
76    /// Get some text, regardless of language.
77    /// Useful when you don't have the default language available.
78    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// ── Metadata ──
111
112/// Key → value metadata, stored as JSON values internally.
113///
114/// Converts to/from WIT `list<tuple<string, list<u8>>>` (CBOR) at the boundary.
115#[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    /// Insert a value. Overwrites any existing entry for the key.
124    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    /// Get a value by key.
129    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
130        self.0.get(key)
131    }
132
133    /// Get a value by key, deserializing into a typed value.
134    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    /// Check if a key exists.
141    pub fn contains_key(&self, key: &str) -> bool {
142        self.0.contains_key(key)
143    }
144
145    /// Returns true if there are no entries.
146    pub fn is_empty(&self) -> bool {
147        self.0.is_empty()
148    }
149
150    /// Iterate over key-value pairs.
151    pub fn iter(&self) -> impl Iterator<Item = (&String, &serde_json::Value)> {
152        self.0.iter()
153    }
154
155    /// Number of entries.
156    pub fn len(&self) -> usize {
157        self.0.len()
158    }
159
160    /// Merge all entries from `other` into `self`. Entries in `other` overwrite existing keys.
161    pub fn extend(&mut self, other: Metadata) {
162        self.0.extend(other.0);
163    }
164}
165
166/// Convert from a JSON object value. Non-object values produce empty metadata.
167impl 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
176/// Convert to a JSON object value (consuming).
177impl 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
183/// Convert from WIT metadata (CBOR-encoded values).
184impl 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
197/// Convert to WIT metadata (CBOR-encoded values).
198impl 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// ── Component info (act:component custom section) ──
210
211/// One path × mode entry in a `[std.capabilities."wasi:filesystem"].allow` array.
212/// Both fields are required.
213#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
214pub struct FilesystemAllow {
215    /// Glob pattern (matches the user-policy `allow` / `deny` shape).
216    pub path: String,
217    /// Access mode the component requests.
218    pub mode: FsMode,
219}
220
221/// Filesystem access mode a component declares for a path.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
223#[serde(rename_all = "lowercase")]
224pub enum FsMode {
225    /// Read-only.
226    Ro,
227    /// Read-write.
228    Rw,
229}
230
231/// One entry in a `[std.capabilities."wasi:http"].allow` array.
232///
233/// `host` is required (exact match, `*.suffix` wildcard, or `*` for any).
234/// Other fields are optional narrowers. Declarations never carry `cidr`,
235/// `except_ports`, or `deny` — those are user-policy concerns.
236#[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/// One entry in a `[std.capabilities."wasi:sockets"].allow` array.
248///
249/// Exactly one of `host` or `cidr` is required. `ports` is required and
250/// must be non-empty — there is no "any port". `protocols` defaults to
251/// `["tcp", "udp"]` (both).
252#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
253pub struct SocketsAllow {
254    /// Exact host, `*.suffix` wildcard, or `*` for any. Mutually
255    /// exclusive with `cidr`.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub host: Option<String>,
258    /// CIDR (IPv4 or IPv6). Mutually exclusive with `host`.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub cidr: Option<String>,
261    /// Ports this rule applies to. Required, non-empty.
262    pub ports: Vec<u16>,
263    /// Protocols this rule applies to. Defaults to both.
264    #[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/// Raw socket protocol — TCP or UDP.
280#[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/// Component metadata stored in the `act:component` WASM custom section (CBOR-encoded).
288///
289/// Used by SDK macros (serialization) and host (deserialization).
290/// Also deserializable from `act.toml` manifest via `alias` attributes.
291///
292/// Extra namespaces (not `std`) are collected into `extra`.
293#[non_exhaustive]
294#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
295pub struct ComponentInfo {
296    /// Well-known component metadata.
297    #[serde(default)]
298    pub std: StdComponentInfo,
299    /// Extra namespaces (third-party extensions).
300    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
301    pub extra: HashMap<String, serde_json::Value>,
302}
303
304/// Well-known component metadata under the `std` namespace.
305#[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    // Convenience accessors for backward compatibility.
342    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// ── Error type ──
354
355/// Error type mapping to ACT `tool-error`.
356#[derive(Debug, Clone)]
357pub struct ActError {
358    pub kind: String,
359    pub message: String,
360}
361
362impl ActError {
363    pub fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
364        Self {
365            kind: kind.into(),
366            message: message.into(),
367        }
368    }
369
370    pub fn not_found(message: impl Into<String>) -> Self {
371        Self::new(ERR_NOT_FOUND, message)
372    }
373
374    pub fn invalid_args(message: impl Into<String>) -> Self {
375        Self::new(ERR_INVALID_ARGS, message)
376    }
377
378    pub fn internal(message: impl Into<String>) -> Self {
379        Self::new(ERR_INTERNAL, message)
380    }
381
382    pub fn timeout(message: impl Into<String>) -> Self {
383        Self::new(ERR_TIMEOUT, message)
384    }
385
386    pub fn capability_denied(message: impl Into<String>) -> Self {
387        Self::new(ERR_CAPABILITY_DENIED, message)
388    }
389
390    pub fn session_not_found(message: impl Into<String>) -> Self {
391        Self::new(ERR_SESSION_NOT_FOUND, message)
392    }
393}
394
395impl std::fmt::Display for ActError {
396    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397        write!(f, "{}: {}", self.kind, self.message)
398    }
399}
400
401impl std::error::Error for ActError {}
402
403/// Result type for ACT operations.
404pub type ActResult<T> = Result<T, ActError>;
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use serde_json::json;
410    use std::collections::BTreeMap;
411
412    #[test]
413    fn localized_string_plain() {
414        let ls = LocalizedString::plain("hello");
415        assert_eq!(ls.resolve("en"), "hello");
416        assert_eq!(ls.any_text(), "hello");
417    }
418
419    #[test]
420    fn localized_string_from_str() {
421        let ls = LocalizedString::from("hello");
422        assert_eq!(ls.any_text(), "hello");
423    }
424
425    #[test]
426    fn localized_string_default() {
427        let ls = LocalizedString::default();
428        assert_eq!(ls.any_text(), "");
429    }
430
431    #[test]
432    fn localized_string_resolve_by_lang() {
433        let mut map = std::collections::HashMap::new();
434        map.insert("en".to_string(), "hello".to_string());
435        map.insert("ru".to_string(), "привет".to_string());
436        let ls = LocalizedString::Localized(map);
437        assert_eq!(ls.resolve("ru"), "привет");
438        assert_eq!(ls.resolve("en"), "hello");
439        // Unknown lang falls back to some entry
440        assert!(!ls.resolve("fr").is_empty());
441    }
442
443    #[test]
444    fn localized_string_resolve_prefix() {
445        let mut map = HashMap::new();
446        map.insert("zh-Hans".to_string(), "你好".to_string());
447        map.insert("en".to_string(), "hello".to_string());
448        let ls = LocalizedString::Localized(map);
449        assert_eq!(ls.resolve("zh"), "你好");
450    }
451
452    #[test]
453    fn localized_string_get() {
454        let ls = LocalizedString::new("en", "hello");
455        assert_eq!(ls.get("en"), Some("hello"));
456        assert_eq!(ls.get("ru"), None);
457    }
458
459    #[test]
460    fn localized_string_from_vec() {
461        let v = vec![("en".to_string(), "hi".to_string())];
462        let ls = LocalizedString::from(v);
463        assert_eq!(ls.resolve("en"), "hi");
464    }
465
466    #[test]
467    fn metadata_insert_and_get() {
468        let mut m = Metadata::new();
469        m.insert("std:read-only", true);
470        assert_eq!(m.get("std:read-only"), Some(&json!(true)));
471        assert_eq!(m.get_as::<bool>("std:read-only"), Some(true));
472    }
473
474    #[test]
475    fn metadata_to_json_empty() {
476        let json: serde_json::Value = Metadata::new().into();
477        assert_eq!(json, json!({}));
478    }
479
480    #[test]
481    fn metadata_to_json_with_values() {
482        let mut m = Metadata::new();
483        m.insert("std:read-only", true);
484        let json: serde_json::Value = m.into();
485        assert_eq!(json["std:read-only"], json!(true));
486    }
487
488    #[test]
489    fn metadata_from_vec() {
490        let v = vec![("key".to_string(), cbor::to_cbor(&42u32))];
491        let m = Metadata::from(v);
492        assert_eq!(m.get("key"), Some(&json!(42)));
493        assert_eq!(m.get_as::<u32>("key"), Some(42));
494    }
495
496    #[test]
497    fn capabilities_cbor_roundtrip() {
498        use crate::CapabilityRequest;
499        let mut info = ComponentInfo::new("test", "0.1.0", "test component");
500        info.std
501            .capabilities
502            .0
503            .insert("wasi:http".into(), CapabilityRequest::default());
504        info.std.capabilities.0.insert(
505            "wasi:filesystem".into(),
506            CapabilityRequest {
507                params: BTreeMap::from([("mount-root".into(), json!("/data"))]),
508                ..Default::default()
509            },
510        );
511
512        let mut buf = Vec::new();
513        ciborium::into_writer(&info, &mut buf).unwrap();
514        let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
515
516        assert!(decoded.std.capabilities.has("wasi:http"));
517        assert!(decoded.std.capabilities.has("wasi:filesystem"));
518        assert!(!decoded.std.capabilities.has("wasi:sockets"));
519        assert_eq!(decoded.std.capabilities.fs_mount_root(), Some("/data"));
520    }
521
522    #[test]
523    fn capabilities_empty_roundtrip() {
524        let info = ComponentInfo::new("test", "0.1.0", "test");
525        let mut buf = Vec::new();
526        ciborium::into_writer(&info, &mut buf).unwrap();
527        let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
528        assert!(decoded.std.capabilities.is_empty());
529    }
530
531    #[test]
532    fn capabilities_fs_no_params_roundtrip() {
533        use crate::CapabilityRequest;
534        let mut info = ComponentInfo::new("test", "0.1.0", "test");
535        info.std
536            .capabilities
537            .0
538            .insert("wasi:filesystem".into(), CapabilityRequest::default());
539        let mut buf = Vec::new();
540        ciborium::into_writer(&info, &mut buf).unwrap();
541        let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
542        assert!(decoded.std.capabilities.has("wasi:filesystem"));
543        assert_eq!(decoded.std.capabilities.fs_mount_root(), None);
544    }
545
546    #[test]
547    fn capabilities_unknown_preserved() {
548        use crate::CapabilityRequest;
549        let mut info = ComponentInfo::new("test", "0.1.0", "test");
550        info.std.capabilities.0.insert(
551            "acme:gpu".into(),
552            CapabilityRequest {
553                constraints: vec![json!({ "cores": 8 })],
554                ..Default::default()
555            },
556        );
557        let mut buf = Vec::new();
558        ciborium::into_writer(&info, &mut buf).unwrap();
559        let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
560        assert!(decoded.std.capabilities.has("acme:gpu"));
561        assert_eq!(
562            decoded
563                .std
564                .capabilities
565                .get("acme:gpu")
566                .unwrap()
567                .constraints[0]["cores"],
568            8
569        );
570    }
571
572    #[test]
573    fn filesystem_cap_with_allow_roundtrips() {
574        let toml_input = r#"
575[std.capabilities."wasi:filesystem"]
576description = "test"
577
578[[std.capabilities."wasi:filesystem".allow]]
579path = "/etc/**"
580mode = "ro"
581
582[[std.capabilities."wasi:filesystem".allow]]
583path = "/tmp/**"
584mode = "rw"
585"#;
586        #[derive(serde::Deserialize)]
587        struct Wrap {
588            std: Std,
589        }
590        #[derive(serde::Deserialize)]
591        struct Std {
592            capabilities: Capabilities,
593        }
594        let w: Wrap = toml::from_str(toml_input).expect("parses");
595        let fs = w
596            .std
597            .capabilities
598            .get("wasi:filesystem")
599            .expect("fs declared");
600        let allow = fs
601            .constraints_as::<crate::FilesystemAllow>()
602            .expect("parse");
603        assert_eq!(allow.len(), 2);
604        assert_eq!(allow[0].path, "/etc/**");
605        assert_eq!(allow[1].path, "/tmp/**");
606    }
607
608    #[test]
609    fn filesystem_cap_requires_path_and_mode_on_each_entry() {
610        // Missing `mode` → parse error at constraints_as time (FilesystemAllow requires mode).
611        let toml_input = r#"
612[std.capabilities."wasi:filesystem"]
613
614[[std.capabilities."wasi:filesystem".allow]]
615path = "/tmp/**"
616"#;
617        #[derive(serde::Deserialize)]
618        struct Wrap {
619            std: Std,
620        }
621        #[derive(serde::Deserialize)]
622        struct Std {
623            capabilities: Capabilities,
624        }
625        let w: Wrap = toml::from_str(toml_input).expect("toml parses");
626        let fs = w
627            .std
628            .capabilities
629            .get("wasi:filesystem")
630            .expect("fs declared");
631        assert!(
632            fs.constraints_as::<FilesystemAllow>().is_err(),
633            "missing mode must fail"
634        );
635    }
636
637    #[test]
638    fn http_cap_with_allow_roundtrips() {
639        let toml_input = r#"
640[std.capabilities."wasi:http"]
641description = "Calls OpenAI + GitHub"
642
643[[std.capabilities."wasi:http".allow]]
644host = "api.openai.com"
645scheme = "https"
646methods = ["GET", "POST"]
647
648[[std.capabilities."wasi:http".allow]]
649host = "*.github.com"
650scheme = "https"
651"#;
652        #[derive(serde::Deserialize)]
653        struct Wrap {
654            std: Std,
655        }
656        #[derive(serde::Deserialize)]
657        struct Std {
658            capabilities: Capabilities,
659        }
660        let w: Wrap = toml::from_str(toml_input).expect("parses");
661        let http = w.std.capabilities.get("wasi:http").expect("http declared");
662        let allow = http.constraints_as::<HttpAllow>().expect("parse");
663        assert_eq!(allow.len(), 2);
664        assert_eq!(allow[0].host, "api.openai.com");
665        assert_eq!(allow[0].scheme.as_deref(), Some("https"));
666        assert_eq!(
667            allow[0].methods.as_deref(),
668            Some(&["GET".to_string(), "POST".to_string()][..])
669        );
670        assert_eq!(allow[1].host, "*.github.com");
671    }
672
673    #[test]
674    fn http_cap_requires_host_on_each_entry() {
675        // Missing `host` → constraints_as::<HttpAllow> fails.
676        let toml_input = r#"
677[std.capabilities."wasi:http"]
678
679[[std.capabilities."wasi:http".allow]]
680scheme = "https"
681"#;
682        #[derive(serde::Deserialize)]
683        struct Wrap {
684            std: Std,
685        }
686        #[derive(serde::Deserialize)]
687        struct Std {
688            capabilities: Capabilities,
689        }
690        let w: Wrap = toml::from_str(toml_input).expect("toml parses");
691        let http = w.std.capabilities.get("wasi:http").expect("http declared");
692        assert!(
693            http.constraints_as::<HttpAllow>().is_err(),
694            "missing host must fail"
695        );
696    }
697
698    #[test]
699    fn http_cap_wildcard_host() {
700        let toml_input = r#"
701[[std.capabilities."wasi:http".allow]]
702host = "*"
703"#;
704        #[derive(serde::Deserialize)]
705        struct Wrap {
706            std: Std,
707        }
708        #[derive(serde::Deserialize)]
709        struct Std {
710            capabilities: Capabilities,
711        }
712        let w: Wrap = toml::from_str(toml_input).expect("parses");
713        let http = w.std.capabilities.get("wasi:http").expect("http declared");
714        let allow = http.constraints_as::<HttpAllow>().expect("parse");
715        assert_eq!(allow[0].host, "*");
716    }
717
718    #[test]
719    fn sockets_cap_with_allow_roundtrips() {
720        let toml_input = r#"
721[std.capabilities."wasi:sockets"]
722
723[[std.capabilities."wasi:sockets".allow]]
724host = "vnc.example.com"
725ports = [5900]
726protocols = ["tcp"]
727
728[[std.capabilities."wasi:sockets".allow]]
729cidr = "10.0.0.0/8"
730ports = [80, 443]
731"#;
732        #[derive(serde::Deserialize)]
733        struct Wrap {
734            std: Std,
735        }
736        #[derive(serde::Deserialize)]
737        struct Std {
738            capabilities: Capabilities,
739        }
740        let w: Wrap = toml::from_str(toml_input).expect("parses");
741        let allow = w
742            .std
743            .capabilities
744            .get("wasi:sockets")
745            .expect("sockets declared")
746            .constraints_as::<crate::SocketsAllow>()
747            .expect("parse");
748        assert_eq!(allow.len(), 2);
749        let b = &allow[1];
750        assert_eq!(b.host, None);
751        assert_eq!(b.cidr.as_deref(), Some("10.0.0.0/8"));
752        assert_eq!(b.ports, vec![80, 443]);
753        // `protocols` omitted on the cidr entry → default tcp+udp applies on parse.
754        assert_eq!(b.protocols, vec![SocketProtocol::Tcp, SocketProtocol::Udp]);
755    }
756
757    #[test]
758    fn sockets_cap_has_string() {
759        use crate::CapabilityRequest;
760        let mut c = Capabilities::default();
761        assert!(!c.has(crate::constants::CAP_SOCKETS));
762        c.0.insert(
763            crate::constants::CAP_SOCKETS.into(),
764            CapabilityRequest::default(),
765        );
766        assert!(c.has(crate::constants::CAP_SOCKETS));
767    }
768
769    #[test]
770    fn sockets_allow_default_protocols_not_emitted() {
771        // Manifest author omitted `protocols`: the default (tcp+udp) is
772        // applied on deserialize but MUST NOT leak back out on re-serialize,
773        // otherwise host-driven round-trips grow noise.
774        let toml_input = r#"
775[[allow]]
776host = "vnc.example.com"
777ports = [5900]
778"#;
779        #[derive(serde::Serialize, serde::Deserialize)]
780        struct W {
781            allow: Vec<SocketsAllow>,
782        }
783        let w: W = toml::from_str(toml_input).unwrap();
784        assert_eq!(
785            w.allow[0].protocols,
786            vec![SocketProtocol::Tcp, SocketProtocol::Udp]
787        );
788
789        let re = toml::to_string(&w).unwrap();
790        assert!(
791            !re.contains("protocols"),
792            "default protocols leaked into re-serialized output: {re}"
793        );
794
795        // And a second round-trip still parses cleanly.
796        let w2: W = toml::from_str(&re).unwrap();
797        assert_eq!(
798            w2.allow[0].protocols,
799            vec![SocketProtocol::Tcp, SocketProtocol::Udp]
800        );
801    }
802}