Skip to main content

mant_protocol/
content_selector.rs

1//! Explicit local-content selectors, never semantic lookup or link activation.
2
3use std::{fmt, str::FromStr};
4
5use mant_ir::{NodeId, OutlinePath, is_normalized_node_id};
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9use crate::NodePath;
10
11/// One exact local owner in the document actually loaded for this operation.
12///
13/// Paths are snapshot-relative: after edits a still-valid path can denote a
14/// different owner. Neither variant provides cross-call stale detection.
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, JsonSchema)]
16#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
17pub enum ContentSelector {
18    /// Exact canonical structural path, including `root` and tldr path `0`.
19    Path {
20        /// A path returned by the selected document's outline.
21        #[schemars(length(min = 1, max = 512))]
22        path: NodePath,
23    },
24    /// Exact canonical content ID, not an authored fragment or semantic name.
25    Id {
26        /// An ID returned by the selected document's outline.
27        #[schemars(length(min = 1, max = 512))]
28        id: NodeId,
29    },
30}
31
32impl<'de> Deserialize<'de> for ContentSelector {
33    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
34        #[derive(Deserialize)]
35        #[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
36        enum Wire {
37            Path { path: NodePath },
38            Id { id: NodeId },
39        }
40        let selector = match Wire::deserialize(deserializer)? {
41            Wire::Path { path } => Self::Path { path },
42            Wire::Id { id } => Self::Id { id },
43        };
44        selector.validate().map_err(serde::de::Error::custom)?;
45        Ok(selector)
46    }
47}
48
49/// A malformed, noncanonical or oversized explicit content selector.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct InvalidContentSelector;
52
53impl fmt::Display for InvalidContentSelector {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        f.write_str(
56            "use path:<canonical outline path> or id:<canonical content ID> (maximum 512 bytes)",
57        )
58    }
59}
60
61impl std::error::Error for InvalidContentSelector {}
62
63impl ContentSelector {
64    /// Select a trusted projected structural path. Public callers must validate
65    /// user input before execution even when constructing the enum directly.
66    #[must_use]
67    pub fn path(value: impl Into<NodePath>) -> Self {
68        Self::Path { path: value.into() }
69    }
70
71    /// Select an exact content identity without resolving it.
72    #[must_use]
73    pub fn id(value: impl Into<NodeId>) -> Self {
74        Self::Id { id: value.into() }
75    }
76
77    /// The selected value without a CLI prefix; its variant remains authoritative.
78    #[must_use]
79    pub fn value(&self) -> &str {
80        match self {
81            Self::Path { path } => path.as_str(),
82            Self::Id { id } => id.as_str(),
83        }
84    }
85
86    /// Validate before resolution; there is no trimming, normalization or fallback.
87    ///
88    /// # Errors
89    /// Returns an error for invalid syntax, control characters or oversized input.
90    pub fn validate(&self) -> Result<(), InvalidContentSelector> {
91        let value = self.value();
92        if value.is_empty() || value.len() > 512 {
93            return Err(InvalidContentSelector);
94        }
95        let valid = match self {
96            Self::Path { .. } => value
97                .parse::<OutlinePath>()
98                .is_ok_and(|path| path.to_string() == value),
99            Self::Id { .. } => is_normalized_node_id(value),
100        };
101        valid.then_some(()).ok_or(InvalidContentSelector)
102    }
103}
104
105impl FromStr for ContentSelector {
106    type Err = InvalidContentSelector;
107
108    fn from_str(value: &str) -> Result<Self, Self::Err> {
109        // Reject before cloning or parsing attacker-controlled path components.
110        if value.len() > 517 {
111            return Err(InvalidContentSelector);
112        }
113        let selected = if let Some(id) = value.strip_prefix("id:") {
114            Self::id(id)
115        } else {
116            Self::path(value.strip_prefix("path:").unwrap_or(value))
117        };
118        selected.validate()?;
119        Ok(selected)
120    }
121}
122
123impl fmt::Display for ContentSelector {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        match self {
126            Self::Path { path } => write!(f, "path:{path}"),
127            Self::Id { id } => write!(f, "id:{id}"),
128        }
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn strict_namespaces_and_closed_wire_have_no_name_or_link_fallback() {
138        for value in ["root", "0", "1.2/e3", "path:1.2/e3", "id:option-x"] {
139            assert!(value.parse::<ContentSelector>().is_ok(), "{value}");
140        }
141        for value in [
142            "option-x",
143            "--help",
144            "#topic",
145            "https://example.com",
146            " 1 ",
147            "01",
148            "path:topic",
149            "id:Mixed.Target",
150        ] {
151            assert!(value.parse::<ContentSelector>().is_err(), "{value}");
152        }
153        assert_ne!(ContentSelector::path("root"), ContentSelector::id("root"));
154        for value in [
155            r#""1""#,
156            r#"{"kind":"path","path":"1","future":true}"#,
157            r#"{"kind":"name","name":"--help"}"#,
158        ] {
159            assert!(serde_json::from_str::<ContentSelector>(value).is_err());
160        }
161        for value in [
162            serde_json::json!({"kind":"path","path":""}),
163            serde_json::json!({"kind":"path","path":"01"}),
164            serde_json::json!({"kind":"id","id":"Mixed.Target"}),
165            serde_json::json!({"kind":"id","id":"x\n"}),
166            serde_json::json!({"kind":"id","id":"x".repeat(513)}),
167        ] {
168            assert!(serde_json::from_value::<ContentSelector>(value).is_err());
169        }
170        let selected = ContentSelector::id("option-x");
171        assert_eq!(
172            serde_json::from_str::<ContentSelector>(&serde_json::to_string(&selected).unwrap())
173                .unwrap(),
174            selected
175        );
176        assert!(ContentSelector::path("1.".repeat(1000)).validate().is_err());
177    }
178}