Skip to main content

kcode_server_object_envelopes/
file.rs

1use 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/// A decoded Kweb application file payload.
15#[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    /// False only for historical raw object payloads without an envelope.
23    pub enveloped: bool,
24}
25
26/// Encodes one file payload without storing it.
27pub 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
78/// Decodes one file payload without reading its Kweb object.
79pub 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
169/// Returns a bounded safe basename, sanitizing `fallback` by the same policy.
170pub 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}