Skip to main content

printobserver_types/
file_name.rs

1//! The one piece of caller-supplied text that crosses to a printer's file API.
2//!
3//! [`FileName`] is a newtype rather than a record: it wraps one string,
4//! declares no named fields of its own, and serializes as that string. It is a
5//! type rather than a bare string because an agent can ask for a print, so this
6//! is the one string in this vocabulary that reaches the printer's own file
7//! API, and a name that is not a name is a path-traversal question rather than
8//! a typing one.
9//!
10//! # What it refuses, and what it accepts
11//!
12//! The rule is one total condition over strings: a value is refused if and only
13//! if it carries one of the six forbidden properties [`FileNameRefusal`]
14//! declares, and every other value is accepted. The accepted set is not an
15//! allowlist of characters, and the NUL byte — already on the forbidden list —
16//! is the only character this type refuses for being the character it is. Four
17//! policies follow from that and are stated rather than left to follow: a
18//! control character other than NUL is **accepted**; a leading or trailing
19//! space is **accepted**; a `.` at the start of a name that is not exactly `.`
20//! or `..` is **accepted**, the parent-directory property being a whole segment
21//! rather than a prefix; a colon that is not a single letter's drive prefix is
22//! **accepted**; and a character outside ASCII is **accepted**, this type
23//! declaring no script, normalization or encoding policy beyond the string
24//! being one.
25//!
26//! # The separator set is a claim about the world
27//!
28//! [`SEPARATORS`] is exactly `/` and `\`, and that is a claim about the file
29//! APIs this name crosses to rather than a fact about this crate: `OctoPrint`'s
30//! file API is served from POSIX hosts, whose separator is `/`, and its own
31//! path handling additionally treats `\` as a separator on Windows hosts. A
32//! reader who believes another character reaches a separator on some host this
33//! system runs against should argue with this set, here, rather than discover
34//! its absence as a gap in a corpus.
35
36use core::fmt;
37use core::str::FromStr;
38use std::borrow::Cow;
39
40use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
41use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
42
43/// The characters this type treats as path separators.
44pub const SEPARATORS: [char; 2] = ['/', '\\'];
45
46/// The property that makes a string no file name.
47///
48/// These six are the whole of what [`FileName`] refuses; a value carrying none
49/// of them is accepted.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum FileNameRefusal {
52    /// The value is empty.
53    Empty,
54    /// The value contains a NUL byte.
55    Nul,
56    /// The value begins with a separator.
57    LeadingSeparator,
58    /// The value contains a separator.
59    Separator,
60    /// The value carries a `.` or `..` segment, or is one.
61    DotSegment,
62    /// The value carries a drive prefix: one letter followed by a colon.
63    DrivePrefix,
64}
65
66impl FileNameRefusal {
67    /// Every forbidden property this type declares, in the order it tests them.
68    ///
69    /// The whole vocabulary, so that a test ranging over the refused categories
70    /// can read them off this type rather than off a list of its own.
71    pub const ALL: [Self; 6] = [
72        Self::Empty,
73        Self::Nul,
74        Self::LeadingSeparator,
75        Self::Separator,
76        Self::DotSegment,
77        Self::DrivePrefix,
78    ];
79
80    /// One line saying what the property is.
81    #[must_use]
82    pub const fn detail(self) -> &'static str {
83        match self {
84            Self::Empty => "it is empty",
85            Self::Nul => "it contains a NUL byte",
86            Self::LeadingSeparator => "it begins with a path separator",
87            Self::Separator => "it contains a path separator",
88            Self::DotSegment => "it carries a `.` or `..` segment",
89            Self::DrivePrefix => "it carries a drive prefix",
90        }
91    }
92}
93
94impl fmt::Display for FileNameRefusal {
95    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96        formatter.write_str(self.detail())
97    }
98}
99
100/// Why a string is no file name.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct FileNameError {
103    /// The property that refused it.
104    refusal: FileNameRefusal,
105    /// The value that was refused.
106    value: String,
107}
108
109impl FileNameError {
110    /// The property that refused the value.
111    #[must_use]
112    pub const fn refusal(&self) -> FileNameRefusal {
113        self.refusal
114    }
115
116    /// The value that was refused.
117    #[must_use]
118    pub fn value(&self) -> &str {
119        &self.value
120    }
121}
122
123impl fmt::Display for FileNameError {
124    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
125        write!(
126            formatter,
127            "{:?} is no file name: {}",
128            self.value, self.refusal
129        )
130    }
131}
132
133impl core::error::Error for FileNameError {}
134
135/// Whether the value carries a drive prefix: one letter, then a colon.
136fn has_drive_prefix(value: &str) -> bool {
137    let mut characters = value.chars();
138    let Some(first) = characters.next() else {
139        return false;
140    };
141    first.is_ascii_alphabetic() && characters.next() == Some(':')
142}
143
144/// The property that refuses this value, if one does.
145fn refusal_for(value: &str) -> Option<FileNameRefusal> {
146    if value.is_empty() {
147        return Some(FileNameRefusal::Empty);
148    }
149    if value.contains('\0') {
150        return Some(FileNameRefusal::Nul);
151    }
152    if value.starts_with(SEPARATORS) {
153        return Some(FileNameRefusal::LeadingSeparator);
154    }
155    if value.contains(SEPARATORS) {
156        return Some(FileNameRefusal::Separator);
157    }
158    if value
159        .split(SEPARATORS)
160        .any(|segment| segment == "." || segment == "..")
161    {
162        return Some(FileNameRefusal::DotSegment);
163    }
164    if has_drive_prefix(value) {
165        return Some(FileNameRefusal::DrivePrefix);
166    }
167    None
168}
169
170/// A file name a printer's own file API can be asked for.
171///
172/// See the [module documentation](self) for the whole of what this type refuses
173/// and what it accepts.
174#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
175pub struct FileName(String);
176
177impl FileName {
178    /// Read a file name, refusing a value carrying a forbidden property.
179    ///
180    /// # Errors
181    ///
182    /// Returns the property that refused the value.
183    pub fn new(value: impl Into<String>) -> Result<Self, FileNameError> {
184        let value = value.into();
185        match refusal_for(&value) {
186            Some(refusal) => Err(FileNameError { refusal, value }),
187            None => Ok(Self(value)),
188        }
189    }
190
191    /// The name, carrying exactly the characters it was given.
192    #[must_use]
193    pub fn as_str(&self) -> &str {
194        &self.0
195    }
196}
197
198impl fmt::Display for FileName {
199    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
200        formatter.write_str(&self.0)
201    }
202}
203
204impl FromStr for FileName {
205    type Err = FileNameError;
206
207    fn from_str(text: &str) -> Result<Self, Self::Err> {
208        Self::new(text)
209    }
210}
211
212impl Serialize for FileName {
213    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
214        serializer.serialize_str(&self.0)
215    }
216}
217
218impl<'de> Deserialize<'de> for FileName {
219    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
220        let text = String::deserialize(deserializer)?;
221        Self::new(text).map_err(D::Error::custom)
222    }
223}
224
225impl JsonSchema for FileName {
226    fn schema_name() -> Cow<'static, str> {
227        Cow::Borrowed("FileName")
228    }
229
230    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
231        json_schema!({
232            "type": "string",
233            "title": "FileName",
234            "description": "A file name a printer's own file API can be asked for: no path \
235                            separator, no NUL byte, no `.` or `..` segment, no drive prefix, \
236                            and not empty.",
237            "minLength": 1,
238            "pattern": "^[^/\\\\\u{0}]+$",
239            "not": { "pattern": "^([.]{1,2}$|[A-Za-z]:)" }
240        })
241    }
242}