Compare commits

..
4 changed files with 113 additions and 53 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. /// 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}"));
} }
} }
} }
-16
View File
@@ -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");
+39 -11
View File
@@ -11,6 +11,10 @@ use crate::geom::{Op, P};
/// Line segments per Bézier when a crossing shape is flattened. /// Line segments per Bézier when a crossing shape is flattened.
const CURVE_STEPS: usize = 16; const CURVE_STEPS: usize = 16;
/// Point-against-edge tests one clipped item may cost (all its subpaths together); an item over
/// it is dropped, as a single polygon over it already was.
const MAX_CLIP_WORK: usize = 1_000_000;
/// The bounding box `[l, t, r, b]` of the points in `ops` (control points included). /// The bounding box `[l, t, r, b]` of the points in `ops` (control points included).
pub(crate) fn ops_bbox(ops: &[Op]) -> Option<[f64; 4]> { pub(crate) fn ops_bbox(ops: &[Op]) -> Option<[f64; 4]> {
let mut b: Option<[f64; 4]> = None; let mut b: Option<[f64; 4]> = None;
@@ -100,14 +104,20 @@ fn is_rect(clip: &[P]) -> bool {
/// Sutherland–Hodgman: `poly` cut by convex `clip`. /// Sutherland–Hodgman: `poly` cut by convex `clip`.
pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec<P> { pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec<P> {
if poly.len().saturating_mul(clip.len()) > 1_000_000 { clip_polygon_within(poly, clip, &mut { MAX_CLIP_WORK }).unwrap_or_default()
return Vec::new(); }
}
/// [`clip_polygon`] charging each point visited against each clip edge to `budget`: the polygon
/// can gain a vertex at every edge, so its cost isn't known from its input size alone. `None`
/// once the budget is spent.
fn clip_polygon_within(poly: &[P], clip: &[P], budget: &mut usize) -> Option<Vec<P>> {
let mut poly = poly.to_vec(); let mut poly = poly.to_vec();
for i in 0..clip.len() { for i in 0..clip.len() {
if poly.len().saturating_mul(clip.len() - i) > 1_000_000 { // The work actually done: this edge visits the polygon as it now stands.
return Vec::new(); if poly.len() > *budget {
return None;
} }
*budget -= poly.len();
let (a, b) = (clip[i], clip[(i + 1) % clip.len()]); let (a, b) = (clip[i], clip[(i + 1) % clip.len()]);
let keep = |p: P| cross(a, b, p) >= 0.0; let keep = |p: P| cross(a, b, p) >= 0.0;
let cut = |p: P, q: P| { let cut = |p: P, q: P| {
@@ -133,7 +143,7 @@ pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec<P> {
break; break;
} }
} }
poly Some(poly)
} }
fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool { fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool {
@@ -158,9 +168,12 @@ pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec<Raw> {
return Vec::new(); return Vec::new();
} }
let subs = flatten(&ops); let subs = flatten(&ops);
if too_much_work(&subs, clip, usize::from(fill.is_some()) + usize::from(stroke.is_some())) {
return Vec::new();
}
let mut out = Vec::new(); let mut out = Vec::new();
if fill.is_some() { if fill.is_some() {
let f = clip_fill(&subs, clip); let Some(f) = clip_fill(&subs, clip) else { return Vec::new() };
if !f.is_empty() { if !f.is_empty() {
out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() }); out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() });
} }
@@ -181,7 +194,11 @@ pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec<Raw> {
if disjoint(b, cb) { if disjoint(b, cb) {
return Vec::new(); return Vec::new();
} }
let f = clip_fill(&flatten(&ops), clip); let subs = flatten(&ops);
if too_much_work(&subs, clip, 1) {
return Vec::new();
}
let f = clip_fill(&subs, clip).unwrap_or_default();
if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile, region }] } if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile, region }] }
} }
Raw::Bitmap { rect, img, region } => crop(rect, img, clip).map(|(rect, img)| vec![Raw::Bitmap { rect, img, region }]).unwrap_or_default(), Raw::Bitmap { rect, img, region } => crop(rect, img, clip).map(|(rect, img)| vec![Raw::Bitmap { rect, img, region }]).unwrap_or_default(),
@@ -230,17 +247,28 @@ pub(crate) fn flatten(ops: &[Op]) -> Vec<(Vec<P>, bool)> {
subs subs
} }
fn clip_fill(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> { /// Whether cutting every subpath of one item by `clip` would exceed [`MAX_CLIP_WORK`]; the
/// per-polygon check alone lets many small subpaths each spend the whole allowance. `passes` is
/// how many clipping passes the item gets (fill and stroke each cut every subpath), so one item
/// shares a single allowance between them.
fn too_much_work(subs: &[(Vec<P>, bool)], clip: &[P], passes: usize) -> bool {
subs.iter().map(|s| s.0.len() + 1).sum::<usize>().saturating_mul(clip.len()).saturating_mul(passes) > MAX_CLIP_WORK
}
/// The clipped fill of every subpath, its clipping work shared by all of them: `None` (the whole
/// item goes) once [`MAX_CLIP_WORK`] is spent.
fn clip_fill(subs: &[(Vec<P>, bool)], clip: &[P]) -> Option<Vec<Op>> {
let mut ops = Vec::new(); let mut ops = Vec::new();
let mut budget = MAX_CLIP_WORK;
for (pts, _) in subs { for (pts, _) in subs {
let poly = clip_polygon(pts, clip); let poly = clip_polygon_within(pts, clip, &mut budget)?;
if poly.len() >= 3 { if poly.len() >= 3 {
ops.push(Op::M(poly[0])); ops.push(Op::M(poly[0]));
ops.extend(poly[1..].iter().map(|p| Op::L(*p))); ops.extend(poly[1..].iter().map(|p| Op::L(*p)));
ops.push(Op::Z); ops.push(Op::Z);
} }
} }
ops Some(ops)
} }
fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> { fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
+60
View File
@@ -2074,3 +2074,63 @@ fn clip_polygon_work_bound() {
let clipped = crate::cliprect::clip_polygon(&poly, &clip); let clipped = crate::cliprect::clip_polygon(&poly, &clip);
assert!(clipped.is_empty(), "excessive polygon clip product rejected"); assert!(clipped.is_empty(), "excessive polygon clip product rejected");
} }
#[test]
fn clip_polygon_charges_actual_work_not_projected_growth() {
// Two convex 1,000-gons: the product is exactly the budget, and a cut that adds a vertex must
// not make a later step look over budget.
let gon = |cx: f64| -> Vec<(f64, f64)> {
(0..1000).map(|i| (i as f64 / 1000.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (cx + 100.0 * c, 100.0 * s)).collect()
};
let clipped = crate::cliprect::clip_polygon(&gon(0.0), &gon(30.0));
assert!(clipped.len() > 100, "the intersection is kept: {}", clipped.len());
}
#[test]
fn clip_work_is_shared_by_fill_and_stroke() {
use crate::canvas::Raw;
use crate::geom::Op;
// One zigzag subpath crossing a 512-gon's edge: about 510k point-edge tests per pass, so a
// fill alone fits the allowance but a fill plus a stroke would spend it twice.
let clip: Vec<(f64, f64)> =
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
let mut ops = vec![Op::M((0.0, 95.0))];
ops.extend((1..1000).map(|i| Op::L((i as f64 * 0.19, if i % 2 == 0 { 95.0 } else { 300.0 }))));
ops.push(Op::Z);
let raw = |fill, stroke| Raw::Path { ops: ops.clone(), fill, stroke, even_odd: false, region: None };
let black = [0, 0, 0, 255];
assert!(!crate::cliprect::clip_raw(raw(Some(black), None), &clip).is_empty(), "a fill alone is within the allowance");
assert!(crate::cliprect::clip_raw(raw(Some(black), Some((black, 1.0))), &clip).is_empty(), "fill and stroke share one allowance");
}
#[test]
fn clip_work_is_bounded_across_subpaths() {
use crate::canvas::Raw;
use crate::geom::Op;
// 512-gon clip; 4,000 small triangles crossing its edge: each under the per-polygon cap, together
// far over it, for both the fill and the stroke.
let clip: Vec<(f64, f64)> =
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
let tri = |x: f64| [Op::M((x, 95.0)), Op::L((x + 1.0, 95.0)), Op::L((x, 300.0)), Op::Z];
let many: Vec<Op> = (0..4000).flat_map(|i| tri(i as f64 % 200.0)).collect();
let raw = |ops| Raw::Path { ops, fill: Some([0, 0, 0, 255]), stroke: Some(([0, 0, 0, 255], 1.0)), even_odd: false, region: None };
assert!(crate::cliprect::clip_raw(raw(many), &clip).is_empty(), "the whole item's clipping work is bounded");
assert_eq!(crate::cliprect::clip_raw(raw(tri(100.0).to_vec()), &clip).len(), 2, "a small crossing shape is still clipped");
}
/// Clipping a fill can add a vertex at every clip edge, so a polygon's work isn't its input size:
/// shapes that pass the up-front estimate but grow while clipped are charged as they grow, against
/// one budget for the whole item.
#[test]
fn growing_fill_clips_share_one_budget() {
use crate::canvas::Raw;
use crate::geom::Op;
let clip: Vec<(f64, f64)> =
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
// A triangle around the whole circle: clipped, it becomes the 512-gon, about 130,000 steps.
let tri = [Op::M((-1000.0, -1000.0)), Op::L((1000.0, -1000.0)), Op::L((100.0, 1000.0)), Op::Z];
let raw = |n: usize| Raw::Path { ops: tri.repeat(n), fill: Some([0, 0, 0, 255]), stroke: None, even_odd: false, region: None };
// 400 of them pass the estimate (400 x 4 x 512 is under a million) but cost about 52 million.
assert!(crate::cliprect::clip_raw(raw(400), &clip).is_empty(), "the growing work is charged");
assert_eq!(crate::cliprect::clip_raw(raw(2), &clip).len(), 1, "a couple of them are still clipped");
}