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}