Skip to main content

systemprompt_models/
mime.rs

1//! Canonical extension ↔ MIME mapping for the platform.
2//!
3//! This module is the single source of truth. Every surface that needs to name
4//! the type of a file — the HTTP static-asset and plugin-file handlers, the
5//! file-ingestion job, the CLI uploader, and the bridge GUI's embedded asset
6//! server — resolves through here rather than carrying its own table.
7//!
8//! # Essence versus served form
9//!
10//! Two forms of the same type are needed and they are not interchangeable.
11//!
12//! [`from_path`] and [`from_extension`] return the *essence* — `type/subtype`
13//! with no parameters. This is the form to store in a database column, hand to
14//! an upload validator, or compare against an allowlist; a `charset` parameter
15//! on either side of such a comparison makes it fail.
16//!
17//! [`http_content_type`] returns the *served* form, which appends
18//! `charset=utf-8` to the `text/*` types. This is the form for a
19//! `Content-Type` response header and nothing else. The parameter is confined
20//! to `text/*` deliberately: RFC 8259 defines no `charset` for
21//! `application/json`, and YAML and TOML are likewise UTF-8 by definition.
22//!
23//! [`extension_for`] inverts the mapping, tolerating parameters and known
24//! aliases on its input.
25//!
26//! Copyright (c) systemprompt.io — Business Source License 1.1.
27//! See <https://systemprompt.io> for licensing details.
28
29use std::path::Path;
30
31const OCTET_STREAM: &str = "application/octet-stream";
32
33const TABLE: &[(&[&str], &str, &str)] = &[
34    (&["png"], "image/png", "image/png"),
35    (&["jpg", "jpeg"], "image/jpeg", "image/jpeg"),
36    (&["webp"], "image/webp", "image/webp"),
37    (&["gif"], "image/gif", "image/gif"),
38    (&["avif"], "image/avif", "image/avif"),
39    (&["bmp"], "image/bmp", "image/bmp"),
40    (&["tiff", "tif"], "image/tiff", "image/tiff"),
41    (&["ico"], "image/x-icon", "image/x-icon"),
42    (&["svg"], "image/svg+xml", "image/svg+xml"),
43    (&["mp4"], "video/mp4", "video/mp4"),
44    (&["webm"], "video/webm", "video/webm"),
45    (&["mov"], "video/quicktime", "video/quicktime"),
46    (&["avi"], "video/x-msvideo", "video/x-msvideo"),
47    (&["mkv"], "video/x-matroska", "video/x-matroska"),
48    (&["ogv"], "video/ogg", "video/ogg"),
49    (&["mp3"], "audio/mpeg", "audio/mpeg"),
50    (&["weba"], "audio/webm", "audio/webm"),
51    (&["wav"], "audio/wav", "audio/wav"),
52    (&["ogg"], "audio/ogg", "audio/ogg"),
53    (&["aac"], "audio/aac", "audio/aac"),
54    (&["flac"], "audio/flac", "audio/flac"),
55    (&["m4a"], "audio/mp4", "audio/mp4"),
56    (&["woff"], "font/woff", "font/woff"),
57    (&["woff2"], "font/woff2", "font/woff2"),
58    (&["ttf"], "font/ttf", "font/ttf"),
59    (&["otf"], "font/otf", "font/otf"),
60    (&["html", "htm"], "text/html", "text/html; charset=utf-8"),
61    (&["css"], "text/css", "text/css; charset=utf-8"),
62    (
63        &["js", "mjs"],
64        "text/javascript",
65        "text/javascript; charset=utf-8",
66    ),
67    (&["json", "map"], "application/json", "application/json"),
68    (&["txt"], "text/plain", "text/plain; charset=utf-8"),
69    (&["csv"], "text/csv", "text/csv; charset=utf-8"),
70    (&["md"], "text/markdown", "text/markdown; charset=utf-8"),
71    (&["ftl"], "text/plain", "text/plain; charset=utf-8"),
72    (&["xml"], "application/xml", "application/xml"),
73    (&["yaml", "yml"], "application/yaml", "application/yaml"),
74    (&["toml"], "application/toml", "application/toml"),
75    (&["sh"], "application/x-sh", "application/x-sh"),
76    (&["wasm"], "application/wasm", "application/wasm"),
77    (&["pdf"], "application/pdf", "application/pdf"),
78    (&["rtf"], "application/rtf", "application/rtf"),
79    (&["doc"], "application/msword", "application/msword"),
80    (
81        &["docx"],
82        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
83        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
84    ),
85    (
86        &["xls"],
87        "application/vnd.ms-excel",
88        "application/vnd.ms-excel",
89    ),
90    (
91        &["xlsx"],
92        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
93        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
94    ),
95    (
96        &["ppt"],
97        "application/vnd.ms-powerpoint",
98        "application/vnd.ms-powerpoint",
99    ),
100    (
101        &["pptx"],
102        "application/vnd.openxmlformats-officedocument.presentationml.presentation",
103        "application/vnd.openxmlformats-officedocument.presentationml.presentation",
104    ),
105    // Why: without these rows the static handler labels an archive
106    // `text/plain; charset=utf-8` and a browser renders mojibake instead of saving.
107    (&["gz", "tgz"], "application/gzip", "application/gzip"),
108    (&["tar"], "application/x-tar", "application/x-tar"),
109    (&["zip"], "application/zip", "application/zip"),
110    (&["bz2"], "application/x-bzip2", "application/x-bzip2"),
111    (&["xz"], "application/x-xz", "application/x-xz"),
112    (&["zst"], "application/zstd", "application/zstd"),
113    (&["exe", "msi"], OCTET_STREAM, OCTET_STREAM),
114    (
115        &["dmg"],
116        "application/x-apple-diskimage",
117        "application/x-apple-diskimage",
118    ),
119    (
120        &["deb"],
121        "application/vnd.debian.binary-package",
122        "application/vnd.debian.binary-package",
123    ),
124    (&["rpm"], "application/x-rpm", "application/x-rpm"),
125    (&["appimage"], OCTET_STREAM, OCTET_STREAM),
126    // Why: checksum and signature sidecars are meant to be read in the browser,
127    // not downloaded, so they stay text/plain rather than octet-stream.
128    (
129        &["sha256", "sha512", "asc"],
130        "text/plain",
131        "text/plain; charset=utf-8",
132    ),
133    (&["sig"], OCTET_STREAM, OCTET_STREAM),
134];
135
136const ALIASES: &[(&str, &str)] = &[
137    ("image/vnd.microsoft.icon", "image/x-icon"),
138    ("text/xml", "application/xml"),
139    ("application/javascript", "text/javascript"),
140    ("text/yaml", "application/yaml"),
141    ("audio/mp3", "audio/mpeg"),
142    ("audio/wave", "audio/wav"),
143    ("audio/x-wav", "audio/wav"),
144    ("audio/x-m4a", "audio/mp4"),
145];
146
147fn lookup(ext: &str) -> Option<&'static (&'static [&'static str], &'static str, &'static str)> {
148    let lower = ext.to_ascii_lowercase();
149    TABLE
150        .iter()
151        .find(|(exts, _, _)| exts.iter().any(|e| *e == lower))
152}
153
154fn extension_of(path: &Path) -> Option<&str> {
155    path.extension().and_then(std::ffi::OsStr::to_str)
156}
157
158pub fn from_extension(ext: &str) -> Option<&'static str> {
159    lookup(ext).map(|(_, essence, _)| *essence)
160}
161
162pub fn from_path(path: &Path) -> &'static str {
163    extension_of(path)
164        .and_then(from_extension)
165        .unwrap_or(OCTET_STREAM)
166}
167
168pub fn http_content_type(path: &Path) -> &'static str {
169    http_content_type_opt(path).unwrap_or(OCTET_STREAM)
170}
171
172pub fn http_content_type_opt(path: &Path) -> Option<&'static str> {
173    extension_of(path)
174        .and_then(lookup)
175        .map(|(_, _, served)| *served)
176}
177
178pub fn extension_for(mime: &str) -> Option<&'static str> {
179    let essence = essence_of(mime);
180    let resolved = ALIASES
181        .iter()
182        .find(|(alias, _)| *alias == essence)
183        .map_or(essence.as_str(), |(_, canonical)| *canonical);
184
185    // Why: several concrete rows (exe/msi, appimage, sig) share octet-stream as
186    // their essence; taking the first would name an unknown upload `.exe`.
187    if resolved == OCTET_STREAM {
188        return Some("bin");
189    }
190
191    TABLE
192        .iter()
193        .find(|(_, e, _)| *e == resolved)
194        .and_then(|(exts, _, _)| exts.first().copied())
195}
196
197pub fn essence_of(mime: &str) -> String {
198    mime.split(';')
199        .next()
200        .unwrap_or(mime)
201        .trim()
202        .to_ascii_lowercase()
203}