mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-03 17:12:52 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31a7c2baf5 | ||
|
|
74d6145982 | ||
|
|
ee04033524 |
@@ -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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
+31
-16
@@ -498,6 +498,9 @@ impl Backdrops {
|
||||
/// Most pixels one slide raster may have; it is RGBA, and the backdrop and the content are two rasters.
|
||||
const MAX_RASTER_PIXELS: f64 = 64_000_000.0;
|
||||
|
||||
/// Most ruled note lines one handout area gets (a letter page holds about 30).
|
||||
const MAX_NOTE_LINES: usize = 500;
|
||||
|
||||
/// Render scale for a slide of `slide` units drawn `box_width` points wide at `dpi`, with the total pixel count capped.
|
||||
fn raster_scale(box_width: f64, slide: (f64, f64), dpi: f64) -> f64 {
|
||||
let scale = (box_width / 72.0 * dpi.clamp(36.0, 600.0)) / slide.0;
|
||||
@@ -573,20 +576,17 @@ impl Exporter<'_> {
|
||||
|
||||
/// Invisible glyphs exactly over the rasterised text.
|
||||
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) -> Result<()> {
|
||||
// Each paragraph's characters, gathered once: a long paragraph wraps into many runs.
|
||||
let mut para_chars: std::collections::HashMap<usize, Vec<char>> = std::collections::HashMap::new();
|
||||
for run in &pt.layout.runs {
|
||||
if run.glyphs.is_empty() || run.size <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let text: String = pt
|
||||
.body
|
||||
.paragraphs
|
||||
.get(run.para)
|
||||
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.skip(run.chars.0)
|
||||
.take(run.chars.1.saturating_sub(run.chars.0))
|
||||
.collect();
|
||||
let chars = para_chars.entry(run.para).or_insert_with(|| {
|
||||
pt.body.paragraphs.get(run.para).map(|p| p.runs.iter().flat_map(|r| r.text.chars()).collect()).unwrap_or_default()
|
||||
});
|
||||
let (a, b) = (run.chars.0.min(chars.len()), run.chars.1.min(chars.len()));
|
||||
let text: String = chars.get(a..b.max(a)).unwrap_or_default().iter().collect();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -666,19 +666,28 @@ impl Exporter<'_> {
|
||||
let lh = size * 1.3;
|
||||
let mut y = area.y0 + size;
|
||||
s.set_fill(Some(grey(0x20, 1.0)));
|
||||
let space = width(" ");
|
||||
'outer: for para in text.split('\n') {
|
||||
// The line's width is kept as words are added, not measured again for each word.
|
||||
let mut line = String::new();
|
||||
let mut line_w = 0.0;
|
||||
for word in para.split(' ') {
|
||||
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||||
if width(&cand) > area.width() && !line.is_empty() {
|
||||
let word_w = width(word);
|
||||
let cand_w = if line.is_empty() { word_w } else { line_w + space + word_w };
|
||||
if cand_w > area.width() && !line.is_empty() {
|
||||
if y > area.y1 {
|
||||
break 'outer;
|
||||
}
|
||||
s.draw_text(Point::from_xy(area.x0 as f32, y as f32), font.clone(), size as f32, &line, false, TextDirection::Auto);
|
||||
y += lh;
|
||||
line = word.to_string();
|
||||
line_w = word_w;
|
||||
} else {
|
||||
line = cand;
|
||||
if !line.is_empty() {
|
||||
line.push(' ');
|
||||
}
|
||||
line.push_str(word);
|
||||
line_w = cand_w;
|
||||
}
|
||||
}
|
||||
if y > area.y1 {
|
||||
@@ -695,20 +704,26 @@ impl Exporter<'_> {
|
||||
|
||||
/// Ruled lines for handwritten notes (3-per-page handouts).
|
||||
fn note_lines(&self, s: &mut Surface, r: Rect) {
|
||||
let mut y = r.y0 + 18.0;
|
||||
// Counted, not stepped: at huge coordinates adding 22 leaves y unchanged, and a huge page
|
||||
// would want millions of rules.
|
||||
let first = r.y0 + 18.0;
|
||||
let count = ((r.y1 - first) / 22.0).floor() + 1.0;
|
||||
if count.is_nan() || count < 1.0 {
|
||||
return;
|
||||
}
|
||||
s.set_stroke(Some(krilla::paint::Stroke {
|
||||
paint: krilla::color::rgb::Color::new(0xB0, 0xB0, 0xB0).into(),
|
||||
width: 0.5,
|
||||
..Default::default()
|
||||
}));
|
||||
while y <= r.y1 {
|
||||
for i in 0..(count.min(MAX_NOTE_LINES as f64) as usize) {
|
||||
let y = first + 22.0 * i as f64;
|
||||
let mut p = kurbo::BezPath::new();
|
||||
p.move_to((r.x0, y));
|
||||
p.line_to((r.x1, y));
|
||||
if let Some(path) = to_path(&p) {
|
||||
s.draw_path(&path);
|
||||
}
|
||||
y += 22.0;
|
||||
}
|
||||
s.set_stroke(None);
|
||||
}
|
||||
|
||||
@@ -677,3 +677,16 @@ fn raster_scale_caps_total_pixels_not_each_side() {
|
||||
let s = raster_scale(720.0, (960.0, 540.0), 200.0);
|
||||
assert!((s - 720.0 / 72.0 * 200.0 / 960.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn huge_paper_and_long_notes_export_in_bounded_work() {
|
||||
// At 1e19 pt adding a rule's 22 pt leaves y unchanged: the rules are counted, not stepped. A narrow page keeps the raster small.
|
||||
let p = deck(1);
|
||||
let _ = export(&p, &PdfOptions { paper: (612.0, 1.0e19), ..opts(PageLayout::Handouts { per_page: 3 }) });
|
||||
// 20,000 short words of notes, wrapped with the line width kept as words are added.
|
||||
let mut p = deck(1);
|
||||
Arc::make_mut(&mut p.slides[0]).notes = TextBody::from_text(&"ab ".repeat(20_000));
|
||||
let bytes = export(&p, &opts(PageLayout::Notes)).expect("export");
|
||||
assert!(bytes.starts_with(b"%PDF-"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user