mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-09 11:59:36 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cb3033e21 | ||
|
|
73ca6d6ccc |
@@ -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");
|
||||||
|
|||||||
+20
-17
@@ -663,52 +663,55 @@ fn read_slides(
|
|||||||
let pkg = imp.pkg;
|
let pkg = imp.pkg;
|
||||||
let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
|
let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
|
||||||
let mut file_slide_ids: HashMap<u32, SlideId> = HashMap::new();
|
let mut file_slide_ids: HashMap<u32, SlideId> = HashMap::new();
|
||||||
let mut notes_by_target: HashMap<String, TextBody> = HashMap::new();
|
// A notes or comments part belongs to one slide; one shared by many must not be read (and
|
||||||
|
// copied) once per slide that names it.
|
||||||
|
let mut owned_parts: HashSet<String> = HashSet::new();
|
||||||
for (file_id, path, id) in slide_ids {
|
for (file_id, path, id) in slide_ids {
|
||||||
let Some(doc) = docs.get(&opc::norm(path)) else { continue };
|
let Some(doc) = docs.get(&opc::norm(path)) else { continue };
|
||||||
let part = Part::new(pkg, path, doc);
|
let part = Part::new(pkg, path, doc);
|
||||||
let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout);
|
let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout);
|
||||||
let mut slide = read_slide(imp, &part, doc, *id, layout);
|
let mut slide = read_slide(imp, &part, doc, *id, layout);
|
||||||
if let Some(notes) = slide_notes(imp, &part, &mut notes_by_target) {
|
if let Some(notes) = slide_notes(imp, &part, &mut owned_parts) {
|
||||||
slide.notes = notes;
|
slide.notes = notes;
|
||||||
}
|
}
|
||||||
read_slide_comments(pkg, pres_part, &part, &mut slide);
|
read_slide_comments(pkg, pres_part, &part, &mut slide, &mut owned_parts);
|
||||||
file_slide_ids.insert(*file_id, *id);
|
file_slide_ids.insert(*file_id, *id);
|
||||||
p.slides.push(Arc::new(slide));
|
p.slides.push(Arc::new(slide));
|
||||||
}
|
}
|
||||||
file_slide_ids
|
file_slide_ids
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The slide's notes text, read once per notes part.
|
/// The slide's notes text. A notes part is the notes of the first slide that names it.
|
||||||
fn slide_notes(imp: &mut Imp, part: &Part, cache: &mut HashMap<String, TextBody>) -> Option<TextBody> {
|
fn slide_notes(imp: &mut Imp, part: &Part, owned: &mut HashSet<String>) -> Option<TextBody> {
|
||||||
let n = part.rels.first_of("notesSlide")?;
|
let n = part.rels.first_of("notesSlide")?;
|
||||||
let key = opc::norm(&n.target);
|
if !owned.insert(opc::norm(&n.target)) {
|
||||||
if let Some(cached) = cache.get(&key) {
|
log::warn!("pptx: notes part {} is named by more than one slide; kept on the first", n.target);
|
||||||
return Some(cached.clone());
|
return None;
|
||||||
}
|
}
|
||||||
let pkg = imp.pkg;
|
let pkg = imp.pkg;
|
||||||
let nd = load(pkg, &n.target)?;
|
let nd = load(pkg, &n.target)?;
|
||||||
let np = Part::new(pkg, &n.target, &nd);
|
let np = Part::new(pkg, &n.target, &nd);
|
||||||
let notes = read_notes(imp, &np, &nd.root);
|
Some(read_notes(imp, &np, &nd.root))
|
||||||
cache.insert(key, notes.clone());
|
|
||||||
Some(notes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Comments. A modern part and the legacy part can describe the same threads (PowerPoint
|
/// Comments. A modern part and the legacy part can describe the same threads (PowerPoint
|
||||||
/// writes both): the modern one wins, its flattened legacy copy is skipped.
|
/// writes both): the modern one wins, its flattened legacy copy is skipped.
|
||||||
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide) {
|
/// Each comments part is read once, for the first slide that names it.
|
||||||
let mut parts: Vec<(Doc, bool)> = vec![];
|
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide, owned: &mut HashSet<String>) {
|
||||||
|
let mut parts: Vec<(Doc, bool, bool)> = vec![];
|
||||||
for c in part.rels.all_of("comments") {
|
for c in part.rels.all_of("comments") {
|
||||||
|
// An already-owned part is not re-read, but its kind still decides modern precedence.
|
||||||
|
let fresh = owned.insert(opc::norm(&c.target));
|
||||||
if let Some(cd) = pkg.xml(&c.target) {
|
if let Some(cd) = pkg.xml(&c.target) {
|
||||||
// The content type names the modern part; a package that types it only through a
|
// The content type names the modern part; a package that types it only through a
|
||||||
// Default entry instead of an Override is still modern when its root is p188.
|
// Default entry instead of an Override is still modern when its root is p188.
|
||||||
let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188";
|
let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188";
|
||||||
parts.push((cd, modern));
|
parts.push((cd, modern, fresh));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let has_modern = parts.iter().any(|(_, m)| *m);
|
let has_modern = parts.iter().any(|(_, m, _)| *m);
|
||||||
for (cd, modern) in parts {
|
for (cd, modern, fresh) in parts {
|
||||||
if has_modern && !modern {
|
if !fresh || (has_modern && !modern) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
slide.comments.extend(read_comments(pkg, pres_part, &cd.root, modern));
|
slide.comments.extend(read_comments(pkg, pres_part, &cd.root, modern));
|
||||||
|
|||||||
@@ -146,6 +146,33 @@ fn comments_on_several_slides_round_trip() {
|
|||||||
assert_eq!(q.slides[2].comments[0].text, "Slide three");
|
assert_eq!(q.slides[2].comments[0].text, "Slide three");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A slide that names a modern part another slide already owns, plus its own legacy part, must
|
||||||
|
/// not import the legacy copy: modern precedence holds even when the modern part is not re-read.
|
||||||
|
#[test]
|
||||||
|
fn owned_modern_part_still_suppresses_later_legacy() {
|
||||||
|
let mut p = deck();
|
||||||
|
let layout = p.slides[0].layout;
|
||||||
|
let s2 = defaults::new_slide(&mut p, layout).expect("slide");
|
||||||
|
p.slides.push(Arc::new(s2));
|
||||||
|
Arc::make_mut(&mut p.slides[1]).comments = vec![Comment { author: "Cara".into(), text: "Slide two".into(), ..Default::default() }];
|
||||||
|
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||||
|
let rels2 = part(&bytes, "ppt/slides/_rels/slide2.xml.rels").replace("modernComment2.xml", "modernComment1.xml");
|
||||||
|
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip");
|
||||||
|
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||||
|
for i in 0..z.len() {
|
||||||
|
let mut f = z.by_index(i).expect("entry");
|
||||||
|
out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start");
|
||||||
|
if f.name() == "ppt/slides/_rels/slide2.xml.rels" {
|
||||||
|
std::io::Write::write_all(&mut out, rels2.as_bytes()).expect("write");
|
||||||
|
} else {
|
||||||
|
std::io::copy(&mut f, &mut out).expect("copy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import");
|
||||||
|
assert_eq!(q.slides[0].comments.len(), 2);
|
||||||
|
assert!(q.slides[1].comments.is_empty(), "{:?}", q.slides[1].comments);
|
||||||
|
}
|
||||||
|
|
||||||
/// A legacy-only package (what older tools read and write) still imports: 1/8 pt positions,
|
/// A legacy-only package (what older tools read and write) still imports: 1/8 pt positions,
|
||||||
/// flattened replies.
|
/// flattened replies.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -287,3 +287,34 @@ fn unreadable_slide_is_reported() {
|
|||||||
assert_eq!(lost, vec!["ppt/slides/slide2.xml".to_string()]);
|
assert_eq!(lost, vec!["ppt/slides/slide2.xml".to_string()]);
|
||||||
assert_eq!(deckcraft_pptx::import(&broken).expect("import").slides.len(), 1);
|
assert_eq!(deckcraft_pptx::import(&broken).expect("import").slides.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Many slides naming one notes part: it is read for the first slide only, not copied onto every
|
||||||
|
/// slide that names it.
|
||||||
|
#[test]
|
||||||
|
fn a_shared_notes_part_is_read_once() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
let mut p = defaults::new_presentation(None);
|
||||||
|
let layout = p.slides[0].layout;
|
||||||
|
for _ in 0..3 {
|
||||||
|
let s = defaults::new_slide(&mut p, layout).expect("slide");
|
||||||
|
p.slides.push(Arc::new(s));
|
||||||
|
}
|
||||||
|
for (i, s) in p.slides.iter_mut().enumerate() {
|
||||||
|
Arc::make_mut(s).notes = deckcraft_model::text::TextBody::from_text(&format!("notes {i}"));
|
||||||
|
}
|
||||||
|
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
|
||||||
|
for (n, d) in es.iter_mut() {
|
||||||
|
if n.starts_with("ppt/slides/_rels/") {
|
||||||
|
let x = String::from_utf8(d.clone()).expect("utf8");
|
||||||
|
let x = x
|
||||||
|
.replace("notesSlide2.xml", "notesSlide1.xml")
|
||||||
|
.replace("notesSlide3.xml", "notesSlide1.xml")
|
||||||
|
.replace("notesSlide4.xml", "notesSlide1.xml");
|
||||||
|
*d = x.into_bytes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
|
||||||
|
assert_eq!(q.slides.len(), 4);
|
||||||
|
assert_eq!(q.slides[0].notes.text(), "notes 0");
|
||||||
|
assert!(q.slides[1..].iter().all(|s| s.notes.text().is_empty()), "a shared notes part is not copied");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user