diff --git a/cli-rs/Cargo.lock b/cli-rs/Cargo.lock index ac4aeac..810b4b6 100644 --- a/cli-rs/Cargo.lock +++ b/cli-rs/Cargo.lock @@ -2696,22 +2696,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "mini-moka-wasm" version = "0.10.99" @@ -3961,6 +3945,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -4738,12 +4723,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.26" @@ -5237,7 +5216,6 @@ dependencies = [ "ignore", "jacquard-common", "jacquard-lexicon", - "mime_guess", "percent-encoding", "serde", "serde_json", @@ -5288,16 +5266,17 @@ dependencies = [ "jacquard-common", "jacquard-lexicon", "keyring", - "mime_guess", "reqwest", "rusqlite", "rustls", + "rustls-native-certs", "serde", "serde_html_form", "serde_json", "tempfile", "tokio", "webbrowser", + "webpki-root-certs", "wisp-core", "wisp-lexicons", "wisp-ui", diff --git a/cli-rs/Cargo.toml b/cli-rs/Cargo.toml index 2a95cff..4cf22f7 100644 --- a/cli-rs/Cargo.toml +++ b/cli-rs/Cargo.toml @@ -24,13 +24,12 @@ anyhow = "1" clap = { version = "4.6.7", features = ["derive", "env", "wrap_help"] } tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "net", "signal", "time", "sync", "io-util"] } serde = { version = "1", default-features = false, features = ["derive", "alloc"] } -serde_json = "1" +serde_json = { version = "1", features = ["preserve_order"] } thiserror = { version = "2", default-features = false } flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs"] } sha2 = { version = "0.11.0", default-features = false } data-encoding = "2.11" -mime_guess = "2.0.5" ignore = "0.4.33" # ring as the one rustls provider; installed at startup by wispctl::tls. @@ -57,6 +56,9 @@ percent-encoding = "2" url = "2.5.8" rusqlite = { version = "0.40.2", features = ["bundled"] } keyring = { version = "4.2.0", default-features = false, features = ["v1"] } +# Linux fallback roots for containers without a CA bundle (see wispctl::tls). +rustls-native-certs = "0.8.4" +webpki-root-certs = "1.0.9" [profile.release] diff --git a/cli-rs/crates/wisp-core/Cargo.toml b/cli-rs/crates/wisp-core/Cargo.toml index 009e419..f3b1cd2 100644 --- a/cli-rs/crates/wisp-core/Cargo.toml +++ b/cli-rs/crates/wisp-core/Cargo.toml @@ -13,7 +13,6 @@ serde_json.workspace = true flate2.workspace = true sha2.workspace = true data-encoding.workspace = true -mime_guess.workspace = true ignore.workspace = true url.workspace = true percent-encoding.workspace = true diff --git a/cli-rs/crates/wisp-core/src/blob.rs b/cli-rs/crates/wisp-core/src/blob.rs index cdbd2bc..5aaec6d 100644 --- a/cli-rs/crates/wisp-core/src/blob.rs +++ b/cli-rs/crates/wisp-core/src/blob.rs @@ -1,4 +1,5 @@ //! Byte-compatible blob preparation for the TypeScript CLI. +use crate::mime_table; use sha2::{Digest, Sha256}; use std::io::{self, Write}; @@ -37,29 +38,27 @@ pub fn gzip_variants(bytes: &[u8]) -> impl Iterator> + '_ { }) } +/// The extension `mime-types`' `lookup` keys on: Node's +/// `extname('x.' + path)`, so a bare root name like `json` counts as an +/// extension while a leading-dot name inside a directory (`a/.env`) does not. +fn lookup_extension(path: &str) -> Option { + let prefixed = format!("x.{path}"); + let base = prefixed.rsplit(['/', '\\']).next().unwrap_or(&prefixed); + let dot = base.rfind('.')?; + let ext = &base[dot + 1..]; + (dot > 0 && !ext.is_empty() && base != "..").then(|| ext.to_ascii_lowercase()) +} + +/// `lookup(path) || 'application/octet-stream'`, with the TS CLI's table. pub fn mime_for(path: &str) -> String { - match std::path::Path::new(path) - .extension() - .and_then(|e| e.to_str()) - .map(str::to_ascii_lowercase) - .as_deref() - { - Some("js" | "mjs") => "text/javascript".into(), - Some("cjs") => "application/node".into(), - Some("map") => "application/json".into(), - Some("xml") => "application/xml".into(), - Some("woff") => "font/woff".into(), - Some("woff2") => "font/woff2".into(), - Some("ttf") => "font/ttf".into(), - Some("otf") => "font/otf".into(), - Some("ico") => "image/vnd.microsoft.icon".into(), - Some("wav") => "audio/wav".into(), - Some("aiff" | "aif" | "aifc") => "audio/x-aiff".into(), - _ => mime_guess::from_path(path) - .first_or_octet_stream() - .essence_str() - .to_owned(), - } + lookup_extension(path) + .and_then(|ext| { + mime_table::TYPES + .binary_search_by(|(known, _)| known.cmp(&ext.as_str())) + .ok() + }) + .map_or("application/octet-stream", |i| mime_table::TYPES[i].1) + .to_owned() } pub fn should_compress(mime: &str, path: &str) -> bool { @@ -185,6 +184,25 @@ mod tests { ); } } + #[test] + fn mime_table_is_sorted_for_binary_search() { + assert!(mime_table::TYPES.windows(2).all(|w| w[0].0 < w[1].0)); + } + + #[test] + fn lookup_uses_node_extname_of_x_dot_path() { + assert_eq!(mime_for("json"), "application/json"); + assert_eq!(mime_for(".html"), "text/html"); + assert_eq!(mime_for("dir/.html"), "application/octet-stream"); + assert_eq!(mime_for("dir/json"), "application/octet-stream"); + assert_eq!(mime_for("Dir/INDEX.HTML"), "text/html"); + assert_eq!(mime_for("noext"), "application/octet-stream"); + assert_eq!(mime_for("a.weirdext"), "application/octet-stream"); + assert_eq!(mime_for("trailing."), "application/octet-stream"); + assert_eq!(mime_for("config.yaml"), "text/yaml"); + assert_eq!(mime_for("clip.ts"), "video/mp2t"); + } + #[test] fn types_and_redirects() { assert_eq!(mime_for("foo.js"), "text/javascript"); diff --git a/cli-rs/crates/wisp-core/src/identity.rs b/cli-rs/crates/wisp-core/src/identity.rs new file mode 100644 index 0000000..47b551f --- /dev/null +++ b/cli-rs/crates/wisp-core/src/identity.rs @@ -0,0 +1,178 @@ +//! PDS endpoint rules from `@wispplace/atproto-utils` identity.ts, so the CLI +//! talks to the same hosts the TypeScript CLI would. +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use url::{Host, Url}; + +const MAX_URL_BYTES: usize = 8192; +const BLOCKED_HOSTS: &[&str] = &["metadata.google.internal"]; + +const PRIVATE_V4: &[([u8; 4], u32)] = &[ + ([0, 0, 0, 0], 8), + ([10, 0, 0, 0], 8), + ([100, 64, 0, 0], 10), + ([127, 0, 0, 0], 8), + ([169, 254, 0, 0], 16), + ([172, 16, 0, 0], 12), + ([192, 0, 0, 0], 24), + ([192, 0, 2, 0], 24), + ([192, 31, 196, 0], 24), + ([192, 52, 193, 0], 24), + ([192, 88, 99, 0], 24), + ([192, 168, 0, 0], 16), + ([192, 175, 48, 0], 24), + ([198, 18, 0, 0], 15), + ([198, 51, 100, 0], 24), + ([203, 0, 113, 0], 24), + ([224, 0, 0, 0], 4), + ([240, 0, 0, 0], 4), +]; + +/// Global unicast is 2000::/3; these special-purpose ranges inside it are not. +const SPECIAL_V6: &[(u128, u32)] = &[ + (0x2001 << 112, 23), + ((0x2001 << 112) | (0x0db8 << 96), 32), + // NAT64 may translate an apparently global address to a private IPv4 one. + ((0x0064 << 112) | (0xff9b << 96), 96), + ((0x0064 << 112) | (0xff9b << 96) | (0x0001 << 80), 48), + (0x2002 << 112, 16), + ((0x2620 << 112) | (0x004f << 96) | (0x8000 << 80), 48), + (0x3fff << 112, 20), +]; + +fn in_prefix(address: u128, prefix: u128, bits: u32, width: u32) -> bool { + let shift = width - bits; + (address >> shift) == (prefix >> shift) +} + +fn public_v4(ip: Ipv4Addr) -> bool { + let address = u32::from(ip) as u128; + !PRIVATE_V4 + .iter() + .any(|(base, bits)| in_prefix(address, u32::from_be_bytes(*base) as u128, *bits, 32)) +} + +fn public_v6(ip: Ipv6Addr) -> bool { + let address = u128::from(ip); + in_prefix(address, 0x2000 << 112, 3, 128) + && !SPECIAL_V6 + .iter() + .any(|(base, bits)| in_prefix(address, *base, *bits, 128)) +} + +fn loopback_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => ip.octets()[0] == 127, + IpAddr::V6(ip) => ip == Ipv6Addr::LOCALHOST, + } +} + +fn valid_name(host: &str) -> bool { + !host.is_empty() + && host.len() <= 253 + && host.split('.').all(|label| { + let bytes = label.as_bytes(); + (1..=63).contains(&bytes.len()) + && bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || *b == b'-') + && bytes[0] != b'-' + && bytes[bytes.len() - 1] != b'-' + }) +} + +/// Whether `host` is loopback: `localhost`, `*.localhost`, 127/8 or ::1. +pub fn is_loopback_host(url: &Url) -> bool { + match url.host() { + Some(Host::Domain(name)) => { + let name = name.trim_end_matches('.').to_ascii_lowercase(); + name == "localhost" || name.ends_with(".localhost") + } + Some(Host::Ipv4(ip)) => loopback_ip(IpAddr::V4(ip)), + Some(Host::Ipv6(ip)) => loopback_ip(IpAddr::V6(ip)), + None => false, + } +} + +fn valid_host(url: &Url, allow_loopback: bool) -> bool { + if is_loopback_host(url) { + return allow_loopback; + } + match url.host() { + Some(Host::Domain(name)) => { + let name = name.trim_end_matches('.').to_ascii_lowercase(); + !BLOCKED_HOSTS.contains(&name.as_str()) && valid_name(&name) + } + Some(Host::Ipv4(ip)) => public_v4(ip), + Some(Host::Ipv6(ip)) => public_v6(ip), + None => false, + } +} + +/// `validatePdsEndpoint`: an https URL on a public host (http only for an +/// allowed loopback dev PDS), without credentials, query or fragment. Returns +/// the endpoint with any trailing slash removed. +pub fn validate_pds_endpoint(endpoint: &str, allow_loopback: bool) -> Option { + if endpoint.len() > MAX_URL_BYTES { + return None; + } + let url = Url::parse(endpoint).ok()?; + let secure = match url.scheme() { + "https" => true, + "http" => allow_loopback && is_loopback_host(&url), + _ => false, + }; + let clean = url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none(); + (secure && clean && valid_host(&url, allow_loopback)) + .then(|| url.as_str().trim_end_matches('/').to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_public_https_endpoints() { + assert_eq!( + validate_pds_endpoint("https://pds.example.com/", false).as_deref(), + Some("https://pds.example.com") + ); + assert!(validate_pds_endpoint("https://1.1.1.1", false).is_some()); + assert!(validate_pds_endpoint("https://[2606:4700::1111]", false).is_some()); + } + + #[test] + fn rejects_what_the_ts_cli_rejects() { + for endpoint in [ + "http://pds.example.com", + "https://user:pw@pds.example.com", + "https://pds.example.com/?x=1", + "https://pds.example.com/#frag", + "https://10.0.0.1", + "https://192.168.1.10", + "https://169.254.169.254", + "https://[fd00::1]", + "https://[2001:db8::1]", + "https://[64:ff9b::a00:1]", + "https://metadata.google.internal", + "https://localhost:3300", + "https://under_score.example", + "ftp://pds.example.com", + ] { + assert_eq!(validate_pds_endpoint(endpoint, false), None, "{endpoint}"); + } + } + + #[test] + fn loopback_only_when_allowed() { + assert_eq!(validate_pds_endpoint("http://localhost:3300", false), None); + assert_eq!( + validate_pds_endpoint("http://localhost:3300", true).as_deref(), + Some("http://localhost:3300") + ); + assert!(validate_pds_endpoint("http://127.0.0.1:3300", true).is_some()); + assert_eq!(validate_pds_endpoint("http://10.0.0.1", true), None); + } +} diff --git a/cli-rs/crates/wisp-core/src/lib.rs b/cli-rs/crates/wisp-core/src/lib.rs index 289dbc6..4dfa18a 100644 --- a/cli-rs/crates/wisp-core/src/lib.rs +++ b/cli-rs/crates/wisp-core/src/lib.rs @@ -5,7 +5,9 @@ pub mod blob; pub mod constants; pub mod convert; +pub mod identity; pub mod ignore; +mod mime_table; pub mod pages; pub mod path; pub mod redirects; diff --git a/cli-rs/crates/wisp-core/src/mime_table.rs b/cli-rs/crates/wisp-core/src/mime_table.rs new file mode 100644 index 0000000..7e3fad2 --- /dev/null +++ b/cli-rs/crates/wisp-core/src/mime_table.rs @@ -0,0 +1,1312 @@ +// @generated by cli-rs/scripts/mime-table.ts from mime-types 3.0.2. Do not edit. +/// Extension (lowercase, no dot) to MIME type, sorted by extension. +pub static TYPES: &[(&str, &str)] = &[ + ("123", "application/vnd.lotus-1-2-3"), + ("1km", "application/vnd.1000minds.decision-model+xml"), + ("210", "model/step"), + ("3dml", "text/vnd.in3d.3dml"), + ("3ds", "image/x-3ds"), + ("3g2", "video/3gpp2"), + ("3gp", "video/3gpp"), + ("3gpp", "video/3gpp"), + ("3mf", "model/3mf"), + ("7z", "application/x-7z-compressed"), + ("aab", "application/x-authorware-bin"), + ("aac", "audio/aac"), + ("aam", "application/x-authorware-map"), + ("aas", "application/x-authorware-seg"), + ("abw", "application/x-abiword"), + ("ac", "application/pkix-attr-cert"), + ("acc", "application/vnd.americandynamics.acc"), + ("ace", "application/x-ace-compressed"), + ("acu", "application/vnd.acucobol"), + ("acutc", "application/vnd.acucorp"), + ("adp", "audio/adpcm"), + ("adts", "audio/aac"), + ("aep", "application/vnd.audiograph"), + ("afm", "application/x-font-type1"), + ("afp", "application/vnd.ibm.modcap"), + ("age", "application/vnd.age"), + ("ahead", "application/vnd.ahead.space"), + ("ai", "application/postscript"), + ("aif", "audio/x-aiff"), + ("aifc", "audio/x-aiff"), + ("aiff", "audio/x-aiff"), + ( + "air", + "application/vnd.adobe.air-application-installer-package+zip", + ), + ("ait", "application/vnd.dvb.ait"), + ("ami", "application/vnd.amiga.ami"), + ("aml", "application/automationml-aml+xml"), + ("amlx", "application/automationml-amlx+zip"), + ("amr", "audio/amr"), + ("apk", "application/vnd.android.package-archive"), + ("apng", "image/apng"), + ("appcache", "text/cache-manifest"), + ("appinstaller", "application/appinstaller"), + ("application", "application/x-ms-application"), + ("appx", "application/appx"), + ("appxbundle", "application/appxbundle"), + ("apr", "application/vnd.lotus-approach"), + ("arc", "application/x-freearc"), + ("arj", "application/x-arj"), + ("asc", "application/pgp-keys"), + ("asf", "video/x-ms-asf"), + ("asm", "text/x-asm"), + ("aso", "application/vnd.accpac.simply.aso"), + ("asx", "video/x-ms-asf"), + ("atc", "application/vnd.acucorp"), + ("atom", "application/atom+xml"), + ("atomcat", "application/atomcat+xml"), + ("atomdeleted", "application/atomdeleted+xml"), + ("atomsvc", "application/atomsvc+xml"), + ("atx", "application/vnd.antix.game-component"), + ("au", "audio/basic"), + ("avci", "image/avci"), + ("avcs", "image/avcs"), + ("avi", "video/x-msvideo"), + ("avif", "image/avif"), + ("aw", "application/applixware"), + ("azf", "application/vnd.airzip.filesecure.azf"), + ("azs", "application/vnd.airzip.filesecure.azs"), + ("azv", "image/vnd.airzip.accelerator.azv"), + ("azw", "application/vnd.amazon.ebook"), + ("b16", "image/vnd.pco.b16"), + ("bary", "model/vnd.bary"), + ("bat", "application/x-msdownload"), + ("bcpio", "application/x-bcpio"), + ("bdf", "application/x-font-bdf"), + ("bdm", "application/vnd.syncml.dm+wbxml"), + ("bdo", "application/vnd.nato.bindingdataobject+xml"), + ("bdoc", "application/bdoc"), + ("bed", "application/vnd.realvnc.bed"), + ("bh2", "application/vnd.fujitsu.oasysprs"), + ("bin", "application/octet-stream"), + ("blb", "application/x-blorb"), + ("blend", "application/x-blender"), + ("blorb", "application/x-blorb"), + ("bmi", "application/vnd.bmi"), + ("bmml", "application/vnd.balsamiq.bmml+xml"), + ("bmp", "image/bmp"), + ("book", "application/vnd.framemaker"), + ("box", "application/vnd.previewsystems.box"), + ("boz", "application/x-bzip2"), + ("bpk", "application/octet-stream"), + ("brush", "application/vnd.procreate.brush"), + ("brushset", "application/vnd.procrate.brushset"), + ("bsp", "model/vnd.valve.source.compiled-map"), + ("btf", "image/prs.btif"), + ("btif", "image/prs.btif"), + ("buffer", "application/octet-stream"), + ("bz", "application/x-bzip"), + ("bz2", "application/x-bzip2"), + ("c", "text/x-c"), + ("c11amc", "application/vnd.cluetrust.cartomobile-config"), + ("c11amz", "application/vnd.cluetrust.cartomobile-config-pkg"), + ("c4d", "application/vnd.clonk.c4group"), + ("c4f", "application/vnd.clonk.c4group"), + ("c4g", "application/vnd.clonk.c4group"), + ("c4p", "application/vnd.clonk.c4group"), + ("c4u", "application/vnd.clonk.c4group"), + ("cab", "application/vnd.ms-cab-compressed"), + ("caf", "audio/x-caf"), + ("cap", "application/vnd.tcpdump.pcap"), + ("car", "application/vnd.curl.car"), + ("cat", "application/vnd.ms-pki.seccat"), + ("cb7", "application/x-cbr"), + ("cba", "application/x-cbr"), + ("cbr", "application/x-cbr"), + ("cbt", "application/x-cbr"), + ("cbz", "application/x-cbr"), + ("cc", "text/x-c"), + ("cco", "application/x-cocoa"), + ("cct", "application/x-director"), + ("ccxml", "application/ccxml+xml"), + ("cdbcmsg", "application/vnd.contact.cmsg"), + ("cdf", "application/x-netcdf"), + ("cdfx", "application/cdfx+xml"), + ("cdkey", "application/vnd.mediastation.cdkey"), + ("cdmia", "application/cdmi-capability"), + ("cdmic", "application/cdmi-container"), + ("cdmid", "application/cdmi-domain"), + ("cdmio", "application/cdmi-object"), + ("cdmiq", "application/cdmi-queue"), + ("cdx", "chemical/x-cdx"), + ("cdxml", "application/vnd.chemdraw+xml"), + ("cdy", "application/vnd.cinderella"), + ("cer", "application/pkix-cert"), + ("cfs", "application/x-cfs-compressed"), + ("cgm", "image/cgm"), + ("chat", "application/x-chat"), + ("chm", "application/vnd.ms-htmlhelp"), + ("chrt", "application/vnd.kde.kchart"), + ("cif", "chemical/x-cif"), + ( + "cii", + "application/vnd.anser-web-certificate-issue-initiation", + ), + ("cil", "application/vnd.ms-artgalry"), + ("cjs", "application/node"), + ("cla", "application/vnd.claymore"), + ("class", "application/java-vm"), + ("cld", "model/vnd.cld"), + ("clkk", "application/vnd.crick.clicker.keyboard"), + ("clkp", "application/vnd.crick.clicker.palette"), + ("clkt", "application/vnd.crick.clicker.template"), + ("clkw", "application/vnd.crick.clicker.wordbank"), + ("clkx", "application/vnd.crick.clicker"), + ("clp", "application/x-msclip"), + ("cmc", "application/vnd.cosmocaller"), + ("cmdf", "chemical/x-cmdf"), + ("cml", "chemical/x-cml"), + ("cmp", "application/vnd.yellowriver-custom-menu"), + ("cmx", "image/x-cmx"), + ("cod", "application/vnd.rim.cod"), + ("coffee", "text/coffeescript"), + ("com", "application/x-msdownload"), + ("conf", "text/plain"), + ("cpio", "application/x-cpio"), + ("cpl", "application/cpl+xml"), + ("cpp", "text/x-c"), + ("cpt", "application/mac-compactpro"), + ("crd", "application/x-mscardfile"), + ("crl", "application/pkix-crl"), + ("crt", "application/x-x509-ca-cert"), + ("crx", "application/x-chrome-extension"), + ("cryptonote", "application/vnd.rig.cryptonote"), + ("csh", "application/x-csh"), + ("csl", "application/vnd.citationstyles.style+xml"), + ("csml", "chemical/x-csml"), + ("csp", "application/vnd.commonspace"), + ("css", "text/css"), + ("cst", "application/x-director"), + ("csv", "text/csv"), + ("cu", "application/cu-seeme"), + ("curl", "text/vnd.curl"), + ("cwl", "application/cwl"), + ("cww", "application/prs.cww"), + ("cxt", "application/x-director"), + ("cxx", "text/x-c"), + ("dae", "model/vnd.collada+xml"), + ("daf", "application/vnd.mobius.daf"), + ("dart", "application/vnd.dart"), + ("dataless", "application/vnd.fdsn.seed"), + ("davmount", "application/davmount+xml"), + ("dbf", "application/vnd.dbf"), + ("dbk", "application/docbook+xml"), + ("dcm", "application/dicom"), + ("dcmp", "application/vnd.dcmp+xml"), + ("dcr", "application/x-director"), + ("dcurl", "text/vnd.curl.dcurl"), + ("dd2", "application/vnd.oma.dd2+xml"), + ("ddd", "application/vnd.fujixerox.ddd"), + ("ddf", "application/vnd.syncml.dmddf+xml"), + ("dds", "image/vnd.ms-dds"), + ("deb", "application/x-debian-package"), + ("def", "text/plain"), + ("deploy", "application/octet-stream"), + ("der", "application/x-x509-ca-cert"), + ("dfac", "application/vnd.dreamfactory"), + ("dgc", "application/x-dgc-compressed"), + ("dib", "image/bmp"), + ("dic", "text/x-c"), + ("dir", "application/x-director"), + ("dis", "application/vnd.mobius.dis"), + ( + "disposition-notification", + "message/disposition-notification", + ), + ("dist", "application/octet-stream"), + ("distz", "application/octet-stream"), + ("djv", "image/vnd.djvu"), + ("djvu", "image/vnd.djvu"), + ("dll", "application/x-msdownload"), + ("dmg", "application/x-apple-diskimage"), + ("dmp", "application/vnd.tcpdump.pcap"), + ("dms", "application/octet-stream"), + ("dna", "application/vnd.dna"), + ("dng", "image/x-adobe-dng"), + ("doc", "application/msword"), + ("docm", "application/vnd.ms-word.document.macroenabled.12"), + ( + "docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ), + ("dot", "application/msword"), + ("dotm", "application/vnd.ms-word.template.macroenabled.12"), + ( + "dotx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + ), + ("dp", "application/vnd.osgi.dp"), + ("dpg", "application/vnd.dpgraph"), + ("dpx", "image/dpx"), + ("dra", "audio/vnd.dra"), + ("drle", "image/dicom-rle"), + ("drm", "application/vnd.procreate.dream"), + ("dsc", "text/prs.lines.tag"), + ("dssc", "application/dssc+der"), + ("dtb", "application/x-dtbook+xml"), + ("dtd", "application/xml-dtd"), + ("dts", "audio/vnd.dts"), + ("dtshd", "audio/vnd.dts.hd"), + ("dump", "application/octet-stream"), + ("dvb", "video/vnd.dvb.file"), + ("dvi", "application/x-dvi"), + ("dwd", "application/atsc-dwd+xml"), + ("dwf", "model/vnd.dwf"), + ("dwg", "image/vnd.dwg"), + ("dxf", "image/vnd.dxf"), + ("dxp", "application/vnd.spotfire.dxp"), + ("dxr", "application/x-director"), + ("ear", "application/java-archive"), + ("ecelp4800", "audio/vnd.nuera.ecelp4800"), + ("ecelp7470", "audio/vnd.nuera.ecelp7470"), + ("ecelp9600", "audio/vnd.nuera.ecelp9600"), + ("ecma", "application/ecmascript"), + ("edm", "application/vnd.novadigm.edm"), + ("edx", "application/vnd.novadigm.edx"), + ("efif", "application/vnd.picsel"), + ("ei6", "application/vnd.pg.osasli"), + ("elc", "application/octet-stream"), + ("emf", "image/emf"), + ("eml", "message/rfc822"), + ("emma", "application/emma+xml"), + ("emotionml", "application/emotionml+xml"), + ("emz", "application/x-msmetafile"), + ("eol", "audio/vnd.digital-winds"), + ("eot", "application/vnd.ms-fontobject"), + ("eps", "application/postscript"), + ("epub", "application/epub+zip"), + ("es3", "application/vnd.eszigno3+xml"), + ("esa", "application/vnd.osgi.subsystem"), + ("esf", "application/vnd.epson.esf"), + ("et3", "application/vnd.eszigno3+xml"), + ("etx", "text/x-setext"), + ("eva", "application/x-eva"), + ("evy", "application/x-envoy"), + ("exe", "application/x-msdos-program"), + ("exi", "application/exi"), + ("exp", "application/express"), + ("exr", "image/aces"), + ("ext", "application/vnd.novadigm.ext"), + ("ez", "application/andrew-inset"), + ("ez2", "application/vnd.ezpix-album"), + ("ez3", "application/vnd.ezpix-package"), + ("f", "text/x-fortran"), + ("f4v", "video/x-f4v"), + ("f77", "text/x-fortran"), + ("f90", "text/x-fortran"), + ("fbs", "image/vnd.fastbidsheet"), + ("fbx", "application/vnd.autodesk.fbx"), + ("fcdt", "application/vnd.adobe.formscentral.fcdt"), + ("fcs", "application/vnd.isac.fcs"), + ("fdf", "application/fdf"), + ("fdt", "application/fdt+xml"), + ("fe_launch", "application/vnd.denovo.fcselayout-link"), + ("fg5", "application/vnd.fujitsu.oasysgp"), + ("fgd", "application/x-director"), + ("fh", "image/x-freehand"), + ("fh4", "image/x-freehand"), + ("fh5", "image/x-freehand"), + ("fh7", "image/x-freehand"), + ("fhc", "image/x-freehand"), + ("fig", "application/x-xfig"), + ("fits", "image/fits"), + ("flac", "audio/x-flac"), + ("fli", "video/x-fli"), + ("flo", "application/vnd.micrografx.flo"), + ("flv", "video/x-flv"), + ("flw", "application/vnd.kde.kivio"), + ("flx", "text/vnd.fmi.flexstor"), + ("fly", "text/vnd.fly"), + ("fm", "application/vnd.framemaker"), + ("fnc", "application/vnd.frogans.fnc"), + ("fo", "application/vnd.software602.filler.form+xml"), + ("for", "text/x-fortran"), + ("fpx", "image/vnd.fpx"), + ("frame", "application/vnd.framemaker"), + ("fsc", "application/vnd.fsc.weblaunch"), + ("fst", "image/vnd.fst"), + ("ftc", "application/vnd.fluxtime.clip"), + ("fti", "application/vnd.anser-web-funds-transfer-initiation"), + ("fvt", "video/vnd.fvt"), + ("fxp", "application/vnd.adobe.fxp"), + ("fxpl", "application/vnd.adobe.fxp"), + ("fzs", "application/vnd.fuzzysheet"), + ("g2w", "application/vnd.geoplan"), + ("g3", "image/g3fax"), + ("g3w", "application/vnd.geospace"), + ("gac", "application/vnd.groove-account"), + ("gam", "application/x-tads"), + ("gbr", "application/rpki-ghostbusters"), + ("gca", "application/x-gca-compressed"), + ("gdl", "model/vnd.gdl"), + ("gdoc", "application/vnd.google-apps.document"), + ("gdraw", "application/vnd.google-apps.drawing"), + ("ged", "text/vnd.familysearch.gedcom"), + ("geo", "application/vnd.dynageo"), + ("geojson", "application/geo+json"), + ("gex", "application/vnd.geometry-explorer"), + ("gform", "application/vnd.google-apps.form"), + ("ggb", "application/vnd.geogebra.file"), + ("ggs", "application/vnd.geogebra.slides"), + ("ggt", "application/vnd.geogebra.tool"), + ("ghf", "application/vnd.groove-help"), + ("gif", "image/gif"), + ("gim", "application/vnd.groove-identity-message"), + ("gjam", "application/vnd.google-apps.jam"), + ("glb", "model/gltf-binary"), + ("gltf", "model/gltf+json"), + ("gmap", "application/vnd.google-apps.map"), + ("gml", "application/gml+xml"), + ("gmx", "application/vnd.gmx"), + ("gnumeric", "application/x-gnumeric"), + ("gph", "application/vnd.flographit"), + ("gpx", "application/gpx+xml"), + ("gqf", "application/vnd.grafeq"), + ("gqs", "application/vnd.grafeq"), + ("gram", "application/srgs"), + ("gramps", "application/x-gramps-xml"), + ("gre", "application/vnd.geometry-explorer"), + ("grv", "application/vnd.groove-injector"), + ("grxml", "application/srgs+xml"), + ("gscript", "application/vnd.google-apps.script"), + ("gsf", "application/x-font-ghostscript"), + ("gsheet", "application/vnd.google-apps.spreadsheet"), + ("gsite", "application/vnd.google-apps.site"), + ("gslides", "application/vnd.google-apps.presentation"), + ("gtar", "application/x-gtar"), + ("gtm", "application/vnd.groove-tool-message"), + ("gtw", "model/vnd.gtw"), + ("gv", "text/vnd.graphviz"), + ("gxf", "application/gxf"), + ("gxt", "application/vnd.geonext"), + ("gz", "application/gzip"), + ("h", "text/x-c"), + ("h261", "video/h261"), + ("h263", "video/h263"), + ("h264", "video/h264"), + ("hal", "application/vnd.hal+xml"), + ("hbci", "application/vnd.hbci"), + ("hbs", "text/x-handlebars-template"), + ("hdd", "application/x-virtualbox-hdd"), + ("hdf", "application/x-hdf"), + ("heic", "image/heic"), + ("heics", "image/heic-sequence"), + ("heif", "image/heif"), + ("heifs", "image/heif-sequence"), + ("hej2", "image/hej2k"), + ("held", "application/atsc-held+xml"), + ("hh", "text/x-c"), + ("hjson", "application/hjson"), + ("hlp", "application/winhlp"), + ("hpgl", "application/vnd.hp-hpgl"), + ("hpid", "application/vnd.hp-hpid"), + ("hps", "application/vnd.hp-hps"), + ("hqx", "application/mac-binhex40"), + ("htc", "text/x-component"), + ("htke", "application/vnd.kenameaapp"), + ("htm", "text/html"), + ("html", "text/html"), + ("hvd", "application/vnd.yamaha.hv-dic"), + ("hvp", "application/vnd.yamaha.hv-voice"), + ("hvs", "application/vnd.yamaha.hv-script"), + ("i2g", "application/vnd.intergeo"), + ("icc", "application/vnd.iccprofile"), + ("ice", "x-conference/x-cooltalk"), + ("icm", "application/vnd.iccprofile"), + ("ico", "image/vnd.microsoft.icon"), + ("ics", "text/calendar"), + ("ief", "image/ief"), + ("ifb", "text/calendar"), + ("ifm", "application/vnd.shana.informed.formdata"), + ("iges", "model/iges"), + ("igl", "application/vnd.igloader"), + ("igm", "application/vnd.insors.igm"), + ("igs", "model/iges"), + ("igx", "application/vnd.micrografx.igx"), + ("iif", "application/vnd.shana.informed.interchange"), + ("img", "application/octet-stream"), + ("imp", "application/vnd.accpac.simply.imp"), + ("ims", "application/vnd.ms-ims"), + ("in", "text/plain"), + ("ini", "text/plain"), + ("ink", "application/inkml+xml"), + ("inkml", "application/inkml+xml"), + ("install", "application/x-install-instructions"), + ("iota", "application/vnd.astraea-software.iota"), + ("ipfix", "application/ipfix"), + ("ipk", "application/vnd.shana.informed.package"), + ("ipynb", "application/x-ipynb+json"), + ("irm", "application/vnd.ibm.rights-management"), + ("irp", "application/vnd.irepository.package+xml"), + ("iso", "application/x-iso9660-image"), + ("itp", "application/vnd.shana.informed.formtemplate"), + ("its", "application/its+xml"), + ("ivp", "application/vnd.immervision-ivp"), + ("ivu", "application/vnd.immervision-ivu"), + ("jad", "text/vnd.sun.j2me.app-descriptor"), + ("jade", "text/jade"), + ("jaii", "image/jaii"), + ("jais", "image/jais"), + ("jam", "application/vnd.jam"), + ("jar", "application/java-archive"), + ("jardiff", "application/x-java-archive-diff"), + ("java", "text/x-java-source"), + ("jfif", "image/pjpeg"), + ("jhc", "image/jphc"), + ("jisp", "application/vnd.jisp"), + ("jls", "image/jls"), + ("jlt", "application/vnd.hp-jlyt"), + ("jng", "image/x-jng"), + ("jnlp", "application/x-java-jnlp-file"), + ("joda", "application/vnd.joost.joda-archive"), + ("jp2", "image/jp2"), + ("jpe", "image/jpeg"), + ("jpeg", "image/jpeg"), + ("jpf", "image/jpx"), + ("jpg", "image/jpeg"), + ("jpg2", "image/jp2"), + ("jpgm", "image/jpm"), + ("jpgv", "video/jpeg"), + ("jph", "image/jph"), + ("jpm", "image/jpm"), + ("jpx", "image/jpx"), + ("js", "text/javascript"), + ("json", "application/json"), + ("json5", "application/json5"), + ("jsonld", "application/ld+json"), + ("jsonml", "application/jsonml+json"), + ("jsx", "text/jsx"), + ("jt", "model/jt"), + ("jxl", "image/jxl"), + ("jxr", "image/jxr"), + ("jxra", "image/jxra"), + ("jxrs", "image/jxrs"), + ("jxs", "image/jxs"), + ("jxsc", "image/jxsc"), + ("jxsi", "image/jxsi"), + ("jxss", "image/jxss"), + ("kar", "audio/midi"), + ("karbon", "application/vnd.kde.karbon"), + ("kdbx", "application/x-keepass2"), + ("key", "application/vnd.apple.keynote"), + ("kfo", "application/vnd.kde.kformula"), + ("kia", "application/vnd.kidspiration"), + ("kml", "application/vnd.google-earth.kml+xml"), + ("kmz", "application/vnd.google-earth.kmz"), + ("kne", "application/vnd.kinar"), + ("knp", "application/vnd.kinar"), + ("kon", "application/vnd.kde.kontour"), + ("kpr", "application/vnd.kde.kpresenter"), + ("kpt", "application/vnd.kde.kpresenter"), + ("kpxx", "application/vnd.ds-keypoint"), + ("ksp", "application/vnd.kde.kspread"), + ("ktr", "application/vnd.kahootz"), + ("ktx", "image/ktx"), + ("ktx2", "image/ktx2"), + ("ktz", "application/vnd.kahootz"), + ("kwd", "application/vnd.kde.kword"), + ("kwt", "application/vnd.kde.kword"), + ("lasxml", "application/vnd.las.las+xml"), + ("latex", "application/x-latex"), + ("lbd", "application/vnd.llamagraphics.life-balance.desktop"), + ( + "lbe", + "application/vnd.llamagraphics.life-balance.exchange+xml", + ), + ("les", "application/vnd.hhe.lesson-player"), + ("less", "text/less"), + ("lgr", "application/lgr+xml"), + ("lha", "application/x-lzh-compressed"), + ("link66", "application/vnd.route66.link66+xml"), + ("list", "text/plain"), + ("list3820", "application/vnd.ibm.modcap"), + ("listafp", "application/vnd.ibm.modcap"), + ("litcoffee", "text/coffeescript"), + ("lnk", "application/x-ms-shortcut"), + ("log", "text/plain"), + ("lostxml", "application/lost+xml"), + ("lottie", "application/zip+dotlottie"), + ("lrf", "application/octet-stream"), + ("lrm", "application/vnd.ms-lrm"), + ("ltf", "application/vnd.frogans.ltf"), + ("lua", "text/x-lua"), + ("luac", "application/x-lua-bytecode"), + ("lvp", "audio/vnd.lucent.voice"), + ("lwp", "application/vnd.lotus-wordpro"), + ("lzh", "application/x-lzh-compressed"), + ("m13", "application/x-msmediaview"), + ("m14", "application/x-msmediaview"), + ("m1v", "video/mpeg"), + ("m21", "application/mp21"), + ("m2a", "audio/mpeg"), + ("m2t", "video/mp2t"), + ("m2ts", "video/mp2t"), + ("m2v", "video/mpeg"), + ("m3a", "audio/mpeg"), + ("m3u", "audio/x-mpegurl"), + ("m3u8", "application/vnd.apple.mpegurl"), + ("m4a", "audio/mp4"), + ("m4b", "audio/mp4"), + ("m4p", "application/mp4"), + ("m4s", "video/iso.segment"), + ("m4u", "video/vnd.mpegurl"), + ("m4v", "video/x-m4v"), + ("ma", "application/mathematica"), + ("mads", "application/mads+xml"), + ("maei", "application/mmt-aei+xml"), + ("mag", "application/vnd.ecowin.chart"), + ("maker", "application/vnd.framemaker"), + ("man", "text/troff"), + ("manifest", "text/cache-manifest"), + ("map", "application/json"), + ("mar", "application/octet-stream"), + ("markdown", "text/markdown"), + ("mathml", "application/mathml+xml"), + ("mb", "application/mathematica"), + ("mbk", "application/vnd.mobius.mbk"), + ("mbox", "application/mbox"), + ("mc1", "application/vnd.medcalcdata"), + ("mcd", "application/vnd.mcd"), + ("mcurl", "text/vnd.curl.mcurl"), + ("md", "text/markdown"), + ("mdb", "application/x-msaccess"), + ("mdi", "image/vnd.ms-modi"), + ("mdx", "text/mdx"), + ("me", "text/troff"), + ("mesh", "model/mesh"), + ("meta4", "application/metalink4+xml"), + ("metalink", "application/metalink+xml"), + ("mets", "application/mets+xml"), + ("mfm", "application/vnd.mfmp"), + ("mft", "application/rpki-manifest"), + ("mgp", "application/vnd.osgeo.mapguide.package"), + ("mgz", "application/vnd.proteus.magazine"), + ("mht", "message/rfc822"), + ("mhtml", "message/rfc822"), + ("mid", "audio/midi"), + ("midi", "audio/midi"), + ("mie", "application/x-mie"), + ("mif", "application/vnd.mif"), + ("mime", "message/rfc822"), + ("mj2", "video/mj2"), + ("mjp2", "video/mj2"), + ("mjs", "text/javascript"), + ("mk3d", "video/x-matroska"), + ("mka", "audio/x-matroska"), + ("mkd", "text/x-markdown"), + ("mks", "video/x-matroska"), + ("mkv", "video/x-matroska"), + ("mlp", "application/vnd.dolby.mlp"), + ("mmd", "application/vnd.chipnuts.karaoke-mmd"), + ("mmf", "application/vnd.smaf"), + ("mml", "text/mathml"), + ("mmr", "image/vnd.fujixerox.edmics-mmr"), + ("mng", "video/x-mng"), + ("mny", "application/x-msmoney"), + ("mobi", "application/x-mobipocket-ebook"), + ("mods", "application/mods+xml"), + ("mov", "video/quicktime"), + ("movie", "video/x-sgi-movie"), + ("mp2", "audio/mpeg"), + ("mp21", "application/mp21"), + ("mp2a", "audio/mpeg"), + ("mp3", "audio/mpeg"), + ("mp4", "video/mp4"), + ("mp4a", "audio/mp4"), + ("mp4s", "application/mp4"), + ("mp4v", "video/mp4"), + ("mpc", "application/vnd.mophun.certificate"), + ("mpd", "application/dash+xml"), + ("mpe", "video/mpeg"), + ("mpeg", "video/mpeg"), + ("mpf", "application/media-policy-dataset+xml"), + ("mpg", "video/mpeg"), + ("mpg4", "video/mp4"), + ("mpga", "audio/mpeg"), + ("mpkg", "application/vnd.apple.installer+xml"), + ("mpm", "application/vnd.blueice.multipass"), + ("mpn", "application/vnd.mophun.application"), + ("mpp", "application/dash-patch+xml"), + ("mpt", "application/vnd.ms-project"), + ("mpy", "application/vnd.ibm.minipay"), + ("mqy", "application/vnd.mobius.mqy"), + ("mrc", "application/marc"), + ("mrcx", "application/marcxml+xml"), + ("ms", "text/troff"), + ("mscml", "application/mediaservercontrol+xml"), + ("mseed", "application/vnd.fdsn.mseed"), + ("mseq", "application/vnd.mseq"), + ("msf", "application/vnd.epson.msf"), + ("msg", "application/vnd.ms-outlook"), + ("msh", "model/mesh"), + ("msi", "application/x-msdownload"), + ("msix", "application/msix"), + ("msixbundle", "application/msixbundle"), + ("msl", "application/vnd.mobius.msl"), + ("msm", "application/octet-stream"), + ("msp", "application/octet-stream"), + ("msty", "application/vnd.muvee.style"), + ("mtl", "model/mtl"), + ("mts", "video/mp2t"), + ("mus", "application/vnd.musician"), + ("musd", "application/mmt-usd+xml"), + ("musicxml", "application/vnd.recordare.musicxml+xml"), + ("mvb", "application/x-msmediaview"), + ("mvt", "application/vnd.mapbox-vector-tile"), + ("mwf", "application/vnd.mfer"), + ("mxf", "application/mxf"), + ("mxl", "application/vnd.recordare.musicxml"), + ("mxmf", "audio/mobile-xmf"), + ("mxml", "application/xv+xml"), + ("mxs", "application/vnd.triscape.mxs"), + ("mxu", "video/vnd.mpegurl"), + ("n-gage", "application/vnd.nokia.n-gage.symbian.install"), + ("n3", "text/n3"), + ("nb", "application/mathematica"), + ("nbp", "application/vnd.wolfram.player"), + ("nc", "application/x-netcdf"), + ("ncx", "application/x-dtbncx+xml"), + ("nfo", "text/x-nfo"), + ("ngdat", "application/vnd.nokia.n-gage.data"), + ("nitf", "application/vnd.nitf"), + ("nlu", "application/vnd.neurolanguage.nlu"), + ("nml", "application/vnd.enliven"), + ("nnd", "application/vnd.noblenet-directory"), + ("nns", "application/vnd.noblenet-sealer"), + ("nnw", "application/vnd.noblenet-web"), + ("npx", "image/vnd.net-fpx"), + ("nq", "application/n-quads"), + ("nsc", "application/x-conference"), + ("nsf", "application/vnd.lotus-notes"), + ("nt", "application/n-triples"), + ("ntf", "application/vnd.nitf"), + ("numbers", "application/vnd.apple.numbers"), + ("nzb", "application/x-nzb"), + ("oa2", "application/vnd.fujitsu.oasys2"), + ("oa3", "application/vnd.fujitsu.oasys3"), + ("oas", "application/vnd.fujitsu.oasys"), + ("obd", "application/x-msbinder"), + ("obgx", "application/vnd.openblox.game+xml"), + ("obj", "model/obj"), + ("oda", "application/oda"), + ("odb", "application/vnd.oasis.opendocument.database"), + ("odc", "application/vnd.oasis.opendocument.chart"), + ("odf", "application/vnd.oasis.opendocument.formula"), + ( + "odft", + "application/vnd.oasis.opendocument.formula-template", + ), + ("odg", "application/vnd.oasis.opendocument.graphics"), + ("odi", "application/vnd.oasis.opendocument.image"), + ("odm", "application/vnd.oasis.opendocument.text-master"), + ("odp", "application/vnd.oasis.opendocument.presentation"), + ("ods", "application/vnd.oasis.opendocument.spreadsheet"), + ("odt", "application/vnd.oasis.opendocument.text"), + ("oga", "audio/ogg"), + ("ogex", "model/vnd.opengex"), + ("ogg", "audio/ogg"), + ("ogv", "video/ogg"), + ("ogx", "application/ogg"), + ("omdoc", "application/omdoc+xml"), + ("one", "application/onenote"), + ("onea", "application/onenote"), + ("onepkg", "application/onenote"), + ("onetmp", "application/onenote"), + ("onetoc", "application/onenote"), + ("onetoc2", "application/onenote"), + ("opf", "application/oebps-package+xml"), + ("opml", "text/x-opml"), + ("oprc", "application/vnd.palm"), + ("opus", "audio/ogg"), + ("org", "application/vnd.lotus-organizer"), + ("osf", "application/vnd.yamaha.openscoreformat"), + ( + "osfpvg", + "application/vnd.yamaha.openscoreformat.osfpvg+xml", + ), + ("osm", "application/vnd.openstreetmap.data+xml"), + ("otc", "application/vnd.oasis.opendocument.chart-template"), + ("otf", "font/otf"), + ( + "otg", + "application/vnd.oasis.opendocument.graphics-template", + ), + ("oth", "application/vnd.oasis.opendocument.text-web"), + ("oti", "application/vnd.oasis.opendocument.image-template"), + ( + "otp", + "application/vnd.oasis.opendocument.presentation-template", + ), + ( + "ots", + "application/vnd.oasis.opendocument.spreadsheet-template", + ), + ("ott", "application/vnd.oasis.opendocument.text-template"), + ("ova", "application/x-virtualbox-ova"), + ("ovf", "application/x-virtualbox-ovf"), + ("owl", "application/rdf+xml"), + ("oxps", "application/oxps"), + ("oxt", "application/vnd.openofficeorg.extension"), + ("p", "text/x-pascal"), + ("p10", "application/pkcs10"), + ("p12", "application/x-pkcs12"), + ("p21", "model/step"), + ("p7b", "application/x-pkcs7-certificates"), + ("p7c", "application/pkcs7-mime"), + ("p7m", "application/pkcs7-mime"), + ("p7r", "application/x-pkcs7-certreqresp"), + ("p7s", "application/pkcs7-signature"), + ("p8", "application/pkcs8"), + ("pac", "application/x-ns-proxy-autoconfig"), + ("pages", "application/vnd.apple.pages"), + ("pas", "text/x-pascal"), + ("paw", "application/vnd.pawaafile"), + ("pbd", "application/vnd.powerbuilder6"), + ("pbm", "image/x-portable-bitmap"), + ("pcap", "application/vnd.tcpdump.pcap"), + ("pcf", "application/x-font-pcf"), + ("pcl", "application/vnd.hp-pcl"), + ("pclxl", "application/vnd.hp-pclxl"), + ("pct", "image/x-pict"), + ("pcurl", "application/vnd.curl.pcurl"), + ("pcx", "image/vnd.zbrush.pcx"), + ("pdb", "application/vnd.palm"), + ("pde", "text/x-processing"), + ("pdf", "application/pdf"), + ("pem", "application/x-x509-ca-cert"), + ("pfa", "application/x-font-type1"), + ("pfb", "application/x-font-type1"), + ("pfm", "application/x-font-type1"), + ("pfr", "application/font-tdpfr"), + ("pfx", "application/x-pkcs12"), + ("pgm", "image/x-portable-graymap"), + ("pgn", "application/x-chess-pgn"), + ("pgp", "application/pgp-encrypted"), + ("php", "application/x-httpd-php"), + ("pic", "image/x-pict"), + ("pkg", "application/octet-stream"), + ("pki", "application/pkixcmp"), + ("pkipath", "application/pkix-pkipath"), + ("pkpass", "application/vnd.apple.pkpass"), + ("pl", "application/x-perl"), + ("plb", "application/vnd.3gpp.pic-bw-large"), + ("plc", "application/vnd.mobius.plc"), + ("plf", "application/vnd.pocketlearn"), + ("pls", "application/pls+xml"), + ("pm", "application/x-perl"), + ("pml", "application/vnd.ctc-posml"), + ("png", "image/png"), + ("pnm", "image/x-portable-anymap"), + ("portpkg", "application/vnd.macports.portpkg"), + ("pot", "application/vnd.ms-powerpoint"), + ( + "potm", + "application/vnd.ms-powerpoint.template.macroenabled.12", + ), + ( + "potx", + "application/vnd.openxmlformats-officedocument.presentationml.template", + ), + ( + "ppam", + "application/vnd.ms-powerpoint.addin.macroenabled.12", + ), + ("ppd", "application/vnd.cups-ppd"), + ("ppm", "image/x-portable-pixmap"), + ("pps", "application/vnd.ms-powerpoint"), + ( + "ppsm", + "application/vnd.ms-powerpoint.slideshow.macroenabled.12", + ), + ( + "ppsx", + "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + ), + ("ppt", "application/vnd.ms-powerpoint"), + ( + "pptm", + "application/vnd.ms-powerpoint.presentation.macroenabled.12", + ), + ( + "pptx", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ), + ("pqa", "application/vnd.palm"), + ("prc", "model/prc"), + ("pre", "application/vnd.lotus-freelance"), + ("prf", "application/pics-rules"), + ("provx", "application/provenance+xml"), + ("ps", "application/postscript"), + ("psb", "application/vnd.3gpp.pic-bw-small"), + ("psd", "image/vnd.adobe.photoshop"), + ("psf", "application/x-font-linux-psf"), + ("pskcxml", "application/pskc+xml"), + ("pti", "image/prs.pti"), + ("ptid", "application/vnd.pvi.ptid1"), + ("pub", "application/x-mspublisher"), + ("pvb", "application/vnd.3gpp.pic-bw-var"), + ("pwn", "application/vnd.3m.post-it-notes"), + ("pya", "audio/vnd.ms-playready.media.pya"), + ("pyo", "model/vnd.pytha.pyox"), + ("pyox", "model/vnd.pytha.pyox"), + ("pyv", "video/vnd.ms-playready.media.pyv"), + ("qam", "application/vnd.epson.quickanime"), + ("qbo", "application/vnd.intu.qbo"), + ("qfx", "application/vnd.intu.qfx"), + ("qps", "application/vnd.publishare-delta-tree"), + ("qt", "video/quicktime"), + ("qwd", "application/vnd.quark.quarkxpress"), + ("qwt", "application/vnd.quark.quarkxpress"), + ("qxb", "application/vnd.quark.quarkxpress"), + ("qxd", "application/vnd.quark.quarkxpress"), + ("qxl", "application/vnd.quark.quarkxpress"), + ("qxt", "application/vnd.quark.quarkxpress"), + ("ra", "audio/x-pn-realaudio"), + ("ram", "audio/x-pn-realaudio"), + ("raml", "application/raml+yaml"), + ("rapd", "application/route-apd+xml"), + ("rar", "application/vnd.rar"), + ("ras", "image/x-cmu-raster"), + ("rcprofile", "application/vnd.ipunplugged.rcprofile"), + ("rdf", "application/rdf+xml"), + ("rdz", "application/vnd.data-vision.rdz"), + ("relo", "application/p2p-overlay+xml"), + ("rep", "application/vnd.businessobjects"), + ("res", "application/x-dtbresource+xml"), + ("rgb", "image/x-rgb"), + ("rif", "application/reginfo+xml"), + ("rip", "audio/vnd.rip"), + ("ris", "application/x-research-info-systems"), + ("rl", "application/resource-lists+xml"), + ("rlc", "image/vnd.fujixerox.edmics-rlc"), + ("rld", "application/resource-lists-diff+xml"), + ("rm", "application/vnd.rn-realmedia"), + ("rmi", "audio/midi"), + ("rmp", "audio/x-pn-realaudio-plugin"), + ("rms", "application/vnd.jcp.javame.midlet-rms"), + ("rmvb", "application/vnd.rn-realmedia-vbr"), + ("rnc", "application/relax-ng-compact-syntax"), + ("rng", "application/xml"), + ("roa", "application/rpki-roa"), + ("roff", "text/troff"), + ("rp9", "application/vnd.cloanto.rp9"), + ("rpm", "application/x-redhat-package-manager"), + ("rpss", "application/vnd.nokia.radio-presets"), + ("rpst", "application/vnd.nokia.radio-preset"), + ("rq", "application/sparql-query"), + ("rs", "application/rls-services+xml"), + ("rsat", "application/atsc-rsat+xml"), + ("rsd", "application/rsd+xml"), + ("rsheet", "application/urc-ressheet+xml"), + ("rss", "application/rss+xml"), + ("rtf", "application/rtf"), + ("rtx", "text/richtext"), + ("run", "application/x-makeself"), + ("rusd", "application/route-usd+xml"), + ("s", "text/x-asm"), + ("s3m", "audio/s3m"), + ("saf", "application/vnd.yamaha.smaf-audio"), + ("sass", "text/x-sass"), + ("sbml", "application/sbml+xml"), + ("sc", "application/vnd.ibm.secure-container"), + ("scd", "application/x-msschedule"), + ("scm", "application/vnd.lotus-screencam"), + ("scq", "application/scvp-cv-request"), + ("scs", "application/scvp-cv-response"), + ("scss", "text/x-scss"), + ("scurl", "text/vnd.curl.scurl"), + ("sda", "application/vnd.stardivision.draw"), + ("sdc", "application/vnd.stardivision.calc"), + ("sdd", "application/vnd.stardivision.impress"), + ("sdkd", "application/vnd.solent.sdkm+xml"), + ("sdkm", "application/vnd.solent.sdkm+xml"), + ("sdp", "application/sdp"), + ("sdw", "application/vnd.stardivision.writer"), + ("sea", "application/x-sea"), + ("see", "application/vnd.seemail"), + ("seed", "application/vnd.fdsn.seed"), + ("sema", "application/vnd.sema"), + ("semd", "application/vnd.semd"), + ("semf", "application/vnd.semf"), + ("senmlx", "application/senml+xml"), + ("sensmlx", "application/sensml+xml"), + ("ser", "application/java-serialized-object"), + ("setpay", "application/set-payment-initiation"), + ("setreg", "application/set-registration-initiation"), + ("sfd-hdstx", "application/vnd.hydrostatix.sof-data"), + ("sfs", "application/vnd.spotfire.sfs"), + ("sfv", "text/x-sfv"), + ("sgi", "image/sgi"), + ("sgl", "application/vnd.stardivision.writer-global"), + ("sgm", "text/sgml"), + ("sgml", "text/sgml"), + ("sh", "application/x-sh"), + ("shar", "application/x-shar"), + ("shex", "text/shex"), + ("shf", "application/shf+xml"), + ("shtml", "text/html"), + ("sid", "image/x-mrsid-image"), + ("sieve", "application/sieve"), + ("sig", "application/pgp-signature"), + ("sil", "audio/silk"), + ("silo", "model/mesh"), + ("sis", "application/vnd.symbian.install"), + ("sisx", "application/vnd.symbian.install"), + ("sit", "application/x-stuffit"), + ("sitx", "application/x-stuffitx"), + ("siv", "application/sieve"), + ("skd", "application/vnd.koan"), + ("skm", "application/vnd.koan"), + ("skp", "application/vnd.koan"), + ("skt", "application/vnd.koan"), + ( + "sldm", + "application/vnd.ms-powerpoint.slide.macroenabled.12", + ), + ( + "sldx", + "application/vnd.openxmlformats-officedocument.presentationml.slide", + ), + ("slim", "text/slim"), + ("slm", "text/slim"), + ("sls", "application/route-s-tsid+xml"), + ("slt", "application/vnd.epson.salt"), + ("sm", "application/vnd.stepmania.stepchart"), + ("smf", "application/vnd.stardivision.math"), + ("smi", "application/smil+xml"), + ("smil", "application/smil+xml"), + ("smv", "video/x-smv"), + ("smzip", "application/vnd.stepmania.package"), + ("snd", "audio/basic"), + ("snf", "application/x-font-snf"), + ("so", "application/octet-stream"), + ("spc", "application/x-pkcs7-certificates"), + ("spdx", "text/spdx"), + ("spf", "application/vnd.yamaha.smaf-phrase"), + ("spl", "application/x-futuresplash"), + ("spot", "text/vnd.in3d.spot"), + ("spp", "application/scvp-vp-response"), + ("spq", "application/scvp-vp-request"), + ("spx", "audio/ogg"), + ("sql", "application/sql"), + ("src", "application/x-wais-source"), + ("srt", "application/x-subrip"), + ("sru", "application/sru+xml"), + ("srx", "application/sparql-results+xml"), + ("ssdl", "application/ssdl+xml"), + ("sse", "application/vnd.kodak-descriptor"), + ("ssf", "application/vnd.epson.ssf"), + ("ssml", "application/ssml+xml"), + ("st", "application/vnd.sailingtracker.track"), + ("stc", "application/vnd.sun.xml.calc.template"), + ("std", "application/vnd.sun.xml.draw.template"), + ("step", "model/step"), + ("stf", "application/vnd.wt.stf"), + ("sti", "application/vnd.sun.xml.impress.template"), + ("stk", "application/hyperstudio"), + ("stl", "model/stl"), + ("stp", "model/step"), + ("stpnc", "model/step"), + ("stpx", "model/step+xml"), + ("stpxz", "model/step-xml+zip"), + ("stpz", "model/step+zip"), + ("str", "application/vnd.pg.format"), + ("stw", "application/vnd.sun.xml.writer.template"), + ("styl", "text/stylus"), + ("stylus", "text/stylus"), + ("sub", "text/vnd.dvb.subtitle"), + ("sus", "application/vnd.sus-calendar"), + ("susp", "application/vnd.sus-calendar"), + ("sv4cpio", "application/x-sv4cpio"), + ("sv4crc", "application/x-sv4crc"), + ("svc", "application/vnd.dvb.service"), + ("svd", "application/vnd.svd"), + ("svg", "image/svg+xml"), + ("svgz", "image/svg+xml"), + ("swa", "application/x-director"), + ("swf", "application/x-shockwave-flash"), + ("swi", "application/vnd.aristanetworks.swi"), + ("swidtag", "application/swid+xml"), + ("sxc", "application/vnd.sun.xml.calc"), + ("sxd", "application/vnd.sun.xml.draw"), + ("sxg", "application/vnd.sun.xml.writer.global"), + ("sxi", "application/vnd.sun.xml.impress"), + ("sxm", "application/vnd.sun.xml.math"), + ("sxw", "application/vnd.sun.xml.writer"), + ("t", "text/troff"), + ("t3", "application/x-t3vm-image"), + ("t38", "image/t38"), + ("taglet", "application/vnd.mynfc"), + ("tao", "application/vnd.tao.intent-module-archive"), + ("tap", "image/vnd.tencent.tap"), + ("tar", "application/x-tar"), + ("tcap", "application/vnd.3gpp2.tcap"), + ("tcl", "application/x-tcl"), + ("td", "application/urc-targetdesc+xml"), + ("teacher", "application/vnd.smart.teacher"), + ("tei", "application/tei+xml"), + ("teicorpus", "application/tei+xml"), + ("tex", "application/x-tex"), + ("texi", "application/x-texinfo"), + ("texinfo", "application/x-texinfo"), + ("text", "text/plain"), + ("tfi", "application/thraud+xml"), + ("tfm", "application/x-tex-tfm"), + ("tfx", "image/tiff-fx"), + ("tga", "image/x-tga"), + ("thmx", "application/vnd.ms-officetheme"), + ("tif", "image/tiff"), + ("tiff", "image/tiff"), + ("tk", "application/x-tcl"), + ("tmo", "application/vnd.tmobile-livetv"), + ("toml", "application/toml"), + ("torrent", "application/x-bittorrent"), + ("tpl", "application/vnd.groove-tool-template"), + ("tpt", "application/vnd.trid.tpt"), + ("tr", "text/troff"), + ("tra", "application/vnd.trueapp"), + ("trig", "application/trig"), + ("trm", "application/x-msterminal"), + ("ts", "video/mp2t"), + ("tsd", "application/timestamped-data"), + ("tsv", "text/tab-separated-values"), + ("ttc", "font/collection"), + ("ttf", "font/ttf"), + ("ttl", "text/turtle"), + ("ttml", "application/ttml+xml"), + ("twd", "application/vnd.simtech-mindmapper"), + ("twds", "application/vnd.simtech-mindmapper"), + ("txd", "application/vnd.genomatix.tuxedo"), + ("txf", "application/vnd.mobius.txf"), + ("txt", "text/plain"), + ("u32", "application/x-authorware-bin"), + ("u3d", "model/u3d"), + ("u8dsn", "message/global-delivery-status"), + ("u8hdr", "message/global-headers"), + ("u8mdn", "message/global-disposition-notification"), + ("u8msg", "message/global"), + ("ubj", "application/ubjson"), + ("udeb", "application/x-debian-package"), + ("ufd", "application/vnd.ufdl"), + ("ufdl", "application/vnd.ufdl"), + ("ulx", "application/x-glulx"), + ("umj", "application/vnd.umajin"), + ("unityweb", "application/vnd.unity"), + ("uo", "application/vnd.uoml+xml"), + ("uoml", "application/vnd.uoml+xml"), + ("uri", "text/uri-list"), + ("uris", "text/uri-list"), + ("urls", "text/uri-list"), + ("usda", "model/vnd.usda"), + ("usdz", "model/vnd.usdz+zip"), + ("ustar", "application/x-ustar"), + ("utz", "application/vnd.uiq.theme"), + ("uu", "text/x-uuencode"), + ("uva", "audio/vnd.dece.audio"), + ("uvd", "application/vnd.dece.data"), + ("uvf", "application/vnd.dece.data"), + ("uvg", "image/vnd.dece.graphic"), + ("uvh", "video/vnd.dece.hd"), + ("uvi", "image/vnd.dece.graphic"), + ("uvm", "video/vnd.dece.mobile"), + ("uvp", "video/vnd.dece.pd"), + ("uvs", "video/vnd.dece.sd"), + ("uvt", "application/vnd.dece.ttml+xml"), + ("uvu", "video/vnd.uvvu.mp4"), + ("uvv", "video/vnd.dece.video"), + ("uvva", "audio/vnd.dece.audio"), + ("uvvd", "application/vnd.dece.data"), + ("uvvf", "application/vnd.dece.data"), + ("uvvg", "image/vnd.dece.graphic"), + ("uvvh", "video/vnd.dece.hd"), + ("uvvi", "image/vnd.dece.graphic"), + ("uvvm", "video/vnd.dece.mobile"), + ("uvvp", "video/vnd.dece.pd"), + ("uvvs", "video/vnd.dece.sd"), + ("uvvt", "application/vnd.dece.ttml+xml"), + ("uvvu", "video/vnd.uvvu.mp4"), + ("uvvv", "video/vnd.dece.video"), + ("uvvx", "application/vnd.dece.unspecified"), + ("uvvz", "application/vnd.dece.zip"), + ("uvx", "application/vnd.dece.unspecified"), + ("uvz", "application/vnd.dece.zip"), + ("vbox", "application/x-virtualbox-vbox"), + ("vbox-extpack", "application/x-virtualbox-vbox-extpack"), + ("vcard", "text/vcard"), + ("vcd", "application/x-cdlink"), + ("vcf", "text/x-vcard"), + ("vcg", "application/vnd.groove-vcard"), + ("vcs", "text/x-vcalendar"), + ("vcx", "application/vnd.vcx"), + ("vdi", "application/x-virtualbox-vdi"), + ("vds", "model/vnd.sap.vds"), + ("vdx", "application/vnd.ms-visio.viewer"), + ("vhd", "application/x-virtualbox-vhd"), + ("vis", "application/vnd.visionary"), + ("viv", "video/vnd.vivo"), + ("vmdk", "application/x-virtualbox-vmdk"), + ("vob", "video/x-ms-vob"), + ("vor", "application/vnd.stardivision.writer"), + ("vox", "application/x-authorware-bin"), + ("vrml", "model/vrml"), + ("vsd", "application/vnd.visio"), + ("vsdx", "application/vnd.visio"), + ("vsf", "application/vnd.vsf"), + ("vss", "application/vnd.visio"), + ("vst", "application/vnd.visio"), + ("vsw", "application/vnd.visio"), + ("vtf", "image/vnd.valve.source.texture"), + ("vtt", "text/vtt"), + ("vtu", "model/vnd.vtu"), + ("vtx", "application/vnd.visio"), + ("vxml", "application/voicexml+xml"), + ("w3d", "application/x-director"), + ("wad", "application/x-doom"), + ("wadl", "application/vnd.sun.wadl+xml"), + ("war", "application/java-archive"), + ("wasm", "application/wasm"), + ("wav", "audio/wav"), + ("wax", "audio/x-ms-wax"), + ("wbmp", "image/vnd.wap.wbmp"), + ("wbs", "application/vnd.criticaltools.wbs+xml"), + ("wbxml", "application/vnd.wap.wbxml"), + ("wcm", "application/vnd.ms-works"), + ("wdb", "application/vnd.ms-works"), + ("wdp", "image/vnd.ms-photo"), + ("weba", "audio/webm"), + ("webapp", "application/x-web-app-manifest+json"), + ("webm", "video/webm"), + ("webmanifest", "application/manifest+json"), + ("webp", "image/webp"), + ("wg", "application/vnd.pmi.widget"), + ("wgsl", "text/wgsl"), + ("wgt", "application/widget"), + ("wif", "application/watcherinfo+xml"), + ("wks", "application/vnd.ms-works"), + ("wm", "video/x-ms-wm"), + ("wma", "audio/x-ms-wma"), + ("wmd", "application/x-ms-wmd"), + ("wmf", "image/wmf"), + ("wml", "text/vnd.wap.wml"), + ("wmlc", "application/vnd.wap.wmlc"), + ("wmls", "text/vnd.wap.wmlscript"), + ("wmlsc", "application/vnd.wap.wmlscriptc"), + ("wmv", "video/x-ms-wmv"), + ("wmx", "video/x-ms-wmx"), + ("wmz", "application/x-ms-wmz"), + ("woff", "font/woff"), + ("woff2", "font/woff2"), + ("wpd", "application/vnd.wordperfect"), + ("wpl", "application/vnd.ms-wpl"), + ("wps", "application/vnd.ms-works"), + ("wqd", "application/vnd.wqd"), + ("wri", "application/x-mswrite"), + ("wrl", "model/vrml"), + ("wsc", "message/vnd.wfa.wsc"), + ("wsdl", "application/wsdl+xml"), + ("wspolicy", "application/wspolicy+xml"), + ("wtb", "application/vnd.webturbo"), + ("wvx", "video/x-ms-wvx"), + ("x32", "application/x-authorware-bin"), + ("x3d", "model/x3d+xml"), + ("x3db", "model/x3d+fastinfoset"), + ("x3dbz", "model/x3d+binary"), + ("x3dv", "model/x3d-vrml"), + ("x3dvz", "model/x3d+vrml"), + ("x3dz", "model/x3d+xml"), + ("x_b", "model/vnd.parasolid.transmit.binary"), + ("x_t", "model/vnd.parasolid.transmit.text"), + ("xaml", "application/xaml+xml"), + ("xap", "application/x-silverlight-app"), + ("xar", "application/vnd.xara"), + ("xav", "application/xcap-att+xml"), + ("xbap", "application/x-ms-xbap"), + ("xbd", "application/vnd.fujixerox.docuworks.binder"), + ("xbm", "image/x-xbitmap"), + ("xca", "application/xcap-caps+xml"), + ("xcs", "application/calendar+xml"), + ("xdcf", "application/vnd.gov.sk.xmldatacontainer+xml"), + ("xdf", "application/xcap-diff+xml"), + ("xdm", "application/vnd.syncml.dm+xml"), + ("xdp", "application/vnd.adobe.xdp+xml"), + ("xdssc", "application/dssc+xml"), + ("xdw", "application/vnd.fujixerox.docuworks"), + ("xel", "application/xcap-el+xml"), + ("xenc", "application/xenc+xml"), + ("xer", "application/patch-ops-error+xml"), + ("xfdf", "application/xfdf"), + ("xfdl", "application/vnd.xfdl"), + ("xht", "application/xhtml+xml"), + ("xhtm", "application/vnd.pwg-xhtml-print+xml"), + ("xhtml", "application/xhtml+xml"), + ("xhvml", "application/xv+xml"), + ("xif", "image/vnd.xiff"), + ("xla", "application/vnd.ms-excel"), + ("xlam", "application/vnd.ms-excel.addin.macroenabled.12"), + ("xlc", "application/vnd.ms-excel"), + ("xlf", "application/xliff+xml"), + ("xlm", "application/vnd.ms-excel"), + ("xls", "application/vnd.ms-excel"), + ( + "xlsb", + "application/vnd.ms-excel.sheet.binary.macroenabled.12", + ), + ("xlsm", "application/vnd.ms-excel.sheet.macroenabled.12"), + ( + "xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ), + ("xlt", "application/vnd.ms-excel"), + ("xltm", "application/vnd.ms-excel.template.macroenabled.12"), + ( + "xltx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + ), + ("xlw", "application/vnd.ms-excel"), + ("xm", "audio/xm"), + ("xml", "application/xml"), + ("xns", "application/xcap-ns+xml"), + ("xo", "application/vnd.olpc-sugar"), + ("xop", "application/xop+xml"), + ("xpi", "application/x-xpinstall"), + ("xpl", "application/xproc+xml"), + ("xpm", "image/x-xpixmap"), + ("xpr", "application/vnd.is-xpr"), + ("xps", "application/vnd.ms-xpsdocument"), + ("xpw", "application/vnd.intercon.formnet"), + ("xpx", "application/vnd.intercon.formnet"), + ("xsd", "application/xml"), + ("xsf", "application/prs.xsf+xml"), + ("xsl", "application/xml"), + ("xslt", "application/xslt+xml"), + ("xsm", "application/vnd.syncml+xml"), + ("xspf", "application/xspf+xml"), + ("xul", "application/vnd.mozilla.xul+xml"), + ("xvm", "application/xv+xml"), + ("xvml", "application/xv+xml"), + ("xwd", "image/x-xwindowdump"), + ("xyz", "chemical/x-xyz"), + ("xz", "application/x-xz"), + ("yaml", "text/yaml"), + ("yang", "application/yang"), + ("yin", "application/yin+xml"), + ("yml", "text/yaml"), + ("ymp", "text/x-suse-ymp"), + ("z1", "application/x-zmachine"), + ("z2", "application/x-zmachine"), + ("z3", "application/x-zmachine"), + ("z4", "application/x-zmachine"), + ("z5", "application/x-zmachine"), + ("z6", "application/x-zmachine"), + ("z7", "application/x-zmachine"), + ("z8", "application/x-zmachine"), + ("zaz", "application/vnd.zzazz.deck+xml"), + ("zip", "application/zip"), + ("zir", "application/vnd.zul"), + ("zirz", "application/vnd.zul"), + ("zmm", "application/vnd.handheld-entertainment+xml"), +]; diff --git a/cli-rs/crates/wisp-core/src/split.rs b/cli-rs/crates/wisp-core/src/split.rs index 1b5470c..2aa5458 100644 --- a/cli-rs/crates/wisp-core/src/split.rs +++ b/cli-rs/crates/wisp-core/src/split.rs @@ -304,15 +304,17 @@ fn replace_directory(root: &Directory, path: &[&str], uri: &str) -> Result Vec { + let empty = directory_size(&directory(Vec::new())); let mut chunks = Vec::new(); let mut entries = Vec::new(); - let mut size = 100; + let mut size = empty; for entry in &root.entries { - let entry_size = json_size(entry); + let entry_size = json_size(entry) + 1; if !entries.is_empty() && size + entry_size > max_size { chunks.push(directory(std::mem::take(&mut entries))); - size = 100; + size = empty; } entries.push(entry.clone()); size += entry_size; @@ -428,14 +430,8 @@ mod tests { ); for write in &plan.records { assert!(write.root.entries.len() <= 500); - // The chunk budget ignores commas between entries, like the old CLI, - // so chunks can run a little over it. let as_fs = crate::convert::subfs_to_fs(&write.root); - assert!( - directory_size(&as_fs) <= MAX_SUBFS_SIZE * 21 / 20, - "{}", - write.rkey - ); + assert!(directory_size(&as_fs) <= MAX_SUBFS_SIZE, "{}", write.rkey); } } diff --git a/cli-rs/crates/wisp-ui/src/lib.rs b/cli-rs/crates/wisp-ui/src/lib.rs index d2244c8..a4a94e8 100644 --- a/cli-rs/crates/wisp-ui/src/lib.rs +++ b/cli-rs/crates/wisp-ui/src/lib.rs @@ -163,14 +163,16 @@ pub fn info(message: impl Into) { note(Line::from(vec![s::muted("· "), s::muted(message.into())])); } -/// A fatal error, printed as the last thing before a non-zero exit. +/// A fatal error, printed as the last thing before a non-zero exit. Plain +/// output keeps the TS CLI's `Error: ...` line start for log greps. pub fn error(message: impl Into) { blank(); - note(Line::from(vec![ - s::danger("✗ "), - s::danger("Error: "), - s::plain(message.into()), - ])); + let glyph = (rt().mode() == Mode::Rich).then(|| s::danger("✗ ")); + note(Line::from_iter( + glyph + .into_iter() + .chain([s::danger("Error: "), s::plain(message.into())]), + )); blank(); } diff --git a/cli-rs/crates/wispctl/Cargo.toml b/cli-rs/crates/wispctl/Cargo.toml index 3aa12cc..5f833aa 100644 --- a/cli-rs/crates/wispctl/Cargo.toml +++ b/cli-rs/crates/wispctl/Cargo.toml @@ -35,7 +35,10 @@ hyper.workspace = true hyper-util.workspace = true http-body-util.workspace = true bytes.workspace = true -mime_guess.workspace = true jacquard-common.workspace = true rusqlite.workspace = true keyring.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +rustls-native-certs.workspace = true +webpki-root-certs.workspace = true diff --git a/cli-rs/crates/wispctl/src/auth/mod.rs b/cli-rs/crates/wispctl/src/auth/mod.rs index aca882e..bf666d8 100644 --- a/cli-rs/crates/wispctl/src/auth/mod.rs +++ b/cli-rs/crates/wispctl/src/auth/mod.rs @@ -4,7 +4,7 @@ pub mod oauth; pub mod resolver; pub mod store; -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result, anyhow, bail}; use jacquard::client::credential_session::{CredentialSession, SessionKey}; use jacquard::{ AuthorizationToken, BosStr, SmolStr, @@ -27,6 +27,9 @@ use store::{AccountStore, AccountUpdates, AuthMethod, OAuthScopeStrategy, Stored pub struct AuthOptions { pub app_password: Option, pub db_path: Option, + /// `login`: authenticate afresh and remember the account. Other commands + /// reuse stored sessions, and an app password given to them is used once + /// without touching the account store. pub force_reauth: bool, } @@ -173,8 +176,14 @@ fn open_store(db: Option<&Path>) -> Result { } } -pub async fn resolve_account_for_cwd(db: Option<&Path>) -> Result> { - open_store(db)?.resolve_account_for_dir(&std::env::current_dir()?.to_string_lossy()) +/// The account a bare command here would use. Like the TS CLI, an unreadable +/// store just means "none": the caller then asks for a handle. +pub async fn resolve_account_for_cwd(db: Option<&Path>) -> Option { + let cwd = std::env::current_dir().ok()?; + open_store(db) + .and_then(|store| store.resolve_account_for_dir(&cwd.to_string_lossy())) + .ok() + .flatten() } async fn password_session( @@ -203,7 +212,15 @@ async fn password_session( None, Some(Uri::parse(pds.clone()).map_err(|(err, _)| err)?), ) - .await?; + .await + .map_err(|err| { + // jacquard drops the PDS's 401 message; this is what it says. + if err.is_auth() { + anyhow!("Invalid identifier or password") + } else { + err.into() + } + })?; let did = data.did.to_string(); let handle = Some(data.handle.to_string()); status(&format!("Authenticated as {did}")); @@ -258,13 +275,22 @@ async fn finish( }) } +/// Whether the TypeScript CLI left an OAuth session for `did` (keychain account +/// `` or kv `oauth_session:`). +fn has_legacy_oauth_session(db: &Mutex, did: &str) -> bool { + keychain::read_secret(did).is_some() + || db + .lock() + .ok() + .and_then(|store| store.get(&format!("oauth_session:{did}")).ok().flatten()) + .is_some() +} + pub async fn authenticate( handle: Option<&str>, opts: &AuthOptions, mut on_status: impl FnMut(&str), ) -> Result { - let db = Arc::new(Mutex::new(open_store(opts.db_path.as_deref())?)); - let cwd = std::env::current_dir()?.to_string_lossy().into_owned(); let normalized = handle.map(|h| { if h.starts_with("did:") { h.to_owned() @@ -291,7 +317,7 @@ pub async fn authenticate( }; let resolver = Arc::new(resolver::resolver()?); if let Some(password) = password { - let (session, did, pds, session_handle) = password_session( + let (session, did, pds, handle) = password_session( identifier.unwrap(), &password, None, @@ -299,11 +325,22 @@ pub async fn authenticate( &mut on_status, ) .await?; + if !opts.force_reauth { + // Headless/CI path: like the TS CLI, it never touches the account + // store, so it works without a writable (or any) home directory. + return Ok(Authenticated { + agent: Agent::new(session), + did, + handle, + }); + } + let db = Arc::new(Mutex::new(open_store(opts.db_path.as_deref())?)); + let cwd = std::env::current_dir()?.to_string_lossy().into_owned(); return finish( &db, session, did, - session_handle, + handle, AccountUpdates { method: Some(AuthMethod::AppPassword), pds_url: Some(pds), @@ -313,6 +350,8 @@ pub async fn authenticate( ) .await; } + let db = Arc::new(Mutex::new(open_store(opts.db_path.as_deref())?)); + let cwd = std::env::current_dir()?.to_string_lossy().into_owned(); let target_did = match identifier { Some(value) => { let lookup = open_store(opts.db_path.as_deref())?; @@ -435,6 +474,17 @@ pub async fn authenticate( let identifier = identifier .or(target_did.as_deref()) .context("No stored account. Run `wispctl login ` first.")?; + if !opts.force_reauth + && !wisp_ui::can_prompt() + && let Some(did) = &target_did + && has_legacy_oauth_session(&db, did) + { + // wispctl 1.x sessions can't be carried over. Rather than wait five + // minutes for a browser nobody will see, say how to fix it. + bail!( + "The stored session for {identifier} was created by wispctl 1.x and cannot be reused. Run `wispctl login {identifier}` once in a terminal, or pass --password / WISPCTL_APP_PASSWORD." + ); + } let (session, strategy, port) = oauth::login( store, resolver, diff --git a/cli-rs/crates/wispctl/src/auth/resolver.rs b/cli-rs/crates/wispctl/src/auth/resolver.rs index e4a505a..b158a90 100644 --- a/cli-rs/crates/wispctl/src/auth/resolver.rs +++ b/cli-rs/crates/wispctl/src/auth/resolver.rs @@ -1,6 +1,6 @@ //! Environment-aware identity resolution using Jacquard's HTTP stack. use super::store::{AccountStore, AccountUpdates, normalize_handle}; -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result, anyhow}; use jacquard::{ BosStr, IntoStatic, deps::fluent_uri::Uri, @@ -12,16 +12,29 @@ use jacquard::{ oauth::{dpop::DpopExt, resolver::OAuthResolver}, types::string::{Did, Handle}, }; +use wisp_core::identity::{is_loopback_host, validate_pds_endpoint}; + +/// The TS CLI resolved handles only through this endpoint (then checked the +/// DID document), so CI egress allowlists name it rather than DNS/well-known. +const HANDLE_RESOLVER: &str = + "https://slingshot.microcosm.blue/xrpc/com.atproto.identity.resolveHandle"; #[derive(Clone)] pub struct EnvResolver { + /// PDS traffic. No total timeout: a large blob upload takes as long as the + /// link needs, as it did with the TS CLI. http: reqwest::Client, + /// Identity lookups, which are small and bounded. + identity: reqwest::Client, inner: PublicResolver, - handle_url: Option, + handle_url: String, } pub fn resolver() -> Result { let http = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(30)) + .build()?; + let identity = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(20)) .build()?; let mut opts = ResolverOptions::default(); @@ -30,14 +43,20 @@ pub fn resolver() -> Result { base: Uri::parse(format!("{}/", url.trim_end_matches('/'))).map_err(|(err, _)| err)?, }; } - let inner = PublicResolver::new(http.clone(), opts).with_system_dns(); + let inner = PublicResolver::new(identity.clone(), opts); Ok(EnvResolver { http, + identity, inner, - handle_url: std::env::var("WISP_HANDLE_RESOLVER_URL").ok(), + handle_url: std::env::var("WISP_HANDLE_RESOLVER_URL") + .unwrap_or_else(|_| HANDLE_RESOLVER.to_owned()), }) } +fn allow_localhost() -> bool { + std::env::var("WISP_ALLOW_LOCALHOST_FETCH").as_deref() == Ok("1") +} + impl HttpClient for EnvResolver { type Error = reqwest::Error; async fn send_http( @@ -56,41 +75,48 @@ impl IdentityResolver for EnvResolver { &self, handle: &Handle, ) -> jacquard::identity::resolver::Result { - if let Some(url) = &self.handle_url { - let response = self - .http - .get(url) - .query(&[("handle", handle.as_str())]) - .send() - .await - .map_err(|err| { - IdentityError::transport("handle resolver request failed".into(), err) - })? - .error_for_status() - .map_err(|err| { - IdentityError::transport("handle resolver rejected request".into(), err) - })?; - #[derive(serde::Deserialize)] - struct Answer { - did: Did, - } - return serde_json::from_slice::(&bounded_body(response).await?) - .map(|answer| answer.did) - .map_err(|err| { - IdentityError::transport("invalid handle resolver response".into(), err) - }); + let response = self + .identity + .get(&self.handle_url) + .query(&[("handle", handle.as_str())]) + .send() + .await + .map_err(|err| IdentityError::transport("handle resolver request failed".into(), err))? + .error_for_status() + .map_err(|err| { + IdentityError::transport("handle resolver rejected request".into(), err) + })?; + #[derive(serde::Deserialize)] + struct Answer { + did: Did, + } + let did = serde_json::from_slice::(&bounded_body(response).await?) + .map(|answer| answer.did) + .map_err(|err| { + IdentityError::transport("invalid handle resolver response".into(), err) + })?; + // Bidirectional check, as the TS CLI did: the DID must claim the handle. + let doc = self.resolve_did_doc(&did).await?.into_owned()?; + if doc + .handles() + .iter() + .any(|claimed| claimed.as_str().eq_ignore_ascii_case(handle.as_str())) + { + Ok(did) + } else { + Err(IdentityError::handle_resolution_exhausted() + .with_context("the DID document does not list this handle")) } - self.inner.resolve_handle(handle).await } async fn resolve_did_doc( &self, did: &Did, ) -> jacquard::identity::resolver::Result { - if std::env::var("WISP_ALLOW_LOCALHOST_FETCH").as_deref() == Ok("1") + if allow_localhost() && let Some(url) = local_web_url(did.as_str()) { let response = - self.http.get(url).send().await.map_err(|err| { + self.identity.get(url).send().await.map_err(|err| { IdentityError::transport("local DID request failed".into(), err) })?; let status = response.status(); @@ -109,28 +135,29 @@ impl IdentityResolver for EnvResolver { impl OAuthResolver for EnvResolver {} impl DpopExt for EnvResolver {} +/// Handle or DID → (DID, PDS endpoint), failing with the TS CLI's messages. pub async fn resolve_identity(identifier: &str) -> Result<(String, String)> { let resolver = resolver()?; let did = if identifier.starts_with("did:") { Did::new(identifier)?.into_static() } else { + let handle = Handle::new(normalize_handle(identifier)) + .map_err(|_| anyhow!("Failed to resolve handle"))?; resolver - .resolve_handle(&Handle::new(normalize_handle(identifier))?) - .await? + .resolve_handle(&handle) + .await + .map_err(|_| anyhow!("Failed to resolve handle"))? + }; + let doc = match resolver.resolve_did_doc(&did).await { + Ok(response) => Some(response.into_owned()?), + Err(_) => None, }; - let doc = resolver.resolve_did_doc(&did).await?.into_owned()?; let pds = doc - .pds_endpoint() - .context("DID document has no PDS endpoint")?; - let url = reqwest::Url::parse(pds.as_str())?; - if url.scheme() != "https" - && !(url.scheme() == "http" - && is_loopback(&url) - && std::env::var("WISP_ALLOW_LOCALHOST_FETCH").as_deref() == Ok("1")) - { - bail!("HTTP PDS requires a loopback host and WISP_ALLOW_LOCALHOST_FETCH=1"); - } - Ok((did.to_string(), pds.to_string())) + .as_ref() + .and_then(|doc| doc.pds_endpoint()) + .and_then(|pds| validate_pds_endpoint(pds.as_str(), allow_localhost())) + .context("Could not find a valid PDS endpoint")?; + Ok((did.to_string(), pds)) } pub async fn resolve_identifier_to_did( @@ -209,22 +236,13 @@ pub async fn verified_handle(did: &str) -> Result> { Ok(None) } -fn is_loopback(url: &reqwest::Url) -> bool { - url.host_str().is_some_and(|host| { - host == "localhost" - || host - .trim_matches(['[', ']']) - .parse::() - .is_ok_and(|ip| ip.is_loopback()) - }) -} fn local_web_url(did: &str) -> Option { let authority = did .strip_prefix("did:web:")? .replace("%3A", ":") .replace("%3a", ":"); let url = reqwest::Url::parse(&format!("http://{authority}/.well-known/did.json")).ok()?; - is_loopback(&url).then_some(url) + is_loopback_host(&url).then_some(url) } #[allow( diff --git a/cli-rs/crates/wispctl/src/auth/store.rs b/cli-rs/crates/wispctl/src/auth/store.rs index b676ba3..cc08e82 100644 --- a/cli-rs/crates/wispctl/src/auth/store.rs +++ b/cli-rs/crates/wispctl/src/auth/store.rs @@ -58,6 +58,29 @@ fn deserialize_scope<'de, D: serde::Deserializer<'de>>( OAuthScopeStrategy::deserialize(deserializer).map(Some) } +/// Timestamps are JS `Date.now()` values: write whole milliseconds as integers, +/// as the TS CLI does, rather than `1735689600000.0`. +fn millis( + value: &f64, + serializer: S, +) -> std::result::Result { + if value.fract() == 0.0 && value.abs() < 9.007_199_254_740_992e15 { + serializer.serialize_i64(*value as i64) + } else { + serializer.serialize_f64(*value) + } +} + +fn millis_opt( + value: &Option, + serializer: S, +) -> std::result::Result { + match value { + Some(value) => millis(value, serializer), + None => serializer.serialize_none(), + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StoredAccount { @@ -67,9 +90,11 @@ pub struct StoredAccount { pub method: AuthMethod, #[serde(skip_serializing_if = "Option::is_none")] pub pds_url: Option, + #[serde(serialize_with = "millis")] pub added_at: f64, + #[serde(serialize_with = "millis")] pub last_used_at: f64, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(serialize_with = "millis_opt", skip_serializing_if = "Option::is_none")] pub handle_checked_at: Option, #[serde( default, diff --git a/cli-rs/crates/wispctl/src/cli.rs b/cli-rs/crates/wispctl/src/cli.rs index 2a8e8e4..45fea0b 100644 --- a/cli-rs/crates/wispctl/src/cli.rs +++ b/cli-rs/crates/wispctl/src/cli.rs @@ -10,9 +10,17 @@ use clap::{Args, Parser, Subcommand}; name = "wispctl", version, about = "CLI for wisp.place - deploy static sites to the AT Protocol", - subcommand_negates_reqs = true + subcommand_negates_reqs = true, + // commander keeps the last of a repeated option instead of rejecting it. + args_override_self = true, + // `--version` prints the bare version, as commander did. + disable_version_flag = true )] pub struct Cli { + /// Output the version number + #[arg(short = 'V', long)] + pub version: bool, + /// Suppress progress output — useful for CI/agents (also set via WISPCTL_NO_PROGRESS=1) #[arg(short, long, global = true)] pub quiet: bool, @@ -61,7 +69,7 @@ pub struct DbArg { #[derive(Args, Debug, Clone, Default)] pub struct XrpcOptions { /// App password for headless authentication - #[arg(long, value_name = "password")] + #[arg(long, value_name = "password", allow_hyphen_values = true)] pub password: Option, #[command(flatten)] pub db: DbArg, @@ -80,7 +88,7 @@ pub struct DeployArgs { /// Directory to deploy #[arg(short, long, value_name = "path")] pub path: Option, - /// Site name (defaults to directory name) + /// Site name (prompted for when omitted) #[arg(short, long, value_name = "name")] pub site: Option, /// Enable directory listing @@ -96,7 +104,7 @@ pub struct DeployArgs { #[arg(long)] pub force_gzip: bool, /// App password for headless authentication - #[arg(long, value_name = "password")] + #[arg(long, value_name = "password", allow_hyphen_values = true)] pub password: Option, #[command(flatten)] pub db: DbArg, @@ -335,7 +343,7 @@ pub struct LoginArgs { #[command(flatten)] pub db: DbArg, /// Log in with an app password instead of OAuth (or set WISPCTL_APP_PASSWORD) - #[arg(long, value_name = "password")] + #[arg(long, value_name = "password", allow_hyphen_values = true)] pub password: Option, } diff --git a/cli-rs/crates/wispctl/src/commands/deploy.rs b/cli-rs/crates/wispctl/src/commands/deploy.rs index ba16e58..25fd761 100644 --- a/cli-rs/crates/wispctl/src/commands/deploy.rs +++ b/cli-rs/crates/wispctl/src/commands/deploy.rs @@ -31,7 +31,7 @@ fn valid_site(site: &str) -> bool { pub async fn run(mut args: DeployArgs) -> Result<()> { let known = if args.handle.is_none() && args.password.is_none() { - auth::resolve_account_for_cwd(args.db.db.as_deref()).await? + auth::resolve_account_for_cwd(args.db.db.as_deref()).await } else { None }; @@ -129,7 +129,7 @@ pub async fn run(mut args: DeployArgs) -> Result<()> { } for file in files.iter().filter(|file| file.size > MAX_FILE_SIZE) { wisp_ui::warning(format!( - "{} exceeds max size ({} > {})", + "Warning: {} exceeds max size ({} > {})", file.relative_path, wisp_ui::format_bytes(file.size), wisp_ui::format_bytes(MAX_FILE_SIZE) @@ -158,11 +158,14 @@ pub async fn run(mut args: DeployArgs) -> Result<()> { ) .await?; let spinner = wisp_ui::spinner("Creating manifest..."); - let writes = match put_manifest(&agent, &did, &site, &uploads).await { + // Every subfs record any attempt wrote, so a retried attempt's leftovers + // are cleaned up with the old site's. + let mut written = BTreeSet::new(); + let writes = match put_manifest(&agent, &did, &site, &uploads, &mut written).await { Ok(writes) => writes, Err(error) if repo::http_status(&error) == Some(500) => { wisp_ui::warning( - "Manifest put failed with 500, retrying with base64 encoding for text files...", + "[Deploy] Manifest put failed with 500, retrying with base64 encoding for text files...", ); let text_files: Vec<_> = files .iter() @@ -188,17 +191,16 @@ pub async fn run(mut args: DeployArgs) -> Result<()> { *upload = replacement.clone(); } } - put_manifest(&agent, &did, &site, &uploads).await? + put_manifest(&agent, &did, &site, &uploads, &mut written).await? } Err(error) => return Err(error), }; spinner.succeed("Created manifest record".to_owned()); - if let Some(existing) = existing { - for (owner, key) in existing.subfs { - if owner == did && !writes.contains(&key) { - // Best effort, like the old CLI: a leftover record is harmless. - let _ = agent.delete::(&key).await; - } + let previous = existing.into_iter().flat_map(|site| site.subfs); + for key in previous.chain(written).collect::>() { + if !writes.contains(&key) { + // Best effort, like the old CLI: a leftover record is harmless. + let _ = agent.delete::(&key).await; } } if args.directory || args.spa { @@ -247,6 +249,7 @@ async fn put_manifest( did: &str, site: &str, uploads: &[(String, UploadResult)], + written: &mut BTreeSet, ) -> Result> { let root = build_tree(uploads); // Millisecond precision, like JavaScript's toISOString in the old CLI. @@ -266,17 +269,18 @@ async fn put_manifest( ) { completed_messages.push(message); } else { - wisp_ui::info(split_message(message)); + wisp_ui::note(s::muted(split_message(message))); } } let mut keys = BTreeSet::new(); for record in plan.records { repo.put(&record.rkey, subfs_record(&record, &created_at)) .await?; + written.insert(record.rkey.clone()); keys.insert(record.rkey); } for message in completed_messages { - wisp_ui::info(split_message(message)); + wisp_ui::note(s::muted(split_message(message))); } if let Some(spinner) = splitting { spinner.succeed(format!("Created {} subfs records", keys.len())); @@ -318,7 +322,8 @@ fn split_message(message: wisp_core::split::SplitMessage) -> String { struct ExistingSite { blobs: BTreeMap, - subfs: BTreeSet<(String, String)>, + /// Keys of this repo's subfs records the site references. + subfs: BTreeSet, } async fn fetch_existing(repo: &impl SiteRepo, did: &str, site: &str) -> Option { @@ -341,8 +346,13 @@ async fn fetch_existing(repo: &impl SiteRepo, did: &str, site: &str) -> Option Option(&owner, &key).await else { + let Ok(record) = repo.fetch::(did, &key).await else { continue; }; let root = subfs_to_fs(&record.root); diff --git a/cli-rs/crates/wispctl/src/commands/domain.rs b/cli-rs/crates/wispctl/src/commands/domain.rs index d224170..9668ef2 100644 --- a/cli-rs/crates/wispctl/src/commands/domain.rs +++ b/cli-rs/crates/wispctl/src/commands/domain.rs @@ -164,7 +164,7 @@ pub async fn run(args: DomainArgs) -> Result<()> { _ => xrpc::call::(&agent, &service, input).await?, }; let domain = text(&data, "domain"); - if action == "verify" && data["verified"] == false { + if action == "verify" && data["verified"] != true { let error = data["error"] .as_str() .filter(|s| !s.is_empty()) diff --git a/cli-rs/crates/wispctl/src/main.rs b/cli-rs/crates/wispctl/src/main.rs index d894799..2acae5d 100644 --- a/cli-rs/crates/wispctl/src/main.rs +++ b/cli-rs/crates/wispctl/src/main.rs @@ -10,14 +10,28 @@ mod xrpc; use std::future::pending; -use clap::Parser; +use clap::Parser as _; use crate::cli::{Cli, Command}; fn main() { - let cli = Cli::parse(); + let cli = match Cli::try_parse() { + Ok(cli) => cli, + // Help goes to stdout with 0; usage errors exit 1 like commander, not clap's 2. + Err(err) if err.use_stderr() => { + let _ = err.print(); + std::process::exit(1); + } + Err(err) => err.exit(), + }; + let roots = tls::fallback_roots(); tls::install_crypto_provider(); let guard = wisp_ui::init(wisp_ui::Options { quiet: cli.quiet }); + if cli.version { + wisp_ui::out_text(env!("CARGO_PKG_VERSION")); + drop(guard); + std::process::exit(0); + } let runtime = match tokio::runtime::Runtime::new() { Ok(runtime) => runtime, @@ -56,5 +70,6 @@ fn main() { drop(guard); // Long-lived tasks (serve's server and firehose) must not keep us alive. runtime.shutdown_background(); + drop(roots); std::process::exit(code); } diff --git a/cli-rs/crates/wispctl/src/serve/http.rs b/cli-rs/crates/wispctl/src/serve/http.rs index e907d76..a8d6abe 100644 --- a/cli-rs/crates/wispctl/src/serve/http.rs +++ b/cli-rs/crates/wispctl/src/serve/http.rs @@ -209,9 +209,7 @@ pub async fn respond( builder = builder .header( "Content-Type", - mime_guess::from_path(&path) - .first_or_octet_stream() - .as_ref(), + wisp_core::blob::mime_for(&resolved.to_string_lossy()), ) .header("Content-Length", info.len()) .header("Cache-Control", "no-cache"); diff --git a/cli-rs/crates/wispctl/src/tls.rs b/cli-rs/crates/wispctl/src/tls.rs index e24b4ef..7485f21 100644 --- a/cli-rs/crates/wispctl/src/tls.rs +++ b/cli-rs/crates/wispctl/src/tls.rs @@ -13,3 +13,66 @@ pub fn install_crypto_provider() { let _ = rustls::crypto::ring::default_provider().install_default(); }); } + +/// Make the Bun-built CLI's trust store available on Linux, where every TLS +/// stack here (reqwest's platform verifier and the firehose websocket) reads +/// roots through `SSL_CERT_FILE`: +/// +/// - CI images such as debian-slim, busybox and nixery often ship no CA +/// bundle; Bun carried Mozilla's roots, so fall back to the same set. +/// - Bun honoured `NODE_EXTRA_CA_CERTS` (corporate CAs in CI); add it on top. +/// +/// Returns the written bundle, which must outlive every TLS connection. Must +/// run before any other thread exists: it sets an environment variable. +pub fn fallback_roots() -> Option { + #[cfg(target_os = "linux")] + { + use rustls::pki_types::{CertificateDer, pem::PemObject}; + + if std::env::var_os("SSL_CERT_FILE").is_some() || std::env::var_os("SSL_CERT_DIR").is_some() + { + return None; + } + let extra: Vec> = std::env::var_os("NODE_EXTRA_CA_CERTS") + .and_then(|path| CertificateDer::pem_file_iter(path).ok()) + .map(|certs| certs.filter_map(Result::ok).collect()) + .unwrap_or_default(); + let mut roots = rustls_native_certs::load_native_certs().certs; + if roots.is_empty() { + roots = webpki_root_certs::TLS_SERVER_ROOT_CERTS.to_vec(); + } else if extra.is_empty() { + return None; + } + roots.extend(extra); + let path = write_bundle(&roots).ok()?; + // SAFETY: called from main before the runtime or any other thread starts. + unsafe { std::env::set_var("SSL_CERT_FILE", &path) }; + Some(path) + } + #[cfg(not(target_os = "linux"))] + None +} + +#[cfg(target_os = "linux")] +fn write_bundle( + certs: &[rustls::pki_types::CertificateDer<'_>], +) -> std::io::Result { + use base64::Engine; + use std::io::Write; + + let mut file = tempfile::Builder::new() + .prefix("wispctl-roots-") + .suffix(".pem") + .tempfile()?; + for cert in certs { + let encoded = base64::engine::general_purpose::STANDARD.encode(cert.as_ref()); + writeln!(file, "-----BEGIN CERTIFICATE-----")?; + for line in encoded.as_bytes().chunks(64) { + file.write_all(line)?; + writeln!(file)?; + } + writeln!(file, "-----END CERTIFICATE-----")?; + } + file.flush()?; + Ok(file.into_temp_path()) +} diff --git a/cli-rs/crates/wispctl/src/xrpc.rs b/cli-rs/crates/wispctl/src/xrpc.rs index 2d21948..7ee6bdd 100644 --- a/cli-rs/crates/wispctl/src/xrpc.rs +++ b/cli-rs/crates/wispctl/src/xrpc.rs @@ -71,7 +71,7 @@ pub async fn authenticate_for_xrpc( ); } let known = if handle.is_none() && options.password.is_none() { - auth::resolve_account_for_cwd(options.db.db.as_deref()).await? + auth::resolve_account_for_cwd(options.db.db.as_deref()).await } else { None }; diff --git a/cli-rs/scripts/mime-table.ts b/cli-rs/scripts/mime-table.ts new file mode 100755 index 0000000..7d82607 --- /dev/null +++ b/cli-rs/scripts/mime-table.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env bun +// Regenerate crates/wisp-core/src/mime_table.rs: the extension -> type map +// that the TypeScript CLI's `mime-types` (3.0.2) `lookup` used, so deployed +// files keep their Content-Type and gzip decision. +// +// usage (from the repo root): bun cli-rs/scripts/mime-table.ts +// MIME_TYPES_DIR overrides where mime-types is loaded from. +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + +const dir = process.env.MIME_TYPES_DIR ?? join(import.meta.dir, '../../cli/node_modules/mime-types') +const { types } = await import(join(dir, 'index.js')) +const { version } = await import(join(dir, 'package.json')) + +const rows = Object.entries(types as Record) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([ext, type]) => ` (${JSON.stringify(ext)}, ${JSON.stringify(type)}),`) + +const out = `// @generated by cli-rs/scripts/mime-table.ts from mime-types ${version}. Do not edit. +/// Extension (lowercase, no dot) to MIME type, sorted by extension. +pub static TYPES: &[(&str, &str)] = &[ +${rows.join('\n')} +]; +` +writeFileSync(join(import.meta.dir, '../crates/wisp-core/src/mime_table.rs'), out) +console.log(`${rows.length} extensions from mime-types ${version}`)