mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-08 21:10:45 +00:00
CP4.1: review — packs hold only the contract's files and say which locale they are; every zstd frame is read; a corrupt record is rebuilt
- Every zstd frame is decoded; anything but tar padding after the archive is refused. - pack.toml must name the index entry's locale; the files the index promises (spelling, hyphenation) must be in the archive. - Allow-list: pack.toml, spelling.aff, spelling.dic, hyphenation.pat at the root, LICENCES/<[A-Za-z0-9._-]> without a leading or trailing dot; Windows device names (con, prn, aux, nul, com1-9, lpt1-9, any extension) refused in names and locales. - Pack names lose control and bidi/format (Cf) characters before they are stored or listed. - An unreadable language-packs.json is kept as .bad and rebuilt from the pack folders (each pack.toml); no pack drops out. - A bad hand-made entry is Error::Index(IndexError::Invalid), not BadUrl; index parse errors are Index errors too. - Dead file:// handling removed. - language_pack_index_overridden() exposes the developer override; README says it bypasses the pin and works in release builds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1704bef581
commit
d342a50635
Generated
+3
@@ -845,6 +845,7 @@ dependencies = [
|
||||
"skrifa 0.47.0",
|
||||
"tar",
|
||||
"thiserror 2.0.21",
|
||||
"toml",
|
||||
"ureq",
|
||||
"write-fonts 0.54.1",
|
||||
]
|
||||
@@ -5568,10 +5569,12 @@ version = "1.1.8+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ craft-font-catalog = { workspace = true }
|
||||
log = "0.4"
|
||||
# Language packs (`language_packs`): `<locale>.tar.zst` archives, unpacked in pure Rust.
|
||||
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
|
||||
toml = { version = "1", default-features = false, features = ["parse", "serde", "std"] }
|
||||
tar = { version = "0.4", default-features = false }
|
||||
# Blocking HTTPS with rustls and webpki roots: no async runtime, no OpenSSL.
|
||||
ureq = { version = "3", default-features = false, features = ["rustls", "gzip"] }
|
||||
|
||||
@@ -138,9 +138,9 @@ let installed = store.installed_language_packs();
|
||||
| `installed_language_packs()` | The recorded packs whose folder is there. |
|
||||
| `language_packs_dir()` | `<store>/language-packs`, for `craft_proof::packs::set_packs_dir`. |
|
||||
|
||||
**Unpacking is hostile-input safe:** pure-Rust zstd (`ruzstd`, MIT) and `tar` (MIT OR Apache-2.0); regular files and folders only (no links or devices), plain relative paths at most two levels deep (`LICENCES/<file>`), no `..`, roots, drives or backslashes, each file at most 64 MiB, 256 MiB and 256 entries per pack, `pack.toml` required. Index entries need a lowercase BCP 47 locale (listed once), a sha256 and an `https://` URL.
|
||||
**Unpacking is hostile-input safe:** pure-Rust zstd (`ruzstd`, MIT; every frame is read, and nothing but tar padding may follow the archive) and `tar` (MIT OR Apache-2.0); regular files and the `LICENCES` folder only (no links or devices). Only the contract's names are allowed: `pack.toml`, `spelling.aff`, `spelling.dic`, `hyphenation.pat` at the root and `LICENCES/<name>` (`[A-Za-z0-9._-]`, no leading or trailing dot, no Windows device names such as `CON` or `nul.txt`). Each file at most 64 MiB, 256 MiB and 256 entries per pack. `pack.toml` is required and must name the entry's locale; the files the index promises (`spelling`, `hyphenation`) must be there. Index entries need a lowercase BCP 47 locale (listed once, not a Windows device name), a sha256 and an `https://` URL; pack names lose control and bidi/format characters before they are stored or listed. An unreadable `language-packs.json` is kept as `language-packs.json.bad` and rebuilt from the pack folders (each `pack.toml`), so no installed pack drops out of the list.
|
||||
|
||||
**Developer override (testing only):** `CRAFT_LANGUAGE_PACK_INDEX=<url or path>` replaces the pinned index URL with an `http://` URL on a loopback host (`127.0.0.1`, `localhost`, `[::1]`), an `https://` URL or a local file path, and **skips the index's sha256 pin**. Archives are still checked against the sha256 and size in that index, and may then also be loopback `http://` URLs or local paths. Off unless the variable is set; a warning is logged whenever it is used. Never set it in a release.
|
||||
**Developer override (testing only — bypasses the pin):** `CRAFT_LANGUAGE_PACK_INDEX=<url or path>` replaces the pinned index URL with an `http://` URL on a loopback host (`127.0.0.1`, `localhost`, `[::1]`), an `https://` URL or a local file path (`file://` URLs are not taken), and **skips the index's sha256 pin**. Archives are still checked against the sha256 and size in that index, and may then also be loopback `http://` URLs or local paths. Off unless the variable is set; it works in release builds too (for end-to-end tests of a real build), a warning is logged whenever it is used, and `language_pack_index_overridden() -> Option<String>` returns the value so apps can show that the pin is bypassed. Never set it for users.
|
||||
|
||||
## Web
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::http::{Cancel, Client, Timeouts};
|
||||
use crate::index::IndexError;
|
||||
use crate::store::{Error, FontStore, INDEX_CACHE, Progress, Stage, io, read_limited};
|
||||
use crate::verify::{VerifyError, sha256_hex};
|
||||
|
||||
@@ -132,8 +133,8 @@ impl PackIndex {
|
||||
}
|
||||
|
||||
fn parse(bytes: &[u8], local: bool) -> Result<Self, Error> {
|
||||
let bad = |why: String| Error::Io { what: "reading the Language pack index".into(), source: std::io::Error::other(why) };
|
||||
let index: PackIndex = serde_json::from_slice(bytes).map_err(|e| bad(e.to_string()))?;
|
||||
let bad = |why: String| Error::Index(IndexError::Invalid(format!("Language pack index: {why}")));
|
||||
let mut index: PackIndex = serde_json::from_slice(bytes).map_err(|e| Error::Index(IndexError::Json(format!("Language pack index: {e}"))))?;
|
||||
if index.format != 1 {
|
||||
return Err(bad(format!("format {} is not supported", index.format)));
|
||||
}
|
||||
@@ -151,6 +152,9 @@ impl PackIndex {
|
||||
return Err(bad(format!("pack {}: {} is not an https URL", p.locale, p.url.chars().take(200).collect::<String>())));
|
||||
}
|
||||
}
|
||||
for p in &mut index.packs {
|
||||
p.name = plain_text(&p.name);
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
@@ -172,7 +176,7 @@ pub(crate) enum IndexSource {
|
||||
impl IndexSource {
|
||||
/// The pin, unless [`LANGUAGE_PACK_INDEX_ENV`] is set.
|
||||
fn from_env(pin: &PackIndexPin) -> Self {
|
||||
match std::env::var(LANGUAGE_PACK_INDEX_ENV).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) {
|
||||
match language_pack_index_overridden() {
|
||||
Some(v) => {
|
||||
log::warn!(
|
||||
"{LANGUAGE_PACK_INDEX_ENV}={v}: the Language pack index is NOT the pinned one and its sha256 is not checked (testing only)"
|
||||
@@ -188,6 +192,14 @@ impl IndexSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// The developer override of the pack index, when [`LANGUAGE_PACK_INDEX_ENV`] is set: the URL or
|
||||
/// path that replaces the pinned index, whose sha256 is then **not** checked. Apps show a warning
|
||||
/// in their Language pack interface while it is set. It works in release builds too, for
|
||||
/// end-to-end testing against a local index.
|
||||
pub fn language_pack_index_overridden() -> Option<String> {
|
||||
std::env::var(LANGUAGE_PACK_INDEX_ENV).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// A source the developer override may read: `Ok(url)` for an `https://` URL or an `http://`
|
||||
/// one on a loopback host, `Err(path)` for a local file path; `None` for anything else.
|
||||
fn local_source(s: &str) -> Option<Result<&str, PathBuf>> {
|
||||
@@ -207,14 +219,14 @@ fn local_source(s: &str) -> Option<Result<&str, PathBuf>> {
|
||||
if s.contains("://") {
|
||||
return None;
|
||||
}
|
||||
let path = s.strip_prefix("file://").unwrap_or(s);
|
||||
Some(Err(PathBuf::from(path)))
|
||||
Some(Err(PathBuf::from(s)))
|
||||
}
|
||||
|
||||
/// A lowercase BCP 47 tag as packs are named (craft-proof's rule): ASCII lowercase letters and
|
||||
/// digits in parts of 1 to 8 joined by `-`, at most 35 characters.
|
||||
fn is_locale(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
!windows_reserved(s)
|
||||
&& !s.is_empty()
|
||||
&& s.len() <= 35
|
||||
&& s.split('-').all(|p| !p.is_empty() && p.len() <= 8 && p.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()))
|
||||
}
|
||||
@@ -307,7 +319,7 @@ impl FontStore {
|
||||
cancel: &Cancel,
|
||||
progress: &mut dyn FnMut(Progress),
|
||||
) -> Result<InstalledLanguagePack, Error> {
|
||||
let local = std::env::var(LANGUAGE_PACK_INDEX_ENV).is_ok_and(|v| !v.trim().is_empty());
|
||||
let local = language_pack_index_overridden().is_some();
|
||||
self.install_pack_from(local, index, pack, cancel, progress)
|
||||
}
|
||||
|
||||
@@ -322,7 +334,8 @@ impl FontStore {
|
||||
self.require_network()?;
|
||||
// The entry is checked again here: callers may build one by hand.
|
||||
if !is_locale(&pack.locale) || !is_sha256(&pack.sha256) || pack.size > MAX_PACK_ARCHIVE_BYTES {
|
||||
return Err(Error::BadUrl(pack.locale.chars().take(40).collect()));
|
||||
let locale: String = pack.locale.chars().take(40).collect();
|
||||
return Err(Error::Index(IndexError::Invalid(format!("Language pack {locale:?}: bad locale, sha256 or size"))));
|
||||
}
|
||||
let what = format!("{}.tar.zst", pack.locale);
|
||||
let (size, sha) = (pack.size, pack.sha256.clone());
|
||||
@@ -349,10 +362,10 @@ impl FontStore {
|
||||
self.sweep_staging();
|
||||
let stage = Stage::new(self.dir())?;
|
||||
let unpacked = stage.dir.join(&pack.locale);
|
||||
unpack(&bytes, &unpacked).map_err(|e| Error::Io { what: format!("unpacking {what}"), source: std::io::Error::other(e) })?;
|
||||
unpack(&bytes, &unpacked, pack).map_err(|e| Error::Io { what: format!("unpacking {what}"), source: std::io::Error::other(e) })?;
|
||||
let record = InstalledLanguagePack {
|
||||
locale: pack.locale.clone(),
|
||||
name: pack.name.chars().take(200).collect(),
|
||||
name: plain_text(&pack.name),
|
||||
release: index.release.chars().take(200).collect(),
|
||||
version: pack.version.chars().take(200).collect(),
|
||||
licence: pack.licence.chars().take(200).collect(),
|
||||
@@ -374,7 +387,7 @@ impl FontStore {
|
||||
}
|
||||
return Err(io(format!("installing the {} pack", pack.locale))(e));
|
||||
}
|
||||
let mut installed = self.read_installed();
|
||||
let mut installed = self.read_installed()?;
|
||||
installed.packs.retain(|p| p.locale != record.locale);
|
||||
installed.packs.push(record.clone());
|
||||
installed.packs.sort_by(|a, b| a.locale.cmp(&b.locale));
|
||||
@@ -392,7 +405,13 @@ impl FontStore {
|
||||
/// The installed Language packs whose folder is there, by locale.
|
||||
pub fn installed_language_packs(&self) -> Vec<InstalledLanguagePack> {
|
||||
let dir = self.language_packs_dir();
|
||||
self.read_installed().packs.into_iter().filter(|p| is_locale(&p.locale) && dir.join(&p.locale).is_dir()).collect()
|
||||
let installed = self.read_installed().unwrap_or_else(|_| self.installed_from_disk());
|
||||
installed
|
||||
.packs
|
||||
.into_iter()
|
||||
.filter(|p| is_locale(&p.locale) && dir.join(&p.locale).is_dir())
|
||||
.map(|p| InstalledLanguagePack { name: plain_text(&p.name), ..p })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Removes the pack for `locale`: its folder disappears at once (one rename) and is deleted
|
||||
@@ -404,7 +423,7 @@ impl FontStore {
|
||||
let stage = Stage::new(self.dir())?;
|
||||
let _lock = self.lock()?;
|
||||
let dest = self.language_packs_dir().join(locale);
|
||||
let mut installed = self.read_installed();
|
||||
let mut installed = self.read_installed()?;
|
||||
let known = installed.packs.iter().any(|p| p.locale == locale);
|
||||
if !known && std::fs::symlink_metadata(&dest).is_err() {
|
||||
return Err(Error::NotInstalled(locale.to_string()));
|
||||
@@ -417,8 +436,52 @@ impl FontStore {
|
||||
// `stage` deletes the old folder when dropped.
|
||||
}
|
||||
|
||||
fn read_installed(&self) -> Installed {
|
||||
read_limited(&self.dir().join(INSTALLED), MAX_PACK_INDEX_BYTES).and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
||||
/// The record of installed packs (none yet: empty). One that can't be read is kept aside as
|
||||
/// `language-packs.json.bad` and rebuilt from the pack folders on disk, so no installed pack
|
||||
/// is ever dropped from it.
|
||||
fn read_installed(&self) -> Result<Installed, Error> {
|
||||
let path = self.dir().join(INSTALLED);
|
||||
if std::fs::symlink_metadata(&path).is_err() {
|
||||
return Ok(Installed::default());
|
||||
}
|
||||
if let Some(i) = read_limited(&path, MAX_PACK_INDEX_BYTES).and_then(|b| serde_json::from_slice::<Installed>(&b).ok()) {
|
||||
return Ok(i);
|
||||
}
|
||||
log::warn!("{}: can't be read; rebuilt from the Language pack folders", path.display());
|
||||
let rebuilt = self.installed_from_disk();
|
||||
let _ = std::fs::rename(&path, self.dir().join(format!("{INSTALLED}.bad")));
|
||||
self.write_installed(&rebuilt)?;
|
||||
Ok(rebuilt)
|
||||
}
|
||||
|
||||
/// The installed packs as their folders say: each folder named by a locale, with the name
|
||||
/// and licence from its `pack.toml` (release, version and sha256 unknown).
|
||||
fn installed_from_disk(&self) -> Installed {
|
||||
let mut packs: Vec<InstalledLanguagePack> = std::fs::read_dir(self.language_packs_dir())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.filter(|l| is_locale(l))
|
||||
.take(MAX_PACKS)
|
||||
.map(|locale| {
|
||||
let info = read_limited(&self.language_packs_dir().join(&locale).join("pack.toml"), MAX_PACK_TOML_BYTES)
|
||||
.and_then(|b| String::from_utf8(b).ok())
|
||||
.and_then(|t| t.parse::<toml::Table>().ok());
|
||||
let field = |k: &str| info.as_ref().and_then(|t| t.get(k)).and_then(toml::Value::as_str).map(plain_text);
|
||||
InstalledLanguagePack {
|
||||
name: field("name").unwrap_or_else(|| locale.clone()),
|
||||
licence: field("licence").unwrap_or_default(),
|
||||
locale,
|
||||
release: String::new(),
|
||||
version: String::new(),
|
||||
sha256: String::new(),
|
||||
size: 0,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
packs.sort_by(|a, b| a.locale.cmp(&b.locale));
|
||||
Installed { version: 1, packs }
|
||||
}
|
||||
|
||||
fn write_installed(&self, installed: &Installed) -> Result<(), Error> {
|
||||
@@ -428,18 +491,46 @@ impl FontStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unpacks a `.tar.zst` pack into `dest` (created): regular files and folders only, at most two
|
||||
/// levels deep (`LICENCES/<file>`), every path plain and relative, each file at most
|
||||
/// [`MAX_PACK_FILE_BYTES`], all of them [`MAX_PACK_UNPACKED_BYTES`], at most
|
||||
/// [`MAX_PACK_ENTRIES`]; `pack.toml` required.
|
||||
pub(crate) fn unpack(archive: &[u8], dest: &Path) -> Result<(), String> {
|
||||
let zstd = ruzstd::decoding::StreamingDecoder::new(archive).map_err(|e| format!("not zstd: {e}"))?;
|
||||
/// The zstd frames of `archive` one after another, as one stream (`zstd` writes one frame, but
|
||||
/// a concatenation of frames is a valid `.zst` too, and must not stop after the first).
|
||||
struct Frames<'a> {
|
||||
decoder: Option<ruzstd::decoding::StreamingDecoder<&'a [u8], ruzstd::decoding::FrameDecoder>>,
|
||||
}
|
||||
|
||||
impl<'a> Frames<'a> {
|
||||
fn new(archive: &'a [u8]) -> Result<Self, String> {
|
||||
Ok(Self { decoder: Some(ruzstd::decoding::StreamingDecoder::new(archive).map_err(|e| format!("not zstd: {e}"))?) })
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for Frames<'_> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
|
||||
loop {
|
||||
let Some(d) = self.decoder.as_mut() else { return Ok(0) };
|
||||
let n = d.read(buf)?;
|
||||
if n > 0 || buf.is_empty() {
|
||||
return Ok(n);
|
||||
}
|
||||
let rest: &[u8] = d.get_ref();
|
||||
self.decoder = if rest.is_empty() { None } else { Some(ruzstd::decoding::StreamingDecoder::new(rest).map_err(std::io::Error::other)?) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unpacks the `.tar.zst` of `pack` into `dest` (created). Only the contract's files are allowed:
|
||||
/// `pack.toml`, `spelling.aff`, `spelling.dic`, `hyphenation.pat` at the root and
|
||||
/// `LICENCES/<name>` ([`licence_name`]); regular files and that folder only, each file at most
|
||||
/// [`MAX_PACK_FILE_BYTES`], all of them [`MAX_PACK_UNPACKED_BYTES`], at most [`MAX_PACK_ENTRIES`].
|
||||
/// Every zstd frame is read, and nothing but tar padding may follow the archive's end. `pack.toml`
|
||||
/// must name `pack`'s locale, and the files the index promises (`spelling`, `hyphenation`) must
|
||||
/// be there.
|
||||
pub(crate) fn unpack(archive: &[u8], dest: &Path, pack: &PackEntry) -> Result<(), String> {
|
||||
// The decompressed stream is capped too: tar padding and headers beyond the files' bytes.
|
||||
let limited = zstd.take(MAX_PACK_UNPACKED_BYTES.saturating_add((MAX_PACK_ENTRIES as u64 + 2) * 1024));
|
||||
let mut tar = tar::Archive::new(limited);
|
||||
let cap = MAX_PACK_UNPACKED_BYTES.saturating_add((MAX_PACK_ENTRIES as u64 + 2) * 1024 + 20 * 512);
|
||||
let mut tar = tar::Archive::new(Frames::new(archive)?.take(cap));
|
||||
std::fs::create_dir_all(dest).map_err(|e| e.to_string())?;
|
||||
let (mut total, mut count) = (0u64, 0usize);
|
||||
let mut written: Vec<PathBuf> = Vec::new();
|
||||
let mut written: Vec<String> = Vec::new();
|
||||
for entry in tar.entries().map_err(|e| format!("not a tar archive: {e}"))? {
|
||||
let mut entry = entry.map_err(|e| format!("damaged archive: {e}"))?;
|
||||
count += 1;
|
||||
@@ -448,21 +539,27 @@ pub(crate) fn unpack(archive: &[u8], dest: &Path) -> Result<(), String> {
|
||||
}
|
||||
let raw = entry.path().map_err(|e| format!("bad file name: {e}"))?.into_owned();
|
||||
let rel = safe_entry_path(&raw).ok_or_else(|| format!("{} is outside the pack", raw.display()))?;
|
||||
let parts: Vec<&str> = rel.iter().filter_map(|c| c.to_str()).collect();
|
||||
let kind = entry.header().entry_type();
|
||||
if kind.is_dir() {
|
||||
if rel.components().count() > 1 {
|
||||
return Err(format!("{}: folders nest at most one level", rel.display()));
|
||||
}
|
||||
if !rel.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(dest.join(&rel)).map_err(|e| e.to_string())?;
|
||||
match parts.as_slice() {
|
||||
[] => {}
|
||||
[LICENCES_DIR] => std::fs::create_dir_all(dest.join(LICENCES_DIR)).map_err(|e| e.to_string())?,
|
||||
_ => return Err(format!("{}: not a folder a pack has", rel.display())),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !kind.is_file() {
|
||||
return Err(format!("{}: only files and folders are allowed", rel.display()));
|
||||
}
|
||||
if rel.components().count() > 2 || written.contains(&rel) {
|
||||
return Err(format!("{}: too deep, or listed twice", rel.display()));
|
||||
let allowed = match parts.as_slice() {
|
||||
[name] => ROOT_FILES.contains(name),
|
||||
[LICENCES_DIR, name] => licence_name(name),
|
||||
_ => false,
|
||||
};
|
||||
let key = parts.join("/");
|
||||
if !allowed || written.contains(&key) {
|
||||
return Err(format!("{}: not a file a pack has, or listed twice", rel.display()));
|
||||
}
|
||||
let size = entry.header().size().map_err(|e| e.to_string())?;
|
||||
if size > MAX_PACK_FILE_BYTES {
|
||||
@@ -481,14 +578,84 @@ pub(crate) fn unpack(archive: &[u8], dest: &Path) -> Result<(), String> {
|
||||
if copied != size {
|
||||
return Err(format!("{}: truncated", rel.display()));
|
||||
}
|
||||
written.push(rel);
|
||||
written.push(key);
|
||||
}
|
||||
if !written.iter().any(|p| p.as_os_str() == "pack.toml") {
|
||||
// Only zeros (the tar end blocks and padding) may follow: not a second archive, not a frame
|
||||
// that wasn't read.
|
||||
let mut rest = tar.into_inner();
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
let n = rest.read(&mut buf).map_err(|e| format!("damaged archive: {e}"))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
if buf.get(..n).is_some_and(|b| b.iter().any(|&x| x != 0)) {
|
||||
return Err("data after the end of the archive".into());
|
||||
}
|
||||
}
|
||||
let has = |f: &str| written.iter().any(|w| w == f);
|
||||
if !has("pack.toml") {
|
||||
return Err("no pack.toml".into());
|
||||
}
|
||||
let toml = std::fs::read_to_string(dest.join("pack.toml")).map_err(|e| format!("pack.toml: {e}"))?;
|
||||
let named = toml.parse::<toml::Table>().ok().and_then(|t| t.get("locale").and_then(toml::Value::as_str).map(str::to_string));
|
||||
if named.as_deref() != Some(pack.locale.as_str()) {
|
||||
return Err(format!("pack.toml names locale {named:?}, not {:?}", pack.locale));
|
||||
}
|
||||
if pack.spelling && !(has("spelling.aff") && has("spelling.dic")) {
|
||||
return Err("the index promises a spelling dictionary the archive doesn't have".into());
|
||||
}
|
||||
if pack.hyphenation && !has("hyphenation.pat") {
|
||||
return Err("the index promises hyphenation patterns the archive doesn't have".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The files a pack holds at its root.
|
||||
const ROOT_FILES: [&str; 4] = ["pack.toml", "spelling.aff", "spelling.dic", "hyphenation.pat"];
|
||||
/// The folder of a pack's licence texts.
|
||||
const LICENCES_DIR: &str = "LICENCES";
|
||||
/// Largest `pack.toml` read back.
|
||||
const MAX_PACK_TOML_BYTES: u64 = 1 << 20;
|
||||
|
||||
/// A licence file's name: `[A-Za-z0-9._-]`, at most 100 characters, not starting with a dot or
|
||||
/// ending with one, and not a name Windows reserves.
|
||||
fn licence_name(n: &str) -> bool {
|
||||
!n.is_empty()
|
||||
&& n.len() <= 100
|
||||
&& !n.starts_with('.')
|
||||
&& !n.ends_with('.')
|
||||
&& n.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
|
||||
&& !windows_reserved(n)
|
||||
}
|
||||
|
||||
/// A name Windows reserves for a device (`CON`, `nul.txt`, `COM1`, `lpt9.md`), with or without an
|
||||
/// extension, in any case.
|
||||
fn windows_reserved(n: &str) -> bool {
|
||||
let stem = n.split('.').next().unwrap_or_default().trim_end_matches(' ').to_ascii_lowercase();
|
||||
matches!(stem.as_str(), "con" | "prn" | "aux" | "nul")
|
||||
|| (stem.len() == 4
|
||||
&& (stem.starts_with("com") || stem.starts_with("lpt"))
|
||||
&& stem.as_bytes().get(3).is_some_and(|d| (b'1'..=b'9').contains(d)))
|
||||
}
|
||||
|
||||
/// Text from an index or `pack.toml` shown to people (a pack's name): without control,
|
||||
/// bidirectional or other format characters (U+202E would reverse what follows), trimmed, at most
|
||||
/// 200 characters.
|
||||
fn plain_text(s: &str) -> String {
|
||||
s.chars().filter(|&c| !c.is_control() && !is_format(c)).take(200).collect::<String>().trim().to_string()
|
||||
}
|
||||
|
||||
/// Unicode format characters (general category Cf): bidirectional controls, zero-width ones, the
|
||||
/// soft hyphen, tags.
|
||||
fn is_format(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{ad}' | '\u{600}'..='\u{605}' | '\u{61c}' | '\u{6dd}' | '\u{70f}' | '\u{890}'..='\u{891}' | '\u{8e2}' | '\u{180e}'
|
||||
| '\u{200b}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2060}'..='\u{2064}' | '\u{2066}'..='\u{206f}' | '\u{feff}'
|
||||
| '\u{fff9}'..='\u{fffb}' | '\u{110bd}' | '\u{110cd}' | '\u{13430}'..='\u{1343f}' | '\u{1bca0}'..='\u{1bca3}'
|
||||
| '\u{1d173}'..='\u{1d17a}' | '\u{e0001}' | '\u{e0020}'..='\u{e007f}')
|
||||
}
|
||||
|
||||
/// `p` as a plain relative path (`./` prefixes dropped), or `None` for a root, a drive, `..` or a
|
||||
/// name that isn't UTF-8 or holds a backslash, colon or control character.
|
||||
fn safe_entry_path(p: &Path) -> Option<PathBuf> {
|
||||
|
||||
@@ -116,6 +116,11 @@ fn entry(locale: &str, url: &str, archive: &[u8]) -> serde_json::Value {
|
||||
"size": archive.len(), "licence": "MIT", "class": "Embeddable", "spelling": true, "hyphenation": true})
|
||||
}
|
||||
|
||||
/// The index entry for `archive` as `locale`.
|
||||
fn pack_entry(locale: &str, archive: &[u8]) -> PackEntry {
|
||||
serde_json::from_value(entry(locale, &format!("{RELEASE}{locale}.tar.zst"), archive)).unwrap()
|
||||
}
|
||||
|
||||
fn index_json(packs: &[serde_json::Value]) -> Vec<u8> {
|
||||
serde_json::to_vec(&serde_json::json!({"format": 1, "release": "language-packs-1", "packs": packs})).unwrap()
|
||||
}
|
||||
@@ -226,6 +231,7 @@ fn indexes_are_checked() {
|
||||
("http://evil.org@127.0.0.1/i.json", true),
|
||||
("http://127.0.0.1@evil.org/i.json", false),
|
||||
("ftp://127.0.0.1/i.json", false),
|
||||
("file:///tmp/i.json", false),
|
||||
] {
|
||||
assert_eq!(local_source(s).is_some(), ok, "{s}");
|
||||
}
|
||||
@@ -269,6 +275,7 @@ fn developer_override_reads_a_local_index_without_the_pin() {
|
||||
#[test]
|
||||
fn hostile_archives_are_refused() {
|
||||
let toml: &[u8] = b"locale = \"nl\"\nlicence = \"MIT\"\n";
|
||||
let nl = PackEntry { spelling: false, hyphenation: false, ..pack_entry("nl", b"") };
|
||||
let big = vec![0u8; 10];
|
||||
let cases: Vec<(&str, Vec<u8>)> = vec![
|
||||
("traversal", zst(&tar_of(&[("pack.toml", FILE, toml), ("../evil", FILE, b"x")]))),
|
||||
@@ -283,19 +290,36 @@ fn hostile_archives_are_refused() {
|
||||
("not zstd", tar_of(&[("pack.toml", FILE, toml)])),
|
||||
("not tar", zst(b"hello, this is not a tar archive at all")),
|
||||
("empty", Vec::new()),
|
||||
// Only the contract's names.
|
||||
("other root file", zst(&tar_of(&[("pack.toml", FILE, toml), ("readme.txt", FILE, b"x")]))),
|
||||
("other folder", zst(&tar_of(&[("pack.toml", FILE, toml), ("extra/", tar::EntryType::Directory, b"")]))),
|
||||
("nested licence", zst(&tar_of(&[("pack.toml", FILE, toml), ("LICENCES/sub/x", FILE, b"x")]))),
|
||||
("hidden licence", zst(&tar_of(&[("pack.toml", FILE, toml), ("LICENCES/.x", FILE, b"x")]))),
|
||||
("space in licence", zst(&tar_of(&[("pack.toml", FILE, toml), ("LICENCES/a b", FILE, b"x")]))),
|
||||
("trailing dot", zst(&tar_of(&[("pack.toml", FILE, toml), ("LICENCES/COPYING.", FILE, b"x")]))),
|
||||
("reserved CON", zst(&tar_of(&[("pack.toml", FILE, toml), ("LICENCES/CON", FILE, b"x")]))),
|
||||
("reserved nul.txt", zst(&tar_of(&[("pack.toml", FILE, toml), ("LICENCES/nul.txt", FILE, b"x")]))),
|
||||
("reserved com1", zst(&tar_of(&[("pack.toml", FILE, toml), ("LICENCES/com1.md", FILE, b"x")]))),
|
||||
// pack.toml must name the entry's locale.
|
||||
("other locale", zst(&tar_of(&[("pack.toml", FILE, b"locale = \"de\"\n")]))),
|
||||
("no locale", zst(&tar_of(&[("pack.toml", FILE, b"licence = \"MIT\"\n")]))),
|
||||
("bad toml", zst(&tar_of(&[("pack.toml", FILE, b"locale = \n")]))),
|
||||
// Nothing may follow the archive: not another frame, not raw bytes.
|
||||
("second archive", [zst(&tar_of(&[("pack.toml", FILE, toml)])), zst(&tar_of(&[("spelling.dic", FILE, b"1\nx\n")]))].concat()),
|
||||
("trailing bytes", [zst(&tar_of(&[("pack.toml", FILE, toml)])), b"garbage".to_vec()].concat()),
|
||||
("truncated", zst(&tar_of(&[("pack.toml", FILE, toml), ("spelling.dic", FILE, &big)]))[..20].to_vec()),
|
||||
];
|
||||
for (what, archive) in cases {
|
||||
let root = temp("hostile");
|
||||
let dest = root.join("nl");
|
||||
assert!(unpack(&archive, &dest).is_err(), "{what}");
|
||||
assert!(unpack(&archive, &dest, &nl).is_err(), "{what}");
|
||||
assert!(!root.join("evil").exists() && !root.parent().unwrap().join("evil").exists(), "{what}");
|
||||
}
|
||||
// Too many entries.
|
||||
let names: Vec<String> = (0..=MAX_PACK_ENTRIES).map(|i| format!("f{i}")).collect();
|
||||
let names: Vec<String> = (0..=MAX_PACK_ENTRIES).map(|i| format!("LICENCES/f{i}")).collect();
|
||||
let mut files: Vec<(&str, tar::EntryType, &[u8])> = vec![("pack.toml", FILE, toml)];
|
||||
files.extend(names.iter().map(|n| (n.as_str(), FILE, &b""[..])));
|
||||
assert!(unpack(&zst(&tar_of(&files)), &temp("many").join("nl")).is_err());
|
||||
assert!(unpack(&zst(&tar_of(&files)), &temp("many").join("nl"), &nl).is_err());
|
||||
// A header claiming a file larger than the cap is refused before anything is read.
|
||||
let mut t = tar::Header::new_gnu();
|
||||
t.set_path("pack.toml").unwrap();
|
||||
@@ -311,12 +335,82 @@ fn hostile_archives_are_refused() {
|
||||
h.set_cksum();
|
||||
tarball.extend_from_slice(h.as_bytes());
|
||||
tarball.extend_from_slice(&[0u8; 1024]);
|
||||
let err = unpack(&zst(&tarball), &temp("huge").join("nl")).unwrap_err();
|
||||
let err = unpack(&zst(&tarball), &temp("huge").join("nl"), &nl).unwrap_err();
|
||||
assert!(err.contains("larger than"), "{err}");
|
||||
// A good one unpacks.
|
||||
let dest = temp("good").join("nl");
|
||||
unpack(&pack_archive("nl", "x"), &dest).unwrap();
|
||||
let good = pack_archive("nl", "x");
|
||||
unpack(&good, &dest, &pack_entry("nl", &good)).unwrap();
|
||||
assert!(dest.join("LICENCES/COPYING").is_file());
|
||||
// A tar split over several zstd frames is read to the end.
|
||||
let tarball = tar_of(&[("pack.toml", FILE, toml), ("LICENCES/", tar::EntryType::Directory, b""), ("LICENCES/COPYING", FILE, b"t")]);
|
||||
let (a, b) = tarball.split_at(700);
|
||||
let dest = temp("frames").join("nl");
|
||||
unpack(&[zst(a), zst(b)].concat(), &dest, &nl).unwrap();
|
||||
assert!(dest.join("LICENCES/COPYING").is_file());
|
||||
// The index's promises are checked against the files.
|
||||
let bare = zst(&tar_of(&[("pack.toml", FILE, toml)]));
|
||||
for e in [PackEntry { spelling: true, ..nl.clone() }, PackEntry { hyphenation: true, ..nl.clone() }] {
|
||||
let err = unpack(&bare, &temp("promise").join("nl"), &e).unwrap_err();
|
||||
assert!(err.contains("promises"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_plain_text_and_reserved_locales_refused() {
|
||||
let a = pack_archive("nl", "x");
|
||||
let mut e = entry("nl", &format!("{RELEASE}nl.tar.zst"), &a);
|
||||
e["name"] = serde_json::json!(" Neder\u{202e}lands\u{7}\u{200b}\n");
|
||||
assert_eq!(PackIndex::from_json(&index_json(&[e])).unwrap().packs[0].name, "Nederlands");
|
||||
for l in ["con", "nul", "aux", "prn", "com1", "lpt9"] {
|
||||
assert!(!is_locale(l), "{l}");
|
||||
assert!(PackIndex::from_json(&index_json(&[entry(l, &format!("{RELEASE}{l}.tar.zst"), &a)])).is_err(), "{l}");
|
||||
}
|
||||
assert!(is_locale("com") && is_locale("lpt") && is_locale("com10"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_record_is_rebuilt_from_the_folders() {
|
||||
let (store, server, pin, _) = setup("corrupt");
|
||||
let index = store.fetch_pack_index_from(&IndexSource::Pinned(pin), &Cancel::new()).unwrap();
|
||||
let nl = index.pack("nl").unwrap().clone();
|
||||
store.install_pack_from(false, &index, &nl, &Cancel::new(), &mut |_| {}).unwrap();
|
||||
let record = store.dir().join("language-packs.json");
|
||||
std::fs::write(&record, b"{ not json").unwrap();
|
||||
// Still listed, with the name from its pack.toml.
|
||||
let listed = store.installed_language_packs();
|
||||
assert_eq!(listed.iter().map(|p| (p.locale.as_str(), p.name.as_str())).collect::<Vec<_>>(), [("nl", "Test")]);
|
||||
// Installing another keeps it.
|
||||
std::fs::write(&record, b"{ not json").unwrap();
|
||||
let de = pack_archive("de", "zzde");
|
||||
server.serve("release/de.tar.zst", &de);
|
||||
let de_entry = PackEntry { url: format!("{RELEASE}de.tar.zst"), ..pack_entry("de", &de) };
|
||||
store.install_pack_from(false, &index, &de_entry, &Cancel::new(), &mut |_| {}).unwrap();
|
||||
let mut locales: Vec<String> = store.installed_language_packs().into_iter().map(|p| p.locale).collect();
|
||||
locales.sort();
|
||||
assert_eq!(locales, ["de", "nl"]);
|
||||
assert!(store.dir().join("language-packs.json.bad").is_file(), "the unreadable record is kept aside");
|
||||
// Removing works on a rebuilt record too.
|
||||
std::fs::write(&record, b"\xff").unwrap();
|
||||
store.remove_language_pack("nl").unwrap();
|
||||
assert_eq!(store.installed_language_packs().into_iter().map(|p| p.locale).collect::<Vec<_>>(), ["de"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_hand_made_entries_are_index_errors() {
|
||||
let (store, _server, pin, _) = setup("variant");
|
||||
let index = store.fetch_pack_index_from(&IndexSource::Pinned(pin), &Cancel::new()).unwrap();
|
||||
let nl = index.pack("nl").unwrap().clone();
|
||||
for e in [PackEntry { locale: "../nl".into(), ..nl.clone() }, PackEntry { sha256: "x".into(), ..nl.clone() }] {
|
||||
let got = store.install_pack_from(false, &index, &e, &Cancel::new(), &mut |_| {});
|
||||
assert!(matches!(got, Err(Error::Index(IndexError::Invalid(_)))), "{got:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_override_is_visible() {
|
||||
let set = std::env::var(LANGUAGE_PACK_INDEX_ENV).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
|
||||
assert_eq!(language_pack_index_overridden(), set);
|
||||
}
|
||||
|
||||
/// A staging folder an earlier process with the same id left behind is cleared, so an unpack
|
||||
|
||||
@@ -48,7 +48,7 @@ pub use http::{Cancel, Client, FetchError, Timeouts, USER_AGENT};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use language_packs::{
|
||||
InstalledLanguagePack, LANGUAGE_PACK_INDEX_ENV, LANGUAGE_PACKS, MAX_PACK_ARCHIVE_BYTES, MAX_PACK_ENTRIES, MAX_PACK_FILE_BYTES,
|
||||
MAX_PACK_INDEX_BYTES, MAX_PACK_UNPACKED_BYTES, PackEntry, PackIndex, PackIndexPin,
|
||||
MAX_PACK_INDEX_BYTES, MAX_PACK_UNPACKED_BYTES, PackEntry, PackIndex, PackIndexPin, language_pack_index_overridden,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use store::{Caller, Error, FontStore, InstalledFamily, InstalledFile, Network, Problem, Progress, UserSource};
|
||||
|
||||
Reference in New Issue
Block a user