Skip to main content

artifactize_core/scope/
argument.rs

1//! Validated declared argv references and their exact authored string form.
2
3use super::{ScopeError, instruction, logical_path};
4use serde::Serialize;
5
6/// An Artifact root has no suffix; non-root suffixes are validated logical paths.
7#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
8pub enum LogicalTarget {
9    Root,
10    Suffix(LogicalSuffix),
11}
12
13/// A nonempty relative logical path. Its private storage cannot bypass validation.
14#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
15pub struct LogicalSuffix(String);
16
17impl std::str::FromStr for LogicalSuffix {
18    type Err = ScopeError;
19    fn from_str(path: &str) -> Result<Self, Self::Err> {
20        logical_path(path)?;
21        if path.is_empty() {
22            return Err(ScopeError(
23                "Artifact argument suffix must be nonempty.".into(),
24            ));
25        }
26        Ok(Self(path.to_owned()))
27    }
28}
29
30impl std::str::FromStr for LogicalTarget {
31    type Err = ScopeError;
32    fn from_str(path: &str) -> Result<Self, Self::Err> {
33        if path.is_empty() {
34            Ok(Self::Root)
35        } else {
36            path.parse().map(Self::Suffix)
37        }
38    }
39}
40
41impl std::ops::Deref for LogicalTarget {
42    type Target = str;
43    fn deref(&self) -> &str {
44        match self {
45            Self::Root => "",
46            Self::Suffix(path) => &path.0,
47        }
48    }
49}
50
51impl LogicalTarget {
52    pub fn as_str(&self) -> &str {
53        self
54    }
55}
56
57impl Serialize for LogicalTarget {
58    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
59        serializer.serialize_str(self.as_str())
60    }
61}
62
63impl AsRef<std::path::Path> for LogicalTarget {
64    fn as_ref(&self) -> &std::path::Path {
65        std::path::Path::new(self.as_str())
66    }
67}
68
69impl std::fmt::Display for LogicalTarget {
70    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        formatter.write_str(self)
72    }
73}
74
75/// A declared argument, parsed once when its declaration loads: either a plain literal, or a
76/// `{name}[/path]` / `--flag={name}[/path]` Artifact reference. Parsing here only checks shape
77/// (a single well-formed reference, a valid path suffix); whether `name` names a real Artifact
78/// is a separate, config-wide check ([`reference_target`]) run once every declaration is loaded.
79/// [`std::fmt::Display`] reconstructs the exact original text, so round-tripping through the
80/// saved `Vec<String>` forms (`StoredProfile`, Views) changes no byte and no reuse key.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum Argument {
83    Literal(String),
84    Reference {
85        prefix: String,
86        name: crate::config::ReferenceName,
87        path: LogicalTarget,
88    },
89}
90
91impl std::str::FromStr for Argument {
92    type Err = ScopeError;
93    fn from_str(argument: &str) -> Result<Self, Self::Err> {
94        let references = instruction::references(argument);
95        let Some(reference) = references.first() else {
96            return Ok(Self::Literal(argument.to_owned()));
97        };
98        let prefix = &argument[..reference.start];
99        let flag = prefix
100            .strip_prefix("--")
101            .and_then(|flag| flag.strip_suffix('='))
102            .is_some_and(|flag| {
103                !flag.is_empty()
104                    && flag
105                        .bytes()
106                        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
107            });
108        if references.len() != 1 || (!prefix.is_empty() && !flag) {
109            return Err(ScopeError(
110                "Artifact arguments must be {name}[/path] or --flag={name}[/path].".into(),
111            ));
112        }
113        let suffix = &argument[reference.end..];
114        let path = if suffix.is_empty() {
115            ""
116        } else {
117            suffix
118                .strip_prefix('/')
119                .filter(|path| !path.is_empty())
120                .ok_or_else(|| {
121                    ScopeError("Artifact argument suffix must be a nonempty /path.".into())
122                })?
123        };
124        logical_path(path)?;
125        Ok(Self::Reference {
126            prefix: prefix.to_owned(),
127            name: reference.name.clone(),
128            path: path.parse()?,
129        })
130    }
131}
132
133impl std::fmt::Display for Argument {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        match self {
136            Self::Literal(text) => f.write_str(text),
137            Self::Reference { prefix, name, path } => {
138                write!(f, "{prefix}{{{name}}}")?;
139                if !path.is_empty() {
140                    write!(f, "/{path}")?;
141                }
142                Ok(())
143            }
144        }
145    }
146}
147
148impl TryFrom<String> for Argument {
149    type Error = ScopeError;
150    fn try_from(value: String) -> Result<Self, Self::Error> {
151        value.parse()
152    }
153}
154
155impl From<Argument> for String {
156    fn from(argument: Argument) -> Self {
157        argument.to_string()
158    }
159}
160
161impl Serialize for Argument {
162    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
163        serializer.serialize_str(&self.to_string())
164    }
165}
166
167impl<'de> serde::Deserialize<'de> for Argument {
168    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
169        use serde::de::Error;
170        String::deserialize(deserializer)?
171            .parse()
172            .map_err(|failure: ScopeError| D::Error::custom(failure.0))
173    }
174}
175
176/// For fixtures: a trusted literal string, as declarations and test tables already spell it.
177impl From<&str> for Argument {
178    fn from(value: &str) -> Self {
179        value.parse().expect("a valid declared argument")
180    }
181}
182
183/// Equal iff `other`, spelled as a declared argument, is the same literal or reference: not a
184/// mere prefix/suffix match, and never panics on a malformed `other` (it is just unequal).
185impl PartialEq<str> for Argument {
186    fn eq(&self, other: &str) -> bool {
187        other.parse::<Self>().is_ok_and(|parsed| *self == parsed)
188    }
189}
190impl PartialEq<&str> for Argument {
191    fn eq(&self, other: &&str) -> bool {
192        self == *other
193    }
194}
195impl PartialEq<Argument> for str {
196    fn eq(&self, other: &Argument) -> bool {
197        other == self
198    }
199}
200impl PartialEq<Argument> for String {
201    fn eq(&self, other: &Argument) -> bool {
202        other == self.as_str()
203    }
204}
205impl PartialEq<String> for Argument {
206    fn eq(&self, other: &String) -> bool {
207        self == other.as_str()
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn reference_suffix_boundary_preserves_root_and_rejects_unsafe_paths() {
217        for text in [
218            "{owner}",
219            "{owner}/folder/file",
220            "--input={owner}/folder/file",
221        ] {
222            let argument: Argument = serde_json::from_value(serde_json::json!(text)).unwrap();
223            assert_eq!(
224                serde_json::to_value(argument).unwrap(),
225                serde_json::json!(text)
226            );
227        }
228        let root: LogicalTarget = "".parse().unwrap();
229        assert_eq!(root, LogicalTarget::Root);
230        assert_eq!(serde_json::to_value(root).unwrap(), "");
231        let suffix: LogicalTarget = "folder/file".parse().unwrap();
232        assert_eq!(serde_json::to_value(suffix).unwrap(), "folder/file");
233        for invalid in ["", ".", "../outside", "a//b", "a/../b", "a\\b", "a\0b"] {
234            assert!(invalid.parse::<LogicalSuffix>().is_err(), "{invalid:?}");
235        }
236        for invalid in [
237            "{owner}/",
238            "{owner}/../outside",
239            "{owner}/a//b",
240            "{owner}/.",
241        ] {
242            assert!(serde_json::from_value::<Argument>(serde_json::json!(invalid)).is_err());
243        }
244    }
245}