Skip to main content

mars_schema/
names.rs

1//! Names and relative coordinates the dependency vocabulary is built from: item names, source
2//! subpaths, and rename maps. Pure string handling; separators normalize to `/` on every host.
3
4use serde::{Deserialize, Serialize};
5use std::fmt;
6use std::path::{Component, Path, PathBuf};
7
8#[macro_export]
9macro_rules! string_newtype {
10    ($(#[$meta:meta])* $name:ident) => {
11        $(#[$meta])*
12        #[derive(
13            ::serde::Serialize, ::serde::Deserialize, Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd,
14        )]
15        #[serde(transparent)]
16        pub struct $name(String);
17
18        impl From<String> for $name {
19            fn from(value: String) -> Self {
20                Self(value)
21            }
22        }
23
24        impl From<&str> for $name {
25            fn from(value: &str) -> Self {
26                Self(value.to_owned())
27            }
28        }
29
30        impl AsRef<str> for $name {
31            fn as_ref(&self) -> &str {
32                &self.0
33            }
34        }
35
36        impl ::std::borrow::Borrow<str> for $name {
37            fn borrow(&self) -> &str {
38                &self.0
39            }
40        }
41
42        impl ::std::ops::Deref for $name {
43            type Target = str;
44
45            fn deref(&self) -> &Self::Target {
46                &self.0
47            }
48        }
49
50        impl From<$name> for String {
51            fn from(value: $name) -> Self {
52                value.0
53            }
54        }
55
56        impl ::std::fmt::Display for $name {
57            fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
58                f.write_str(&self.0)
59            }
60        }
61
62        impl PartialEq<str> for $name {
63            fn eq(&self, other: &str) -> bool {
64                self.0 == other
65            }
66        }
67
68        impl PartialEq<&str> for $name {
69            fn eq(&self, other: &&str) -> bool {
70                self.0 == *other
71            }
72        }
73
74        impl PartialEq<String> for $name {
75            fn eq(&self, other: &String) -> bool {
76                self.0 == *other
77            }
78        }
79
80        impl PartialEq<$name> for String {
81            fn eq(&self, other: &$name) -> bool {
82                *self == other.0
83            }
84        }
85    };
86}
87
88string_newtype!(
89    /// The slug an item is selected, renamed and installed by.
90    ItemName
91);
92
93impl ItemName {
94    pub fn as_str(&self) -> &str {
95        &self.0
96    }
97}
98
99/// Why a relative coordinate was refused.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum RelativePathError {
102    Empty,
103    Absolute,
104    Escaping,
105}
106
107/// Normalize a relative path coordinate to forward-slash segments: `\` becomes `/`, `.` and
108/// trailing separators drop, and an empty, rooted, drive-lettered (`C:/`) or `..` coordinate is
109/// refused.
110pub fn normalize_relative(raw: &str) -> Result<String, RelativePathError> {
111    if raw.is_empty() {
112        return Err(RelativePathError::Empty);
113    }
114    let normalized_separators = raw.replace('\\', "/");
115    if is_windows_absolute(&normalized_separators) {
116        return Err(RelativePathError::Absolute);
117    }
118
119    let mut segments = Vec::new();
120    for component in Path::new(&normalized_separators).components() {
121        match component {
122            Component::Normal(seg) => segments.push(seg.to_string_lossy().into_owned()),
123            Component::CurDir => {}
124            Component::ParentDir => return Err(RelativePathError::Escaping),
125            Component::RootDir | Component::Prefix(_) => return Err(RelativePathError::Absolute),
126        }
127    }
128
129    if segments.is_empty() {
130        return Err(RelativePathError::Empty);
131    }
132
133    Ok(segments.join("/"))
134}
135
136fn is_windows_absolute(path: &str) -> bool {
137    let bytes = path.as_bytes();
138    path.starts_with('/')
139        || (bytes.len() >= 3
140            && bytes[0].is_ascii_alphabetic()
141            && bytes[1] == b':'
142            && bytes[2] == b'/')
143}
144
145/// Normalized relative package coordinate under a fetched source root.
146#[derive(Hash, Eq, PartialEq, Clone, Debug, Ord, PartialOrd)]
147pub struct SourceSubpath(String);
148
149impl SourceSubpath {
150    pub fn new(value: impl AsRef<str>) -> Result<Self, SourceSubpathError> {
151        let raw = value.as_ref();
152        let normalized = normalize_relative(raw).map_err(|err| match err {
153            RelativePathError::Empty => SourceSubpathError::Empty,
154            RelativePathError::Absolute => SourceSubpathError::Absolute {
155                input: raw.to_string(),
156            },
157            RelativePathError::Escaping => SourceSubpathError::Escaping {
158                input: raw.to_string(),
159            },
160        })?;
161
162        Ok(Self(normalized))
163    }
164
165    pub fn as_str(&self) -> &str {
166        &self.0
167    }
168
169    pub fn as_path(&self) -> &Path {
170        Path::new(&self.0)
171    }
172    /// Join this relative subpath under `base`, rejecting traversal attempts.
173    pub fn join_under(&self, base: &Path) -> Result<PathBuf, SourceSubpathError> {
174        let mut joined = base.to_path_buf();
175        for component in self.as_path().components() {
176            match component {
177                Component::Normal(seg) => joined.push(seg),
178                Component::CurDir => {}
179                Component::ParentDir => {
180                    return Err(SourceSubpathError::Escaping {
181                        input: self.0.clone(),
182                    });
183                }
184                Component::RootDir | Component::Prefix(_) => {
185                    return Err(SourceSubpathError::Absolute {
186                        input: self.0.clone(),
187                    });
188                }
189            }
190        }
191
192        if joined.strip_prefix(base).is_err() {
193            return Err(SourceSubpathError::Escaping {
194                input: self.0.clone(),
195            });
196        }
197
198        Ok(joined)
199    }
200}
201
202impl fmt::Display for SourceSubpath {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        f.write_str(&self.0)
205    }
206}
207
208impl AsRef<str> for SourceSubpath {
209    fn as_ref(&self) -> &str {
210        self.as_str()
211    }
212}
213
214impl std::str::FromStr for SourceSubpath {
215    type Err = SourceSubpathError;
216
217    fn from_str(s: &str) -> Result<Self, Self::Err> {
218        Self::new(s)
219    }
220}
221
222impl Serialize for SourceSubpath {
223    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
224        self.0.serialize(serializer)
225    }
226}
227
228impl<'de> Deserialize<'de> for SourceSubpath {
229    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
230        let value = String::deserialize(deserializer)?;
231        SourceSubpath::new(value).map_err(serde::de::Error::custom)
232    }
233}
234
235#[derive(Debug, thiserror::Error, PartialEq, Eq)]
236pub enum SourceSubpathError {
237    #[error("subpath cannot be empty")]
238    Empty,
239    #[error("subpath must be relative, got absolute value: {input:?}")]
240    Absolute { input: String },
241    #[error("subpath cannot escape package root: {input:?}")]
242    Escaping { input: String },
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct RenameRule {
247    pub from: ItemName,
248    pub to: ItemName,
249}
250
251/// Ordered rename rules, serialized as TOML inline table/map for compatibility.
252#[derive(Debug, Clone, Default, PartialEq, Eq)]
253pub struct RenameMap(Vec<RenameRule>);
254
255impl RenameMap {
256    pub fn new() -> Self {
257        Self(Vec::new())
258    }
259
260    pub fn insert(&mut self, from: ItemName, to: ItemName) {
261        if let Some(existing) = self.0.iter_mut().find(|r| r.from == from) {
262            existing.to = to;
263            return;
264        }
265        self.0.push(RenameRule { from, to });
266    }
267    pub fn get(&self, from: &str) -> Option<&ItemName> {
268        self.0.iter().find(|r| r.from == from).map(|r| &r.to)
269    }
270}
271
272impl Serialize for RenameMap {
273    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
274        use serde::ser::SerializeMap;
275        let mut map = serializer.serialize_map(Some(self.0.len()))?;
276        for rule in &self.0 {
277            map.serialize_entry(rule.from.as_str(), rule.to.as_str())?;
278        }
279        map.end()
280    }
281}
282
283impl<'de> Deserialize<'de> for RenameMap {
284    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
285        let map = indexmap::IndexMap::<String, String>::deserialize(deserializer)?;
286        Ok(Self(
287            map.into_iter()
288                .map(|(from, to)| RenameRule {
289                    from: ItemName::from(from),
290                    to: ItemName::from(to),
291                })
292                .collect(),
293        ))
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use std::path::PathBuf;
301
302    #[test]
303    fn source_subpath_normalizes_windows_and_unix_separators() {
304        let subpath = SourceSubpath::new(r"plugins\foo/bar\baz").unwrap();
305        assert_eq!(subpath.as_str(), "plugins/foo/bar/baz");
306    }
307
308    #[test]
309    fn source_subpath_rejects_empty() {
310        let err = SourceSubpath::new("").unwrap_err();
311        assert_eq!(err, SourceSubpathError::Empty);
312    }
313
314    #[test]
315    fn source_subpath_rejects_absolute() {
316        let err = SourceSubpath::new("/abs/path").unwrap_err();
317        assert!(matches!(err, SourceSubpathError::Absolute { .. }));
318    }
319
320    #[test]
321    fn source_subpath_rejects_root_only() {
322        let err = SourceSubpath::new("/").unwrap_err();
323        assert!(matches!(err, SourceSubpathError::Absolute { .. }));
324    }
325
326    #[test]
327    fn source_subpath_rejects_windows_absolute() {
328        let err = SourceSubpath::new(r"C:\abs\path").unwrap_err();
329        assert!(matches!(err, SourceSubpathError::Absolute { .. }));
330    }
331
332    #[test]
333    fn source_subpath_rejects_escape() {
334        let err = SourceSubpath::new("../escape").unwrap_err();
335        assert!(matches!(err, SourceSubpathError::Escaping { .. }));
336    }
337
338    #[test]
339    fn source_subpath_accepts_nested_relative_path() {
340        let subpath = SourceSubpath::new("a/b/c").unwrap();
341        assert_eq!(subpath.as_str(), "a/b/c");
342    }
343
344    #[test]
345    fn source_subpath_serializes_with_forward_slashes() {
346        #[derive(Debug, Serialize, Deserialize, PartialEq)]
347        struct SubpathWrapper {
348            subpath: SourceSubpath,
349        }
350
351        let wrapper = SubpathWrapper {
352            subpath: SourceSubpath::new(r"plugins\foo").unwrap(),
353        };
354        let toml = toml::to_string(&wrapper).unwrap();
355        assert!(toml.contains("subpath = \"plugins/foo\""));
356    }
357
358    #[test]
359    fn source_subpath_join_under_base() {
360        let base = PathBuf::from("/tmp/mars");
361        let subpath = SourceSubpath::new("plugins/foo").unwrap();
362        let joined = subpath.join_under(&base).unwrap();
363        assert_eq!(joined, base.join("plugins").join("foo"));
364    }
365
366    #[test]
367    fn source_subpath_join_under_rejects_escape_path() {
368        let escaped = SourceSubpath(String::from("../escape"));
369        let err = escaped.join_under(Path::new("/tmp/base")).unwrap_err();
370        assert!(matches!(err, SourceSubpathError::Escaping { .. }));
371    }
372
373    #[test]
374    fn source_subpath_rejects_windows_drive_forward_slash() {
375        let err = SourceSubpath::new("C:/foo").unwrap_err();
376        assert!(matches!(err, SourceSubpathError::Absolute { .. }));
377    }
378
379    #[test]
380    fn source_subpath_rejects_current_dir_dot() {
381        let err = SourceSubpath::new(".").unwrap_err();
382        assert_eq!(err, SourceSubpathError::Empty);
383    }
384
385    #[test]
386    fn source_subpath_rejects_mid_path_double_parent_escape() {
387        let err = SourceSubpath::new("a/../../escape").unwrap_err();
388        assert!(matches!(err, SourceSubpathError::Escaping { .. }));
389    }
390
391    #[test]
392    fn source_subpath_rejects_harmless_parent_in_middle() {
393        let err = SourceSubpath::new("a/b/../c").unwrap_err();
394        assert!(matches!(err, SourceSubpathError::Escaping { .. }));
395    }
396
397    #[test]
398    fn source_subpath_normalizes_trailing_slash() {
399        let subpath = SourceSubpath::new("plugins/foo/").unwrap();
400        assert_eq!(subpath.as_str(), "plugins/foo");
401    }
402
403    #[test]
404    fn source_subpath_normalizes_leading_dot_slash() {
405        let subpath = SourceSubpath::new("./plugins/foo").unwrap();
406        assert_eq!(subpath.as_str(), "plugins/foo");
407    }
408
409    #[test]
410    fn rename_map_toml_roundtrip_compat() {
411        #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
412        struct RenameWrapper {
413            rename: RenameMap,
414        }
415
416        let input = r#"rename = { "coder" = "cool-coder" }"#;
417        let parsed: RenameWrapper = toml::from_str(input).unwrap();
418        assert_eq!(
419            parsed.rename.get("coder").map(|v| v.as_str()),
420            Some("cool-coder")
421        );
422
423        let serialized = toml::to_string(&parsed).unwrap();
424        let reparsed: RenameWrapper = toml::from_str(&serialized).unwrap();
425        assert_eq!(parsed, reparsed);
426    }
427}