kcode_server_object_envelopes/
file.rs1use kcode_kweb_db::ObjectId;
2
3use crate::codec::{Reader, copy_string};
4use crate::{Error, Result};
5
6const FILE_NAMESPACE: &[u8; 5] = b"KFILE";
7const FILE_MAGIC: &[u8; 8] = b"KFILE001";
8const FILE_HEADER_LENGTH: usize = FILE_MAGIC.len() + 4 + 4 + 4 + 8;
9const MAX_FILE_NAME_BYTES: usize = 255;
10const MAX_MEDIA_TYPE_BYTES: usize = 255;
11const MAX_TRANSPORT_KIND_BYTES: usize = 64;
12const DEFAULT_FILE_NAME: &str = "object.bin";
13
14#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct StoredFile {
17 pub object_id: ObjectId,
18 pub file_name: String,
19 pub media_type: String,
20 pub transport_kind: Option<String>,
21 pub bytes: Vec<u8>,
22 pub enveloped: bool,
24}
25
26pub fn encode_file(
28 logical_id: &str,
29 file_name: Option<&str>,
30 media_type: &str,
31 transport_kind: Option<&str>,
32 bytes: Vec<u8>,
33) -> Result<Vec<u8>> {
34 let fallback = generated_fallback(logical_id)?;
35 let file_name = sanitize_file_name(file_name.unwrap_or_default(), &fallback);
36 let media_type = sanitize_media_type(media_type);
37 let transport_kind = transport_kind
38 .map(sanitize_transport_kind)
39 .filter(|value| !value.is_empty());
40
41 let file_name_length =
42 u32::try_from(file_name.len()).map_err(|_| Error::new("object filename is too large"))?;
43 let media_type_length = u32::try_from(media_type.len())
44 .map_err(|_| Error::new("object media type is too large"))?;
45 let transport_kind_length =
46 u32::try_from(transport_kind.as_deref().map(str::len).unwrap_or_default())
47 .map_err(|_| Error::new("object transport kind is too large"))?;
48 let content_length =
49 u64::try_from(bytes.len()).map_err(|_| Error::new("object content is too large"))?;
50
51 let encoded_length = FILE_HEADER_LENGTH
52 .checked_add(file_name.len())
53 .and_then(|length| length.checked_add(media_type.len()))
54 .and_then(|length| {
55 length.checked_add(transport_kind.as_deref().map(str::len).unwrap_or_default())
56 })
57 .and_then(|length| length.checked_add(bytes.len()))
58 .ok_or_else(|| Error::new("file object encoded length overflow"))?;
59 let mut encoded = Vec::new();
60 encoded
61 .try_reserve_exact(encoded_length)
62 .map_err(|_| Error::new("unable to allocate encoded file object"))?;
63
64 encoded.extend_from_slice(FILE_MAGIC);
65 encoded.extend_from_slice(&file_name_length.to_be_bytes());
66 encoded.extend_from_slice(&media_type_length.to_be_bytes());
67 encoded.extend_from_slice(&transport_kind_length.to_be_bytes());
68 encoded.extend_from_slice(&content_length.to_be_bytes());
69 encoded.extend_from_slice(file_name.as_bytes());
70 encoded.extend_from_slice(media_type.as_bytes());
71 if let Some(transport_kind) = transport_kind {
72 encoded.extend_from_slice(transport_kind.as_bytes());
73 }
74 encoded.extend_from_slice(&bytes);
75 Ok(encoded)
76}
77
78pub fn decode_file(object_id: ObjectId, mut bytes: Vec<u8>) -> Result<StoredFile> {
80 if !bytes.starts_with(FILE_MAGIC) {
81 if bytes.starts_with(FILE_NAMESPACE) {
82 return Err(Error::new("file object has an unknown or truncated format"));
83 }
84 let (media_type, extension) = sniff_media_type(&bytes);
85 return Ok(StoredFile {
86 object_id,
87 file_name: format!("{object_id}.{extension}"),
88 media_type: media_type.into(),
89 transport_kind: None,
90 bytes,
91 enveloped: false,
92 });
93 }
94
95 let (file_name, media_type, transport_kind, content_start, content_length) = {
96 let mut input = Reader::new(&bytes);
97 let magic = input.take(FILE_MAGIC.len(), "file object marker")?;
98 if magic != FILE_MAGIC {
99 return Err(Error::new("file object has an unknown format"));
100 }
101 let file_name_length = usize::try_from(input.u32("file object filename length")?)
102 .map_err(|_| Error::new("file object filename length exceeds usize"))?;
103 let media_type_length = usize::try_from(input.u32("file object media type length")?)
104 .map_err(|_| Error::new("file object media type length exceeds usize"))?;
105 let transport_kind_length =
106 usize::try_from(input.u32("file object transport kind length")?)
107 .map_err(|_| Error::new("file object transport kind length exceeds usize"))?;
108 let content_length = usize::try_from(input.u64("file object content length")?)
109 .map_err(|_| Error::new("file object content length exceeds usize"))?;
110 validate_metadata_length(
111 file_name_length,
112 1,
113 MAX_FILE_NAME_BYTES,
114 "file object filename",
115 )?;
116 validate_metadata_length(
117 media_type_length,
118 1,
119 MAX_MEDIA_TYPE_BYTES,
120 "file object media type",
121 )?;
122 validate_metadata_length(
123 transport_kind_length,
124 0,
125 MAX_TRANSPORT_KIND_BYTES,
126 "file object transport kind",
127 )?;
128 let file_name = read_utf8(&mut input, file_name_length, "file object filename")?;
129 let media_type = read_utf8(&mut input, media_type_length, "file object media type")?;
130 let transport_kind = read_utf8(
131 &mut input,
132 transport_kind_length,
133 "file object transport kind",
134 )?;
135 let content_start = input.position();
136 input.take(content_length, "file object content")?;
137 input.finish("file object")?;
138 (
139 file_name,
140 media_type,
141 transport_kind,
142 content_start,
143 content_length,
144 )
145 };
146
147 if !is_canonical_file_name(&file_name) {
148 return Err(Error::new("file object filename is unsafe"));
149 }
150 if !is_canonical_media_type(&media_type) {
151 return Err(Error::new("file object media type is unsafe"));
152 }
153 if !transport_kind.is_empty() && !is_canonical_transport_kind(&transport_kind) {
154 return Err(Error::new("file object transport kind is unsafe"));
155 }
156
157 bytes.copy_within(content_start.., 0);
158 bytes.truncate(content_length);
159 Ok(StoredFile {
160 object_id,
161 file_name,
162 media_type,
163 transport_kind: (!transport_kind.is_empty()).then_some(transport_kind),
164 bytes,
165 enveloped: true,
166 })
167}
168
169pub fn sanitize_file_name(value: &str, fallback: &str) -> String {
171 let output = sanitize_basename(value);
172 if !output.trim().is_empty() {
173 return output;
174 }
175
176 let fallback = sanitize_basename(fallback);
177 if fallback.trim().is_empty() {
178 DEFAULT_FILE_NAME.into()
179 } else {
180 fallback
181 }
182}
183
184fn generated_fallback(logical_id: &str) -> Result<String> {
185 let logical_id = logical_id.trim_start_matches("pending:");
186 let mut output = String::new();
187 output
188 .try_reserve_exact(MAX_FILE_NAME_BYTES)
189 .map_err(|_| Error::new("unable to allocate object filename fallback"))?;
190 output.push_str("object-");
191
192 for character in logical_id.chars().take(MAX_FILE_NAME_BYTES) {
193 if character.is_control() {
194 continue;
195 }
196 let character = if matches!(character, '/' | '\\' | '"') {
197 '_'
198 } else {
199 character
200 };
201 let encoded_length = output
202 .len()
203 .checked_add(character.len_utf8())
204 .and_then(|length| length.checked_add(".bin".len()))
205 .ok_or_else(|| Error::new("object filename fallback length overflow"))?;
206 if encoded_length > MAX_FILE_NAME_BYTES {
207 break;
208 }
209 output.push(character);
210 }
211 output.push_str(".bin");
212 Ok(output)
213}
214
215fn sanitize_basename(value: &str) -> String {
216 let basename = value.rsplit(['/', '\\']).next().unwrap_or_default();
217 let mut output = String::with_capacity(basename.len().min(MAX_FILE_NAME_BYTES));
218 for character in basename.chars() {
219 if character.is_control() {
220 continue;
221 }
222 let character = if matches!(character, '/' | '\\' | '"') {
223 '_'
224 } else {
225 character
226 };
227 let Some(encoded_length) = output.len().checked_add(character.len_utf8()) else {
228 break;
229 };
230 if encoded_length > MAX_FILE_NAME_BYTES {
231 break;
232 }
233 output.push(character);
234 }
235 if is_dot_segment(&output) {
236 output.clear();
237 }
238 output
239}
240
241fn is_canonical_file_name(value: &str) -> bool {
242 !value.is_empty()
243 && value.len() <= MAX_FILE_NAME_BYTES
244 && !value.trim().is_empty()
245 && !is_dot_segment(value)
246 && !value
247 .chars()
248 .any(|character| character.is_control() || matches!(character, '/' | '\\' | '"'))
249}
250
251fn is_dot_segment(value: &str) -> bool {
252 matches!(value, "." | "..")
253}
254
255fn read_utf8(input: &mut Reader<'_>, length: usize, label: &str) -> Result<String> {
256 let value = std::str::from_utf8(input.take(length, label)?)
257 .map_err(|_| Error::new(format!("{label} is not UTF-8")))?;
258 copy_string(value, label)
259}
260
261fn validate_metadata_length(
262 length: usize,
263 minimum: usize,
264 maximum: usize,
265 label: &str,
266) -> Result<()> {
267 if !(minimum..=maximum).contains(&length) {
268 return Err(Error::new(format!(
269 "{label} length must be between {minimum} and {maximum} bytes"
270 )));
271 }
272 Ok(())
273}
274
275fn sanitize_media_type(value: &str) -> String {
276 let value = value.trim();
277 if is_canonical_media_type(value) {
278 value.into()
279 } else {
280 "application/octet-stream".into()
281 }
282}
283
284fn is_canonical_media_type(value: &str) -> bool {
285 !value.is_empty()
286 && value.len() <= MAX_MEDIA_TYPE_BYTES
287 && !value
288 .chars()
289 .any(|character| character.is_control() || character.is_whitespace())
290 && value.contains('/')
291}
292
293fn sanitize_transport_kind(value: &str) -> String {
294 let value = value.trim();
295 let mut output = String::with_capacity(value.len().min(MAX_TRANSPORT_KIND_BYTES));
296 for character in value.chars() {
297 if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') {
298 if output.len() == MAX_TRANSPORT_KIND_BYTES {
299 break;
300 }
301 output.push(character);
302 }
303 }
304 output
305}
306
307fn is_canonical_transport_kind(value: &str) -> bool {
308 value.len() <= MAX_TRANSPORT_KIND_BYTES
309 && value
310 .chars()
311 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
312}
313
314fn sniff_media_type(bytes: &[u8]) -> (&'static str, &'static str) {
315 if bytes.starts_with(b"%PDF-") {
316 ("application/pdf", "pdf")
317 } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
318 ("image/png", "png")
319 } else if bytes.starts_with(b"\xff\xd8\xff") {
320 ("image/jpeg", "jpg")
321 } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
322 ("image/gif", "gif")
323 } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
324 ("image/webp", "webp")
325 } else if bytes.starts_with(b"OggS") {
326 ("audio/ogg", "ogg")
327 } else if bytes.starts_with(b"ID3") || bytes.starts_with(b"\xff\xfb") {
328 ("audio/mpeg", "mp3")
329 } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WAVE" {
330 ("audio/wav", "wav")
331 } else if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" {
332 ("video/mp4", "mp4")
333 } else if bytes.starts_with(b"\x1a\x45\xdf\xa3") {
334 ("video/webm", "webm")
335 } else {
336 ("application/octet-stream", "bin")
337 }
338}