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