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/// Mount kind for a `wasi:filesystem` `params.mounts` entry (topology only).
354#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
355#[serde(rename_all = "lowercase")]
356pub enum MountType {
357    /// Bind one host directory to a guest path. Requires `host`.
358    #[default]
359    Bind,
360    /// Expose the platform root(s) at a guest path. `host` is forbidden.
361    Root,
362}
363
364/// One entry in `params.mounts` of the `wasi:filesystem` capability.
365///
366/// Pure topology: it makes a host directory *nameable* at a guest path.
367/// Authorization (which host paths, at which mode) stays in `constraints`
368/// (`FilesystemAllow`); a mount carries no access mode.
369#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
370pub struct FilesystemMount {
371    /// Mount kind. Defaults to `bind`.
372    #[serde(rename = "type", default)]
373    pub kind: MountType,
374    /// Guest mount point (POSIX-absolute). Required for `bind`; `root`
375    /// defaults to "/" when omitted.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub guest: Option<String>,
378    /// Host directory (bind only; `~`-expanded by the host). Required iff
379    /// `bind`, forbidden iff `root`.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub host: Option<String>,
382}
383
384/// Validate a list of mounts. Rules are independent of the constraint set
385/// (cross-checks like the drift lint live in act-build). Returns the first
386/// violation as a human-readable string.
387pub 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        // `type` defaults to bind and is omitted only if we don't skip; we DO serialize it.
527        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// ── Error type ──
542
543/// Error type mapping to ACT `tool-error`.
544#[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
591/// Result type for ACT operations.
592pub 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        // Unknown lang falls back to some entry
628        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        // Missing `mode` → parse error at constraints_as time (FilesystemAllow requires mode).
799        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        // Missing `host` → constraints_as::<HttpAllow> fails.
864        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        // `protocols` omitted on the cidr entry → default tcp+udp applies on parse.
942        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        // Manifest author omitted `protocols`: the default (tcp+udp) is
960        // applied on deserialize but MUST NOT leak back out on re-serialize,
961        // otherwise host-driven round-trips grow noise.
962        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        // And a second round-trip still parses cleanly.
984        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}