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 optional: omit it
250/// (or set it absent) to declare a ceiling over **any port**; provide a
251/// non-empty list to restrict to specific ports. `protocols` defaults to
252/// `["tcp", "udp"]` (both).
253#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
254pub struct SocketsAllow {
255    /// Exact host, `*.suffix` wildcard, or `*` for any. Mutually
256    /// exclusive with `cidr`.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub host: Option<String>,
259    /// CIDR (IPv4 or IPv6). Mutually exclusive with `host`.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub cidr: Option<String>,
262    /// Ports this rule applies to. `None` (omitted) means **any port**.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub ports: Option<Vec<u16>>,
265    /// Protocols this rule applies to. Defaults to both.
266    #[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/// Raw socket protocol — TCP or UDP.
282#[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/// Component metadata stored in the `act:component` WASM custom section (CBOR-encoded).
290///
291/// Used by SDK macros (serialization) and host (deserialization).
292/// Also deserializable from `act.toml` manifest via `alias` attributes.
293///
294/// Extra namespaces (not `std`) are collected into `extra`.
295#[non_exhaustive]
296#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
297pub struct ComponentInfo {
298    /// Well-known component metadata.
299    #[serde(default)]
300    pub std: StdComponentInfo,
301    /// Extra namespaces (third-party extensions).
302    #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")]
303    pub extra: HashMap<String, serde_json::Value>,
304}
305
306/// Well-known component metadata under the `std` namespace.
307#[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    /// Author of the component. Populated by `act-build` from the language
323    /// manifest (Cargo.toml `[package].authors`, pyproject `[project].authors`,
324    /// package.json `author`); an `act.toml` `[std] author` overrides it.
325    /// OPTIONAL — omitted from the CBOR section when absent.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub author: Option<String>,
328    /// SPDX license expression for the component. Populated by `act-build` from
329    /// the language manifest (Cargo.toml `[package].license`, pyproject
330    /// `[project].license`, package.json `license`); an `act.toml` `[std]
331    /// license` overrides it. OPTIONAL — omitted from the CBOR section when absent.
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub license: Option<String>,
334    #[serde(default, skip_serializing_if = "Capabilities::is_empty")]
335    pub capabilities: Capabilities,
336    /// Credentials this component declares it expects. OPTIONAL — an artifact
337    /// packed before this existed decodes with an empty vec.
338    #[serde(default, skip_serializing_if = "Vec::is_empty")]
339    pub credentials: Vec<StdCredential>,
340}
341
342/// One credential a component declares it expects (design §4.3). Descriptive,
343/// not restrictive: the boundary is the profile, and a component asking for an
344/// undeclared key is refused by nothing.
345#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
346pub struct StdCredential {
347    pub key: String,
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub description: Option<String>,
350    #[serde(default, skip_serializing_if = "Vec::is_empty")]
351    pub fields: Vec<StdCredentialField>,
352}
353
354/// One field of a declared credential. `field_type` binds the encoding and how
355/// `act login` acquires it (design §3.2).
356#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
357pub struct StdCredentialField {
358    pub key: String,
359    #[serde(default)]
360    pub label: String,
361    #[serde(rename = "type", default = "std_string")]
362    pub field_type: String,
363    #[serde(default = "yes")]
364    pub secret: bool,
365    #[serde(default = "yes")]
366    pub required: bool,
367    /// Flow parameters, meaningful for a `std:oauth2` field and ignored otherwise.
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub resource: Option<String>,
370    #[serde(default, skip_serializing_if = "Vec::is_empty")]
371    pub scopes: Vec<String>,
372}
373
374fn std_string() -> String {
375    "std:string".to_string()
376}
377
378fn yes() -> bool {
379    true
380}
381
382impl ComponentInfo {
383    pub fn new(
384        name: impl Into<String>,
385        version: impl Into<String>,
386        description: impl Into<String>,
387    ) -> Self {
388        Self {
389            std: StdComponentInfo {
390                name: name.into(),
391                version: version.into(),
392                description: description.into(),
393                ..Default::default()
394            },
395            ..Default::default()
396        }
397    }
398
399    // Convenience accessors for backward compatibility.
400    pub fn name(&self) -> &str {
401        &self.std.name
402    }
403    pub fn version(&self) -> &str {
404        &self.std.version
405    }
406    pub fn description(&self) -> &str {
407        &self.std.description
408    }
409}
410
411/// Mount kind for a `wasi:filesystem` `params.mounts` entry (topology only).
412#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
413#[serde(rename_all = "lowercase")]
414pub enum MountType {
415    /// Bind one host directory to a guest path. Requires `host`.
416    #[default]
417    Bind,
418    /// Expose the platform root(s) at a guest path. `host` is forbidden.
419    Root,
420}
421
422/// One entry in `params.mounts` of the `wasi:filesystem` capability.
423///
424/// Pure topology: it makes a host directory *nameable* at a guest path.
425/// Authorization (which host paths, at which mode) stays in `constraints`
426/// (`FilesystemAllow`); a mount carries no access mode.
427#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
428pub struct FilesystemMount {
429    /// Mount kind. Defaults to `bind`.
430    #[serde(rename = "type", default)]
431    pub kind: MountType,
432    /// Guest mount point (POSIX-absolute). Required for `bind`; `root`
433    /// defaults to "/" when omitted.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub guest: Option<String>,
436    /// Host directory (bind only; `~`-expanded by the host). Required iff
437    /// `bind`, forbidden iff `root`.
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub host: Option<String>,
440}
441
442/// Validate a list of mounts. Rules are independent of the constraint set
443/// (cross-checks like the drift lint live in act-build). Returns the first
444/// violation as a human-readable string.
445pub fn validate_mounts(mounts: &[FilesystemMount]) -> Result<(), String> {
446    let mut seen = std::collections::BTreeSet::new();
447    for (i, m) in mounts.iter().enumerate() {
448        let guest = match m.kind {
449            MountType::Bind => {
450                let g = m
451                    .guest
452                    .as_deref()
453                    .ok_or_else(|| format!("mounts[{i}]: bind mount requires `guest`"))?;
454                if m.host.as_deref().is_none_or(str::is_empty) {
455                    return Err(format!("mounts[{i}]: bind mount requires `host`"));
456                }
457                g
458            }
459            MountType::Root => {
460                if m.host.is_some() {
461                    return Err(format!("mounts[{i}]: root mount must not set `host`"));
462                }
463                m.guest.as_deref().unwrap_or("/")
464            }
465        };
466        validate_guest(i, guest)?;
467        if !seen.insert(guest.to_string()) {
468            return Err(format!("mounts[{i}]: duplicate guest path `{guest}`"));
469        }
470    }
471    Ok(())
472}
473
474fn validate_guest(i: usize, guest: &str) -> Result<(), String> {
475    if !guest.starts_with('/') {
476        return Err(format!(
477            "mounts[{i}]: guest `{guest}` must be POSIX-absolute (start with '/')"
478        ));
479    }
480    if guest.contains('\\') || guest.contains(':') {
481        return Err(format!(
482            "mounts[{i}]: guest `{guest}` must not contain a drive letter or backslash"
483        ));
484    }
485    if guest.split('/').any(|c| c == "." || c == "..") {
486        return Err(format!(
487            "mounts[{i}]: guest `{guest}` must not contain '.' or '..' components"
488        ));
489    }
490    Ok(())
491}
492
493#[cfg(test)]
494mod mount_tests {
495    use super::validate_mounts;
496    use super::{FilesystemMount, MountType};
497
498    fn bind(guest: &str, host: &str) -> FilesystemMount {
499        FilesystemMount {
500            kind: MountType::Bind,
501            guest: Some(guest.into()),
502            host: Some(host.into()),
503        }
504    }
505
506    #[test]
507    fn valid_bind_passes() {
508        assert!(validate_mounts(&[bind("/ows", "~/.ows")]).is_ok());
509    }
510
511    #[test]
512    fn bind_without_host_fails() {
513        let m = FilesystemMount {
514            kind: MountType::Bind,
515            guest: Some("/ows".into()),
516            host: None,
517        };
518        assert!(validate_mounts(&[m]).unwrap_err().contains("host"));
519    }
520
521    #[test]
522    fn root_with_host_fails() {
523        let m = FilesystemMount {
524            kind: MountType::Root,
525            guest: Some("/".into()),
526            host: Some("/x".into()),
527        };
528        assert!(validate_mounts(&[m]).unwrap_err().contains("host"));
529    }
530
531    #[test]
532    fn relative_guest_fails() {
533        assert!(
534            validate_mounts(&[bind("ows", "~/.ows")])
535                .unwrap_err()
536                .contains("absolute")
537        );
538    }
539
540    #[test]
541    fn bind_without_guest_fails() {
542        let m = FilesystemMount {
543            kind: MountType::Bind,
544            guest: None,
545            host: Some("~/.ows".into()),
546        };
547        assert!(validate_mounts(&[m]).unwrap_err().contains("guest"));
548    }
549
550    #[test]
551    fn drive_letter_guest_fails() {
552        assert!(
553            validate_mounts(&[bind("/c:/x", "~/.ows")])
554                .unwrap_err()
555                .contains("drive letter or backslash")
556        );
557    }
558
559    #[test]
560    fn dotdot_guest_fails() {
561        assert!(
562            validate_mounts(&[bind("/ows/../etc", "~/.ows")])
563                .unwrap_err()
564                .contains("..")
565        );
566    }
567
568    #[test]
569    fn duplicate_guest_fails() {
570        let e = validate_mounts(&[bind("/ows", "~/a"), bind("/ows", "~/b")]).unwrap_err();
571        assert!(e.contains("duplicate"));
572    }
573
574    #[test]
575    fn bind_is_the_default_type_and_round_trips() {
576        let m: FilesystemMount =
577            serde_json::from_value(serde_json::json!({ "guest": "/ows", "host": "~/.ows" }))
578                .unwrap();
579        assert_eq!(m.kind, MountType::Bind);
580        assert_eq!(m.guest.as_deref(), Some("/ows"));
581        assert_eq!(m.host.as_deref(), Some("~/.ows"));
582
583        let v = serde_json::to_value(&m).unwrap();
584        // `type` defaults to bind and is omitted only if we don't skip; we DO serialize it.
585        assert_eq!(v["type"], "bind");
586        assert_eq!(v["guest"], "/ows");
587        assert_eq!(v["host"], "~/.ows");
588    }
589
590    #[test]
591    fn root_parses_with_type_field_and_no_host() {
592        let m: FilesystemMount =
593            serde_json::from_value(serde_json::json!({ "type": "root", "guest": "/" })).unwrap();
594        assert_eq!(m.kind, MountType::Root);
595        assert_eq!(m.host, None);
596    }
597}
598
599// ── Error type ──
600
601/// Error type mapping to ACT `tool-error`.
602#[derive(Debug, Clone)]
603pub struct ActError {
604    pub kind: String,
605    pub message: String,
606}
607
608impl ActError {
609    pub fn new(kind: impl Into<String>, message: impl Into<String>) -> Self {
610        Self {
611            kind: kind.into(),
612            message: message.into(),
613        }
614    }
615
616    pub fn not_found(message: impl Into<String>) -> Self {
617        Self::new(ERR_NOT_FOUND, message)
618    }
619
620    pub fn invalid_args(message: impl Into<String>) -> Self {
621        Self::new(ERR_INVALID_ARGS, message)
622    }
623
624    pub fn internal(message: impl Into<String>) -> Self {
625        Self::new(ERR_INTERNAL, message)
626    }
627
628    pub fn timeout(message: impl Into<String>) -> Self {
629        Self::new(ERR_TIMEOUT, message)
630    }
631
632    pub fn capability_denied(message: impl Into<String>) -> Self {
633        Self::new(ERR_CAPABILITY_DENIED, message)
634    }
635
636    pub fn session_not_found(message: impl Into<String>) -> Self {
637        Self::new(ERR_SESSION_NOT_FOUND, message)
638    }
639}
640
641impl std::fmt::Display for ActError {
642    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
643        write!(f, "{}: {}", self.kind, self.message)
644    }
645}
646
647impl std::error::Error for ActError {}
648
649/// Result type for ACT operations.
650pub type ActResult<T> = Result<T, ActError>;
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655    use serde_json::json;
656    use std::collections::BTreeMap;
657
658    #[test]
659    fn localized_string_plain() {
660        let ls = LocalizedString::plain("hello");
661        assert_eq!(ls.resolve("en"), "hello");
662        assert_eq!(ls.any_text(), "hello");
663    }
664
665    #[test]
666    fn localized_string_from_str() {
667        let ls = LocalizedString::from("hello");
668        assert_eq!(ls.any_text(), "hello");
669    }
670
671    #[test]
672    fn localized_string_default() {
673        let ls = LocalizedString::default();
674        assert_eq!(ls.any_text(), "");
675    }
676
677    #[test]
678    fn localized_string_resolve_by_lang() {
679        let mut map = std::collections::HashMap::new();
680        map.insert("en".to_string(), "hello".to_string());
681        map.insert("ru".to_string(), "привет".to_string());
682        let ls = LocalizedString::Localized(map);
683        assert_eq!(ls.resolve("ru"), "привет");
684        assert_eq!(ls.resolve("en"), "hello");
685        // Unknown lang falls back to some entry
686        assert!(!ls.resolve("fr").is_empty());
687    }
688
689    #[test]
690    fn localized_string_resolve_prefix() {
691        let mut map = HashMap::new();
692        map.insert("zh-Hans".to_string(), "你好".to_string());
693        map.insert("en".to_string(), "hello".to_string());
694        let ls = LocalizedString::Localized(map);
695        assert_eq!(ls.resolve("zh"), "你好");
696    }
697
698    #[test]
699    fn localized_string_get() {
700        let ls = LocalizedString::new("en", "hello");
701        assert_eq!(ls.get("en"), Some("hello"));
702        assert_eq!(ls.get("ru"), None);
703    }
704
705    #[test]
706    fn localized_string_from_vec() {
707        let v = vec![("en".to_string(), "hi".to_string())];
708        let ls = LocalizedString::from(v);
709        assert_eq!(ls.resolve("en"), "hi");
710    }
711
712    #[test]
713    fn metadata_insert_and_get() {
714        let mut m = Metadata::new();
715        m.insert("std:read-only", true);
716        assert_eq!(m.get("std:read-only"), Some(&json!(true)));
717        assert_eq!(m.get_as::<bool>("std:read-only"), Some(true));
718    }
719
720    #[test]
721    fn metadata_to_json_empty() {
722        let json: serde_json::Value = Metadata::new().into();
723        assert_eq!(json, json!({}));
724    }
725
726    #[test]
727    fn metadata_to_json_with_values() {
728        let mut m = Metadata::new();
729        m.insert("std:read-only", true);
730        let json: serde_json::Value = m.into();
731        assert_eq!(json["std:read-only"], json!(true));
732    }
733
734    #[test]
735    fn metadata_from_vec() {
736        let v = vec![("key".to_string(), cbor::to_cbor(&42u32))];
737        let m = Metadata::from(v);
738        assert_eq!(m.get("key"), Some(&json!(42)));
739        assert_eq!(m.get_as::<u32>("key"), Some(42));
740    }
741
742    #[test]
743    fn author_license_present_roundtrip_and_omitted_when_none() {
744        // Present: both fields serialize (JSON + CBOR round-trip).
745        let mut info = ComponentInfo::new("test", "0.1.0", "test component");
746        info.std.author = Some("Ada Lovelace <ada@example.com>".to_string());
747        info.std.license = Some("Apache-2.0".to_string());
748
749        let json = serde_json::to_value(&info).unwrap();
750        assert_eq!(json["std"]["author"], "Ada Lovelace <ada@example.com>");
751        assert_eq!(json["std"]["license"], "Apache-2.0");
752
753        let mut buf = Vec::new();
754        ciborium::into_writer(&info, &mut buf).unwrap();
755        let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
756        assert_eq!(
757            decoded.std.author.as_deref(),
758            Some("Ada Lovelace <ada@example.com>")
759        );
760        assert_eq!(decoded.std.license.as_deref(), Some("Apache-2.0"));
761
762        // Absent: `skip_serializing_if` omits the keys entirely.
763        let bare = ComponentInfo::new("test", "0.1.0", "test");
764        assert!(bare.std.author.is_none());
765        assert!(bare.std.license.is_none());
766        let bare_json = serde_json::to_value(&bare).unwrap();
767        assert!(bare_json["std"].get("author").is_none());
768        assert!(bare_json["std"].get("license").is_none());
769    }
770
771    #[test]
772    fn old_component_without_author_license_still_parses() {
773        // Forward-compat: a section from an older component omits the new keys.
774        let old = serde_json::json!({
775            "std": { "name": "legacy", "version": "0.1.0", "description": "old" }
776        });
777        let info: ComponentInfo = serde_json::from_value(old).unwrap();
778        assert_eq!(info.std.name, "legacy");
779        assert!(info.std.author.is_none());
780        assert!(info.std.license.is_none());
781    }
782
783    #[test]
784    fn capabilities_cbor_roundtrip() {
785        use crate::CapabilityRequest;
786        let mut info = ComponentInfo::new("test", "0.1.0", "test component");
787        info.std
788            .capabilities
789            .0
790            .insert("wasi:http".into(), CapabilityRequest::default());
791        info.std.capabilities.0.insert(
792            "wasi:filesystem".into(),
793            CapabilityRequest {
794                params: BTreeMap::from([("mount-root".into(), json!("/data"))]),
795                ..Default::default()
796            },
797        );
798
799        let mut buf = Vec::new();
800        ciborium::into_writer(&info, &mut buf).unwrap();
801        let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
802
803        assert!(decoded.std.capabilities.has("wasi:http"));
804        assert!(decoded.std.capabilities.has("wasi:filesystem"));
805        assert!(!decoded.std.capabilities.has("wasi:sockets"));
806        assert_eq!(decoded.std.capabilities.fs_mount_root(), Some("/data"));
807    }
808
809    #[test]
810    fn capabilities_empty_roundtrip() {
811        let info = ComponentInfo::new("test", "0.1.0", "test");
812        let mut buf = Vec::new();
813        ciborium::into_writer(&info, &mut buf).unwrap();
814        let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
815        assert!(decoded.std.capabilities.is_empty());
816    }
817
818    #[test]
819    fn capabilities_fs_no_params_roundtrip() {
820        use crate::CapabilityRequest;
821        let mut info = ComponentInfo::new("test", "0.1.0", "test");
822        info.std
823            .capabilities
824            .0
825            .insert("wasi:filesystem".into(), CapabilityRequest::default());
826        let mut buf = Vec::new();
827        ciborium::into_writer(&info, &mut buf).unwrap();
828        let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
829        assert!(decoded.std.capabilities.has("wasi:filesystem"));
830        assert_eq!(decoded.std.capabilities.fs_mount_root(), None);
831    }
832
833    #[test]
834    fn capabilities_unknown_preserved() {
835        use crate::CapabilityRequest;
836        let mut info = ComponentInfo::new("test", "0.1.0", "test");
837        info.std.capabilities.0.insert(
838            "acme:gpu".into(),
839            CapabilityRequest {
840                constraints: vec![json!({ "cores": 8 })],
841                ..Default::default()
842            },
843        );
844        let mut buf = Vec::new();
845        ciborium::into_writer(&info, &mut buf).unwrap();
846        let decoded: ComponentInfo = ciborium::from_reader(&buf[..]).unwrap();
847        assert!(decoded.std.capabilities.has("acme:gpu"));
848        assert_eq!(
849            decoded
850                .std
851                .capabilities
852                .get("acme:gpu")
853                .unwrap()
854                .constraints[0]["cores"],
855            8
856        );
857    }
858
859    #[test]
860    fn filesystem_cap_with_allow_roundtrips() {
861        let toml_input = r#"
862[std.capabilities."wasi:filesystem"]
863description = "test"
864
865[[std.capabilities."wasi:filesystem".allow]]
866path = "/etc/**"
867mode = "ro"
868
869[[std.capabilities."wasi:filesystem".allow]]
870path = "/tmp/**"
871mode = "rw"
872"#;
873        #[derive(serde::Deserialize)]
874        struct Wrap {
875            std: Std,
876        }
877        #[derive(serde::Deserialize)]
878        struct Std {
879            capabilities: Capabilities,
880        }
881        let w: Wrap = toml::from_str(toml_input).expect("parses");
882        let fs = w
883            .std
884            .capabilities
885            .get("wasi:filesystem")
886            .expect("fs declared");
887        let allow = fs
888            .constraints_as::<crate::FilesystemAllow>()
889            .expect("parse");
890        assert_eq!(allow.len(), 2);
891        assert_eq!(allow[0].path, "/etc/**");
892        assert_eq!(allow[1].path, "/tmp/**");
893    }
894
895    #[test]
896    fn filesystem_cap_requires_path_and_mode_on_each_entry() {
897        // Missing `mode` → parse error at constraints_as time (FilesystemAllow requires mode).
898        let toml_input = r#"
899[std.capabilities."wasi:filesystem"]
900
901[[std.capabilities."wasi:filesystem".allow]]
902path = "/tmp/**"
903"#;
904        #[derive(serde::Deserialize)]
905        struct Wrap {
906            std: Std,
907        }
908        #[derive(serde::Deserialize)]
909        struct Std {
910            capabilities: Capabilities,
911        }
912        let w: Wrap = toml::from_str(toml_input).expect("toml parses");
913        let fs = w
914            .std
915            .capabilities
916            .get("wasi:filesystem")
917            .expect("fs declared");
918        assert!(
919            fs.constraints_as::<FilesystemAllow>().is_err(),
920            "missing mode must fail"
921        );
922    }
923
924    #[test]
925    fn http_cap_with_allow_roundtrips() {
926        let toml_input = r#"
927[std.capabilities."wasi:http"]
928description = "Calls OpenAI + GitHub"
929
930[[std.capabilities."wasi:http".allow]]
931host = "api.openai.com"
932scheme = "https"
933methods = ["GET", "POST"]
934
935[[std.capabilities."wasi:http".allow]]
936host = "*.github.com"
937scheme = "https"
938"#;
939        #[derive(serde::Deserialize)]
940        struct Wrap {
941            std: Std,
942        }
943        #[derive(serde::Deserialize)]
944        struct Std {
945            capabilities: Capabilities,
946        }
947        let w: Wrap = toml::from_str(toml_input).expect("parses");
948        let http = w.std.capabilities.get("wasi:http").expect("http declared");
949        let allow = http.constraints_as::<HttpAllow>().expect("parse");
950        assert_eq!(allow.len(), 2);
951        assert_eq!(allow[0].host, "api.openai.com");
952        assert_eq!(allow[0].scheme.as_deref(), Some("https"));
953        assert_eq!(
954            allow[0].methods.as_deref(),
955            Some(&["GET".to_string(), "POST".to_string()][..])
956        );
957        assert_eq!(allow[1].host, "*.github.com");
958    }
959
960    #[test]
961    fn http_cap_requires_host_on_each_entry() {
962        // Missing `host` → constraints_as::<HttpAllow> fails.
963        let toml_input = r#"
964[std.capabilities."wasi:http"]
965
966[[std.capabilities."wasi:http".allow]]
967scheme = "https"
968"#;
969        #[derive(serde::Deserialize)]
970        struct Wrap {
971            std: Std,
972        }
973        #[derive(serde::Deserialize)]
974        struct Std {
975            capabilities: Capabilities,
976        }
977        let w: Wrap = toml::from_str(toml_input).expect("toml parses");
978        let http = w.std.capabilities.get("wasi:http").expect("http declared");
979        assert!(
980            http.constraints_as::<HttpAllow>().is_err(),
981            "missing host must fail"
982        );
983    }
984
985    #[test]
986    fn http_cap_wildcard_host() {
987        let toml_input = r#"
988[[std.capabilities."wasi:http".allow]]
989host = "*"
990"#;
991        #[derive(serde::Deserialize)]
992        struct Wrap {
993            std: Std,
994        }
995        #[derive(serde::Deserialize)]
996        struct Std {
997            capabilities: Capabilities,
998        }
999        let w: Wrap = toml::from_str(toml_input).expect("parses");
1000        let http = w.std.capabilities.get("wasi:http").expect("http declared");
1001        let allow = http.constraints_as::<HttpAllow>().expect("parse");
1002        assert_eq!(allow[0].host, "*");
1003    }
1004
1005    #[test]
1006    fn sockets_cap_with_allow_roundtrips() {
1007        let toml_input = r#"
1008[std.capabilities."wasi:sockets"]
1009
1010[[std.capabilities."wasi:sockets".allow]]
1011host = "vnc.example.com"
1012ports = [5900]
1013protocols = ["tcp"]
1014
1015[[std.capabilities."wasi:sockets".allow]]
1016cidr = "10.0.0.0/8"
1017ports = [80, 443]
1018"#;
1019        #[derive(serde::Deserialize)]
1020        struct Wrap {
1021            std: Std,
1022        }
1023        #[derive(serde::Deserialize)]
1024        struct Std {
1025            capabilities: Capabilities,
1026        }
1027        let w: Wrap = toml::from_str(toml_input).expect("parses");
1028        let allow = w
1029            .std
1030            .capabilities
1031            .get("wasi:sockets")
1032            .expect("sockets declared")
1033            .constraints_as::<crate::SocketsAllow>()
1034            .expect("parse");
1035        assert_eq!(allow.len(), 2);
1036        let b = &allow[1];
1037        assert_eq!(b.host, None);
1038        assert_eq!(b.cidr.as_deref(), Some("10.0.0.0/8"));
1039        assert_eq!(b.ports, Some(vec![80, 443]));
1040        // `protocols` omitted on the cidr entry → default tcp+udp applies on parse.
1041        assert_eq!(b.protocols, vec![SocketProtocol::Tcp, SocketProtocol::Udp]);
1042    }
1043
1044    #[test]
1045    fn sockets_cap_has_string() {
1046        use crate::CapabilityRequest;
1047        let mut c = Capabilities::default();
1048        assert!(!c.has(crate::constants::CAP_SOCKETS));
1049        c.0.insert(
1050            crate::constants::CAP_SOCKETS.into(),
1051            CapabilityRequest::default(),
1052        );
1053        assert!(c.has(crate::constants::CAP_SOCKETS));
1054    }
1055
1056    #[test]
1057    fn sockets_allow_default_protocols_not_emitted() {
1058        // Manifest author omitted `protocols`: the default (tcp+udp) is
1059        // applied on deserialize but MUST NOT leak back out on re-serialize,
1060        // otherwise host-driven round-trips grow noise.
1061        let toml_input = r#"
1062[[allow]]
1063host = "vnc.example.com"
1064ports = [5900]
1065"#;
1066        #[derive(serde::Serialize, serde::Deserialize)]
1067        struct W {
1068            allow: Vec<SocketsAllow>,
1069        }
1070        let w: W = toml::from_str(toml_input).unwrap();
1071        assert_eq!(
1072            w.allow[0].protocols,
1073            vec![SocketProtocol::Tcp, SocketProtocol::Udp]
1074        );
1075
1076        let re = toml::to_string(&w).unwrap();
1077        assert!(
1078            !re.contains("protocols"),
1079            "default protocols leaked into re-serialized output: {re}"
1080        );
1081
1082        // And a second round-trip still parses cleanly.
1083        let w2: W = toml::from_str(&re).unwrap();
1084        assert_eq!(
1085            w2.allow[0].protocols,
1086            vec![SocketProtocol::Tcp, SocketProtocol::Udp]
1087        );
1088    }
1089
1090    #[test]
1091    fn credentials_round_trip_and_are_absent_by_default() {
1092        let mut info = StdComponentInfo::default();
1093        assert!(info.credentials.is_empty(), "absent by default");
1094
1095        // An artifact packed before this field existed must still decode.
1096        let old: StdComponentInfo = serde_json::from_str(r#"{"name":"c","version":"1"}"#).unwrap();
1097        assert!(old.credentials.is_empty());
1098
1099        info.credentials.push(StdCredential {
1100            key: "default".into(),
1101            description: Some("Acme".into()),
1102            fields: vec![StdCredentialField {
1103                key: "acme:tenant".into(),
1104                label: "Tenant".into(),
1105                field_type: "std:string".into(),
1106                secret: false,
1107                required: true,
1108                resource: None,
1109                scopes: vec![],
1110            }],
1111        });
1112        let json = serde_json::to_string(&info).unwrap();
1113        let back: StdComponentInfo = serde_json::from_str(&json).unwrap();
1114        assert_eq!(back.credentials, info.credentials);
1115        assert_eq!(back.credentials[0].fields[0].field_type, "std:string");
1116    }
1117}