Compare commits

..
4 changed files with 46 additions and 49 deletions
+14 -26
View File
@@ -85,20 +85,14 @@ impl AutomationRoots {
/// May the file or folder at `path` be read (or written)? `Err` says why not.
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
self.check_path(Path::new(path), access)
}
/// [`Self::check`] for a path that may not be UTF-8: the checked path is the one opened.
pub fn check_path(&self, path: &Path, access: Access) -> Result<(), String> {
let path_text = path.display();
let roots = match access {
Access::Read => &self.read,
Access::Write => &self.write,
};
if roots.is_empty() {
return match access {
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path_text}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path_text}")),
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
};
}
let resolved = resolve(path)?;
@@ -107,8 +101,8 @@ impl AutomationRoots {
}
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
match access {
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path_text}")),
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path_text}")),
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path}")),
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path}")),
}
}
}
@@ -197,23 +191,17 @@ pub fn check_write(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Write))
}
/// May `path` be written here? Always, without roots in force. For a path that may not be UTF-8.
pub fn check_write_path(path: &Path) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check_path(path, Access::Write))
}
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
/// existing folder's links followed and the rest appended.
fn resolve(path: &Path) -> Result<PathBuf, String> {
if path.as_os_str().is_empty() {
fn resolve(path: &str) -> Result<PathBuf, String> {
if path.is_empty() {
return Err("automation path rejected: path is empty".into());
}
let shown = path.display();
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
return Err(format!("automation path rejected: reserved device names are not allowed: {shown}"));
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
}
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?;
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
// The names below the nearest existing folder, last first.
let mut missing: Vec<OsString> = vec![];
let mut at = absolute.as_path();
@@ -224,11 +212,11 @@ fn resolve(path: &Path) -> Result<PathBuf, String> {
return Ok(found);
}
Err(e) => match std::fs::symlink_metadata(at) {
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
// There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
},
}
let parent = at.parent();
@@ -238,12 +226,12 @@ fn resolve(path: &Path) -> Result<PathBuf, String> {
at = parent;
}
(Some(Component::ParentDir), _) => {
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
}
// Down to the root (or drive) and it doesn't exist either.
_ => {
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
}
}
}
+11 -7
View File
@@ -1,6 +1,8 @@
//! Fonts a presentation uses that this machine doesn't have, so opening it can say so (its text
//! draws in a stand-in; see `FontDb::document_face`).
use std::collections::HashSet;
use deckcraft_fonts::FontDb;
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::text::{Bullet, TextBody};
@@ -12,8 +14,13 @@ use deckcraft_model::{Presentation, Shape, ShapeKind};
/// font to load and are left for the round trip.
pub fn install_embedded_fonts(p: &Presentation) {
let db = FontDb::global();
// Faces can name one media item many times; each is copied and enumerated once.
let mut seen = HashSet::new();
for font in &p.embedded_fonts {
for id in font.faces().into_iter().flatten() {
if !seen.insert(id) {
continue;
}
let Some(item) = p.media(id) else { continue };
let Some(sfnt) = deckcraft_fonts::embedded_sfnt(&item.data) else { continue };
db.add_font(sfnt.to_vec());
@@ -27,12 +34,8 @@ pub fn install_embedded_fonts(p: &Presentation) {
/// and can't be installed, so they aren't reported.
pub fn missing_fonts(p: &Presentation) -> Vec<String> {
let db = FontDb::global();
let mut out: Vec<String> = Vec::new();
for family in used_fonts(p) {
if !family.starts_with('.') && !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
out.push(family);
}
}
// used_fonts is already free of case-insensitive duplicates.
let mut out: Vec<String> = used_fonts(p).into_iter().filter(|f| !f.starts_with('.') && !db.has_family(f)).collect();
out.sort_by_key(|f| f.to_lowercase());
out
}
@@ -40,9 +43,10 @@ pub fn missing_fonts(p: &Presentation) -> Vec<String> {
/// The families the slides' text runs and bullets are drawn in.
pub fn used_fonts(p: &Presentation) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut add = |f: String| {
let f = f.trim().to_string();
if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) {
if !f.is_empty() && seen.insert(f.to_ascii_lowercase()) {
out.push(f);
}
};
-16
View File
@@ -668,8 +668,6 @@ pub fn add_user_word(word: &str) -> std::io::Result<()> {
return Ok(());
}
let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?;
// A confined automation client may write only inside its write roots, this file included.
crate::file_access::check_write_path(&path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
if let Some(d) = path.parent() {
std::fs::create_dir_all(d)?;
}
@@ -955,20 +953,6 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
assert_eq!(w, vec!["check", "done"]);
}
#[test]
fn a_confined_client_cannot_write_the_user_dictionary_outside_its_roots() {
let root = std::env::temp_dir().join(format!("deckcraft-dict-root-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let roots = crate::file_access::AutomationRoots::new(std::slice::from_ref(&root), std::slice::from_ref(&root)).unwrap();
// The user dictionary is a file of its own outside `root`, not whatever HOME points at.
let outside = std::env::temp_dir().join(format!("deckcraft-dict-outside-{}", std::process::id()));
set_user_dictionary_path(outside.join("user.dic"));
let r = crate::file_access::confine(roots.as_ref(), || add_user_word("zqxconfinedword"));
assert_eq!(r.map_err(|e| e.kind()), Err(std::io::ErrorKind::PermissionDenied));
assert!(!outside.exists(), "the refused write created nothing");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn long_and_numeric_flags_and_latin1() {
let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n");
+21
View File
@@ -6458,3 +6458,24 @@ fn master_body_placeholder_format_keeps_per_level_formatting() {
assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(28.0), Some(24.0)));
assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]);
}
#[test]
fn many_distinct_fonts_are_listed_once_each_without_a_quadratic_scan() {
// 30,000 runs in 15,000 families, each written twice in different case.
let mut p = deckcraft_model::Presentation::default();
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
let mut body = deckcraft_model::text::TextBody::from_text("x");
let run = body.paragraphs[0].runs[0].clone();
body.paragraphs[0].runs = (0..30_000)
.map(|i| {
let mut r = run.clone();
let name = format!("Qqq Font {}", i / 2);
r.props.font = Some(if i % 2 == 0 { name } else { name.to_uppercase() });
r
})
.collect();
slide.shapes[0].text = Some(body);
let used = crate::fonts::used_fonts(&p);
assert_eq!(used.iter().filter(|f| f.starts_with("Qqq Font ")).count(), 15_000);
assert_eq!(crate::fonts::missing_fonts(&p).iter().filter(|f| f.to_ascii_lowercase().starts_with("qqq font ")).count(), 15_000);
}