mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-09 11:59:36 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25f3fb2064 |
@@ -592,9 +592,16 @@ fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String,
|
|||||||
(layout, frame.into(), captions, any && gray, theme_now.to_string())
|
(layout, frame.into(), captions, any && gray, theme_now.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The album's items for the dialog, within the limits `insert.photoAlbum` itself takes (items,
|
||||||
|
/// bytes per picture and in all), checked before a picture is encoded: an album slide set can
|
||||||
|
/// name one large picture many times.
|
||||||
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec<Value> {
|
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec<Value> {
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
for slide in slides {
|
let mut total = 0usize;
|
||||||
|
'slides: for slide in slides {
|
||||||
|
if items.len() >= ALBUM_MAX_ITEMS {
|
||||||
|
break;
|
||||||
|
}
|
||||||
let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect();
|
let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect();
|
||||||
if pics.is_empty() {
|
if pics.is_empty() {
|
||||||
let text = slide
|
let text = slide
|
||||||
@@ -612,6 +619,11 @@ fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_m
|
|||||||
for sh in pics {
|
for sh in pics {
|
||||||
let ShapeKind::Picture { fill } = &sh.kind else { continue };
|
let ShapeKind::Picture { fill } = &sh.kind else { continue };
|
||||||
let media = doc.media(fill.media);
|
let media = doc.media(fill.media);
|
||||||
|
let size = media.map_or(0, |m| m.data.len());
|
||||||
|
if items.len() >= ALBUM_MAX_ITEMS || size > ALBUM_MAX_BYTES || total.saturating_add(size) > ALBUM_MAX_TOTAL {
|
||||||
|
break 'slides;
|
||||||
|
}
|
||||||
|
total += size;
|
||||||
let name = if !sh.descr.is_empty() {
|
let name = if !sh.descr.is_empty() {
|
||||||
sh.descr.clone()
|
sh.descr.clone()
|
||||||
} else {
|
} else {
|
||||||
@@ -722,3 +734,28 @@ fn insert_album(s: &mut Session, items: Vec<AlbumItem>, opts: &AlbumOpts, at: Op
|
|||||||
s.execute("slide.go", &json!({"index": first}))?;
|
s.execute("slide.go", &json!({"index": first}))?;
|
||||||
Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id}))
|
Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod seed_tests {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use deckcraft_model::style::PictureFill;
|
||||||
|
use deckcraft_model::{Shape, ShapeId, defaults};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reopening_an_album_seeds_at_most_the_items_an_album_takes() {
|
||||||
|
// One picture named by 500 shapes: the dialog gets 48 items, not 500 encoded copies.
|
||||||
|
let mut p = defaults::new_presentation(None);
|
||||||
|
let img = p.add_media("a.png", "image/png", vec![1; 64 * 1024]).unwrap();
|
||||||
|
let pic = |k: u32| Shape {
|
||||||
|
id: ShapeId(10_000 + k),
|
||||||
|
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
Arc::make_mut(&mut p.slides[0]).shapes = (0..500).map(pic).collect();
|
||||||
|
let slides: Vec<&deckcraft_model::Slide> = p.slides.iter().map(|s| s.as_ref()).collect();
|
||||||
|
assert_eq!(items_from_slides(&p, &slides).len(), ALBUM_MAX_ITEMS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,20 +85,14 @@ impl AutomationRoots {
|
|||||||
|
|
||||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
/// 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> {
|
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 {
|
let roots = match access {
|
||||||
Access::Read => &self.read,
|
Access::Read => &self.read,
|
||||||
Access::Write => &self.write,
|
Access::Write => &self.write,
|
||||||
};
|
};
|
||||||
if roots.is_empty() {
|
if roots.is_empty() {
|
||||||
return match access {
|
return match access {
|
||||||
Access::Read => Err(format!("automation filesystem access is not granted: read 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_text}")),
|
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let resolved = resolve(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(", ");
|
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
|
||||||
match access {
|
match access {
|
||||||
Access::Read => Err(format!("automation path rejected: outside the read 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_text}")),
|
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))
|
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
|
/// `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.
|
/// existing folder's links followed and the rest appended.
|
||||||
fn resolve(path: &Path) -> Result<PathBuf, String> {
|
fn resolve(path: &str) -> Result<PathBuf, String> {
|
||||||
if path.as_os_str().is_empty() {
|
if path.is_empty() {
|
||||||
return Err("automation path rejected: path is empty".into());
|
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.
|
// 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()))) {
|
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: {shown}"));
|
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.
|
// The names below the nearest existing folder, last first.
|
||||||
let mut missing: Vec<OsString> = vec![];
|
let mut missing: Vec<OsString> = vec![];
|
||||||
let mut at = absolute.as_path();
|
let mut at = absolute.as_path();
|
||||||
@@ -224,11 +212,11 @@ fn resolve(path: &Path) -> Result<PathBuf, String> {
|
|||||||
return Ok(found);
|
return Ok(found);
|
||||||
}
|
}
|
||||||
Err(e) => match std::fs::symlink_metadata(at) {
|
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.
|
// 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) 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();
|
let parent = at.parent();
|
||||||
@@ -238,12 +226,12 @@ fn resolve(path: &Path) -> Result<PathBuf, String> {
|
|||||||
at = parent;
|
at = parent;
|
||||||
}
|
}
|
||||||
(Some(Component::ParentDir), _) => {
|
(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.
|
// Down to the root (or drive) and it doesn't exist either.
|
||||||
_ => {
|
_ => {
|
||||||
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?;
|
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() {
|
if let Some(d) = path.parent() {
|
||||||
std::fs::create_dir_all(d)?;
|
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"]);
|
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]
|
#[test]
|
||||||
fn long_and_numeric_flags_and_latin1() {
|
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");
|
let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n");
|
||||||
|
|||||||
Reference in New Issue
Block a user