From 6ff301ad3f0e8465f9fad61e4cc91bf143065631 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:47:28 -0700 Subject: [PATCH 1/4] deck: decks with audio or video open Approved by the maintainer (11 Oct 2026): a real 42 MB, 36-slide .pptx with one video wouldn't open, because the import guard refused every audio or video part (held:). Audit (deckcraft d0e57d7e): the decoders, deckcraft-media, are a dependency of the engine crate only (and the egui UI); pptx, format, render, anim and pdf don't depend on them. Inside the engine they are called from media.info and media.posterFrame (cmd/media.rs 100, 123) and from insert.audio and insert.video (cmd/insert.rs 450, 477), the four commands the door already holds back (#448). The package reader keeps a clip's bytes and its poster (pptx read/shapes.rs 259), the renderer draws the poster or a film or speaker glyph (render lib.rs 559-571), and the writers copy the bytes. No path the service runs reaches a decoder. - guard: audio and video parts pass like any other; the content-type, extension, signature and manifest checks go. Every bound stays (entries, names, sizes, totals, ratio, nesting outside ppt/embeddings/, parity), and a deck's media counts against the session's 128 MiB when it opens (too_large:) - tests: a deck with a video (red poster) and a sound opens from a package and a native file, draws the poster (and the glyphs without one), saves natively and reopens, exports a package with the clips' bytes unchanged, and a PDF; hostile clip bytes ride along on every path while the four media commands stay held; a deck past 128 MiB of media is too_large:; the old media-held tests turned around - docs: apps/AGENTS.md, both apps READMEs, ADR 0013's deck row in both languages Co-Authored-By: Claude Opus 5.5 --- apps/AGENTS.md | 10 +- apps/README.md | 2 +- apps/README.zh-CN.md | 2 +- apps/deck/host-service/src/codes.rs | 3 +- apps/deck/host-service/src/doc_tests.rs | 231 +++++++++++++- apps/deck/host-service/src/guard.rs | 283 +++--------------- .../0013-craft-engines-as-pinned-services.md | 2 +- ...-craft-engines-as-pinned-services.zh-CN.md | 2 +- 8 files changed, 286 insertions(+), 249 deletions(-) diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 055ec949..e7923134 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -109,9 +109,15 @@ app's own agent and `tools.json`, the system toolbox, `glance.publish` and engine's reader (`engine_read`, the steps of the pinned `open_presentation` after its sniff, compared by a test). It inflates every entry against its declared size and - refuses a nested zip or an audio or video part (`held:`), an encrypted + refuses a nested zip (`held:`), an encrypted `.pptx` (`protected:`) and a legacy `.ppt` or any other file it does not - know (`unsupported:`); engine-side bounds stay with #448. The one nested + know (`unsupported:`); engine-side bounds stay with #448. Audio and video + open like any other part: the service carries them as bytes (drawn with + their poster, or a film or speaker glyph; saved and exported unchanged) + and nothing it runs decodes or plays them, since deckcraft's decoders + (`deckcraft-media`) are reached only through `media.info`, + `media.posterFrame`, `insert.audio` and `insert.video`, which stay held; + a deck's media counts against the session's 128 MiB (`too_large:`). The one nested zip it lets through is an Office package's embedded object under `ppt/embeddings/` (a chart's workbook, an embedded document), which neither the service nor deckcraft ever unzips; it still counts against diff --git a/apps/README.md b/apps/README.md index f6c923f0..a895c14b 100644 --- a/apps/README.md +++ b/apps/README.md @@ -38,7 +38,7 @@ them. They live in `apps/` of the [OctoSense repository](../README.md); until speech, embedding and asynchronous video requests. Apps declare `model` and exact host API versions; provider entitlement is checked when used. See the [media contract and validation limits](ai-providers/host-service/MEDIA.md). -- **The `deck` host service** (`deck/host-service`, ADR 0013) is the deckcraft presentation engine behind `deck.*`: decks inspected, read as outline text, rendered to PNG, created from titles and bullets, and converted (pptx, native, outline, PDF), confined to the caller's own folder. Its app is Quick Deck (`quickdeck/bundle`), whose calls work in Quick Deck's own storage. For Quick Deck v2 it also keeps open documents (`apps/quickdeck/design/SERVICE.md`): `deck.open` and `deck.create` return a handle, and the methods on it (slides, objects, text, formatting, design, notes, transitions, undo and redo, drawing a slide into a render cache, Present's order and build steps, save and export) each run reviewed engine commands through the same gate as the system agent's `deck.run`, as one undo step. Every file the service opens first passes its own import guard, which refuses a zip beyond its bounds, audio and video, an encrypted `.pptx` and a legacy `.ppt` before the engine reads it. +- **The `deck` host service** (`deck/host-service`, ADR 0013) is the deckcraft presentation engine behind `deck.*`: decks inspected, read as outline text, rendered to PNG, created from titles and bullets, and converted (pptx, native, outline, PDF), confined to the caller's own folder. Its app is Quick Deck (`quickdeck/bundle`), whose calls work in Quick Deck's own storage. For Quick Deck v2 it also keeps open documents (`apps/quickdeck/design/SERVICE.md`): `deck.open` and `deck.create` return a handle, and the methods on it (slides, objects, text, formatting, design, notes, transitions, undo and redo, drawing a slide into a render cache, Present's order and build steps, save and export) each run reviewed engine commands through the same gate as the system agent's `deck.run`, as one undo step. Every file the service opens first passes its own import guard, which refuses a zip beyond its bounds, an encrypted `.pptx` and a legacy `.ppt` before the engine reads it. A deck's audio and video come along as they are: drawn with their poster, saved and exported unchanged, and never decoded or played. - **The `cad` engine service** (`cad/host-service`): the cadcraft drafting engine behind `cad.*` (ADR 0013, no bundle yet) — inspect, query, measure, render and convert DXF/DWG drawings, every path inside the caller's own folder. - **The `light` engine service** (`light/host-service`) is lightcraft's RAW develop engine behind `light.*` (ADR 0013): EXIF/XMP metadata, the develop diff --git a/apps/README.zh-CN.md b/apps/README.zh-CN.md index f7af2b4b..57f92303 100644 --- a/apps/README.zh-CN.md +++ b/apps/README.zh-CN.md @@ -33,7 +33,7 @@ OctoSense-System-Apps 仓库(已归档)。 - **`model` 媒体 API** 使用宿主持有的供应商配置,执行有上限的图片、语音、向量和异步 视频请求。应用声明 `model` 和精确宿主 API 版本;调用时检查供应商权益。见 [媒体契约与验证范围](ai-providers/host-service/MEDIA.zh-CN.md)。 -- **`deck` 宿主服务**(`deck/host-service`,ADR 0013)把 deckcraft 演示文稿引擎放在 `deck.*` 之后:检查幻灯片、提取大纲文本、渲染 PNG、按标题和要点新建演示文稿,以及格式转换(pptx、原生格式、大纲、PDF),全部限制在调用方自己的文件夹内。它的应用是 Quick Deck(`quickdeck/bundle`),其调用在 Quick Deck 自己的存储中运行。它还为 Quick Deck v2 保持打开的文档(`apps/quickdeck/design/SERVICE.md`):`deck.open` 和 `deck.create` 返回一个句柄,作用于它的方法(幻灯片、对象、文本、格式、设计、备注、切换效果、撤销与重做、把幻灯片绘制到渲染缓存、放映的顺序与动画步骤、保存与导出)都通过与系统代理的 `deck.run` 相同的入口运行经过审查的引擎命令,每次调用合为一个撤销步骤。服务打开的每个文件都先通过它自己的导入检查:在引擎读取之前,拒绝超出界限的 zip、音频和视频、加密的 `.pptx` 以及旧版 `.ppt`。 +- **`deck` 宿主服务**(`deck/host-service`,ADR 0013)把 deckcraft 演示文稿引擎放在 `deck.*` 之后:检查幻灯片、提取大纲文本、渲染 PNG、按标题和要点新建演示文稿,以及格式转换(pptx、原生格式、大纲、PDF),全部限制在调用方自己的文件夹内。它的应用是 Quick Deck(`quickdeck/bundle`),其调用在 Quick Deck 自己的存储中运行。它还为 Quick Deck v2 保持打开的文档(`apps/quickdeck/design/SERVICE.md`):`deck.open` 和 `deck.create` 返回一个句柄,作用于它的方法(幻灯片、对象、文本、格式、设计、备注、切换效果、撤销与重做、把幻灯片绘制到渲染缓存、放映的顺序与动画步骤、保存与导出)都通过与系统代理的 `deck.run` 相同的入口运行经过审查的引擎命令,每次调用合为一个撤销步骤。服务打开的每个文件都先通过它自己的导入检查:在引擎读取之前,拒绝超出界限的 zip、加密的 `.pptx` 以及旧版 `.ppt`。演示文稿中的音频和视频会原样保留:显示其海报图,保存和导出时不作改动,并且从不解码或播放。 - **`cad` 引擎服务**(`cad/host-service`):cadcraft 制图引擎,提供 `cad.*` 方法(ADR 0013,暂无 bundle)——在调用方自己的文件夹内检查、查询、测量、渲染和转换 DXF/DWG 图纸。 - **`light` 引擎服务**(`light/host-service`)把 lightcraft 的 RAW 显影引擎 放在 `light.*` 方法之后(ADR 0013):EXIF/XMP 元数据、显影控制目录、单张与 diff --git a/apps/deck/host-service/src/codes.rs b/apps/deck/host-service/src/codes.rs index 58f25508..e5b5a9f8 100644 --- a/apps/deck/host-service/src/codes.rs +++ b/apps/deck/host-service/src/codes.rs @@ -21,7 +21,8 @@ pub(crate) enum Code { Unsupported, /// An encrypted `.pptx`, which needs a password. Protected, - /// The deck holds audio or video, or its zip is beyond the import guard + /// The deck's zip is beyond the import guard (a zip inside the zip, past + /// a bound), or a command the door holds back until deckcraft bounds it /// (#448). Held, /// Over one of the service's caps. diff --git a/apps/deck/host-service/src/doc_tests.rs b/apps/deck/host-service/src/doc_tests.rs index 708e06f3..9542ad24 100644 --- a/apps/deck/host-service/src/doc_tests.rs +++ b/apps/deck/host-service/src/doc_tests.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use deckcraft_engine::cmd::file as engine_file; +use deckcraft_engine::render; use deckcraft_engine::Session; use deckcraft_model::text::RunKind; use deckcraft_model::{Presentation, ShapeId}; @@ -475,7 +476,6 @@ fn hostile() -> Vec<(&'static str, Vec, &'static str)> { let nested = fx::package(vec![Part::stored("ppt/slides/inner.zip", &fx::zip(&[Part::stored("a", b"b")]))], ""); // A chart's workbook that is a zip bomb: refused by its ratio. let workbook_bomb = fx::package(vec![Part::deflated("ppt/embeddings/Microsoft_Excel_Worksheet1.xlsx", &[fx::zip(&[Part::stored("a", b"b")]), vec![0u8; 16 << 20]].concat())], ""); - let media = fx::package(vec![Part::stored("ppt/media/media1.mp4", b"\0\0\0\x18ftypisom")], ""); // A zip whose end record is `end(directory size, directory offset)`. let ended = |parts: &[Part], end: &dyn Fn(u32, u32) -> Vec| { let l = fx::layout(parts); @@ -515,7 +515,6 @@ fn hostile() -> Vec<(&'static str, Vec, &'static str)> { ("hidden_end.pptx", hidden, "damaged"), ("prepended.pptx", prepended, "damaged"), ("shifted.pptx", shifted, "damaged"), - ("media.pptx", media, "held"), ("locked.pptx", fx::ole(&["EncryptionInfo", "EncryptedPackage"]), "protected"), ("legacy.ppt", fx::ole(&["PowerPoint Document", "Current User"]), "unsupported"), ] @@ -625,16 +624,16 @@ fn a_package_with_its_content_types_late_opens_on_every_path() { w.ok("convert", json!({ "path": "late.pptx", "out": "late.deckcraft" })); w.ok("run", json!({ "path": "late.pptx", "cmds": [] })); assert_eq!(engine_reads(), before + 7, "every path read it through the engine"); - // Still behind every bound: the same layout with a movie in it, with a - // zip bomb in it, or with its entry counts disagreeing never reaches + // Still behind every bound: the same layout with a zip inside it, with + // a zip bomb in it, or with its entry counts disagreeing never reaches // the engine. - let movie = fx::with_last(&pptx(&sample(4)), "[Content_Types].xml", vec![Part::stored("ppt/media/clip.mp4", b"\0\0\0\x18ftypisom")]); + let nested = fx::with_last(&pptx(&sample(4)), "[Content_Types].xml", vec![Part::stored("ppt/slides/inner.zip", &fx::zip(&[Part::stored("a", b"b")]))]); let bomb = fx::with_last(&pptx(&sample(4)), "[Content_Types].xml", vec![Part::deflated("ppt/slides/slide9.xml", &vec![0u8; 16 << 20])]); let mut counted = late.clone(); let end = counted.len() - 22; let total = u16::from_le_bytes([counted[end + 10], counted[end + 11]]); counted[end + 8..end + 10].copy_from_slice(&(total + 1).to_le_bytes()); - for (name, bytes, code) in [("late-movie.pptx", movie, "held"), ("late-bomb.pptx", bomb, "held"), ("late-counted.pptx", counted, "damaged")] { + for (name, bytes, code) in [("late-nested.pptx", nested, "held"), ("late-bomb.pptx", bomb, "held"), ("late-counted.pptx", counted, "damaged")] { w.put(name, &bytes); let before = engine_reads(); coded(&w.err("open", json!({ "path": name })), code); @@ -740,6 +739,226 @@ fn the_inspector_reads_the_decks_design_and_its_objects() { assert_eq!((options["header_row"].clone(), options["banded_columns"].clone(), options["banded_rows"].clone()), (json!(false), json!(true), json!(true)), "{options}"); } +// ------------------------------------------------------------ audio and video + +/// A solid red picture, `n` pixels square. +fn red_png(n: u32) -> Vec { + render::Image { width: n, height: n, pixels: [255u8, 0, 0, 255].repeat((n * n) as usize) }.to_png() +} + +/// The straight colour of pixel (`x`, `y`) of `img`. +fn rgba(img: &render::Image, x: u32, y: u32) -> [u8; 4] { + let px = img.to_straight(); + let i = ((y * img.width + x) * 4) as usize; + [px[i], px[i + 1], px[i + 2], px[i + 3]] +} + +/// Where `with_clips` puts its video on slide 1 (points), and its sound on +/// slide 3. +const VIDEO_BOX: [f64; 4] = [700.0, 420.0, 240.0, 100.0]; +const SOUND_BOX: [f64; 4] = [700.0, 380.0, 96.0, 96.0]; + +/// A deck as deckcraft writes one, with a video on slide 1 (with a red +/// poster, unless `poster` is false) and a sound on slide 3, their bytes +/// `video` and `sound`: carried, never decoded. +fn with_clips(video: &[u8], sound: &[u8], poster: bool) -> Presentation { + use deckcraft_model::{MediaClip, MediaId, MediaItem, Shape, ShapeKind, Xfrm}; + let mut p = sample(3); + let clip = |p: &mut Presentation, name: &str, content_type: &str, data: Vec| { + let id = MediaId(p.alloc_id()); + p.media.push(MediaItem { id, name: name.into(), content_type: content_type.into(), data: Arc::new(data), link: None }); + id + }; + let film = clip(&mut p, "media1.mp4", "video/mp4", video.to_vec()); + let still = poster.then(|| clip(&mut p, "image9.png", "image/png", red_png(8))); + let tone = clip(&mut p, "media2.m4a", "audio/mp4", sound.to_vec()); + let shape = |p: &mut Presentation, name: &str, [x, y, w, h]: [f64; 4], clip: MediaClip| Shape { + id: ShapeId(p.alloc_id()), + name: name.into(), + xfrm: Some(Xfrm::new(x, y, w, h)), + kind: ShapeKind::Media(clip), + ..Default::default() + }; + let video_shape = shape(&mut p, "Video 1", VIDEO_BOX, MediaClip { media: film, video: true, poster: still, volume: 1.0, ..Default::default() }); + let sound_shape = shape(&mut p, "Sound 1", SOUND_BOX, MediaClip { media: tone, video: false, volume: 1.0, ..Default::default() }); + Arc::make_mut(&mut p.slides[0]).shapes.push(video_shape); + Arc::make_mut(&mut p.slides[2]).shapes.push(sound_shape); + p +} + +/// The deck the open document `doc` holds. +fn held_deck(doc: &str) -> Presentation { + docs::with_open(doc, |d| d.session.doc().unwrap().doc.as_ref().clone()).unwrap() +} + +/// Bytes that would trip a video decoder if one ever saw them: an MP4 whose +/// boxes claim 4 GiB and 16 EiB and a sample table of four billion samples. +fn hostile_movie() -> Vec { + let mut b = b"\0\0\0\x18ftypisom\0\0\x02\0isomiso2".to_vec(); + b.extend(0xFFFF_FFFFu32.to_be_bytes()); + b.extend(b"moov"); + b.extend(0x14u32.to_be_bytes()); + b.extend(b"stsz\0\0\0\0"); + b.extend(0xFFFF_FFFFu32.to_be_bytes()); + b.extend(0xFFFF_FFFFu32.to_be_bytes()); + b.extend(1u32.to_be_bytes()); + b.extend(b"mdat"); + b.extend(u64::MAX.to_be_bytes()); + b.extend((0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8)); + b +} + +/// The same for a sound: a WAV of 65,535 channels whose data claims 4 GiB. +fn hostile_sound() -> Vec { + let mut b = b"RIFF\xFF\xFF\xFF\xFFWAVEfmt \x10\0\0\0\x01\0\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\x20\0data".to_vec(); + b.extend(0xFFFF_FFFFu32.to_be_bytes()); + b.extend((0..2048u32).map(|i| (i.wrapping_mul(40_503) >> 8) as u8)); + b +} + +/// A deck holding a video and a sound opens from a package and from a +/// native file, draws the video's poster (and a film glyph without one, a +/// speaker glyph for the sound), saves natively and opens again, and +/// exports a package with the clips' bytes unchanged, and a PDF. +#[test] +fn a_deck_with_video_and_sound_opens_draws_saves_and_exports() { + let w = World::new(); + let video = b"\0\0\0\x18ftypisom\0\0\x02\0isomiso2mp41 a small movie".to_vec(); + let sound = b"\0\0\0\x18ftypM4A \0\0\0\0M4A isom a small sound".to_vec(); + let deck = with_clips(&video, &sound, true); + w.put("clips.pptx", &pptx(&deck)); + w.put("clips.deckcraft", &native(&deck)); + let carries = |d: &Presentation| d.media.iter().any(|m| *m.data == video) && d.media.iter().any(|m| *m.data == sound); + let [vx, vy, vw, vh] = VIDEO_BOX; + let centre = ((vx + vw / 2.0) as u32, (vy + vh / 2.0) as u32); + for path in ["clips.pptx", "clips.deckcraft"] { + let doc = w.open(path); + let held = held_deck(&doc); + assert!(carries(&held), "{path}: the clips come through as bytes"); + // The video's poster, drawn where the video sits. + let img = render::render_slide(&held, 0, &render::RenderOpts { scale: 1.0, ..Default::default() }); + let [r, g, b, _] = rgba(&img, centre.0, centre.1); + assert!(r > 200 && g < 60 && b < 60, "{path}: the poster at the video's centre is red, not {:?}", (r, g, b)); + let ids = w.slide_ids(&doc); + for step in [None, Some(0)] { + let mut args = json!({ "doc": doc, "slide": ids[0], "width": 960 }); + if let Some(n) = step { + args["step"] = json!(n); + } + w.ok("view", args); + } + w.ok("cover", json!({ "path": path })); + // Saved natively, it opens again with the same bytes. + let saved = format!("saved-{}.deckcraft", path.replace('.', "-")); + w.ok("save", json!({ "doc": doc, "path": saved })); + let again = w.open(&saved); + assert!(carries(&held_deck(&again)), "{path}: saved and opened again"); + // A package export carries the clips' bytes unchanged; a PDF draws. + let out = format!("out-{}.pptx", path.replace('.', "-")); + w.ok("export", json!({ "doc": doc, "kind": "pptx", "out": out })); + let exported = std::fs::read(w.root.join(&out)).unwrap(); + assert!(carries(&deckcraft_pptx::import(&exported).unwrap()), "{path}: the package export keeps the bytes"); + assert!(carries(&held_deck(&w.open(&out))), "{path}: and opens again"); + let pdf = w.ok("export", json!({ "doc": doc, "kind": "pdf", "out": format!("out-{}.pdf", path.replace('.', "-")) })); + assert_eq!(pdf["pages"], 3, "{pdf}"); + for d in [doc, again] { + w.ok("close", json!({ "doc": d, "discard": true })); + } + } + // Without a poster, the video is a dark frame with a film glyph; the + // sound, a speaker glyph. + let bare = with_clips(&video, &sound, false); + let img = render::render_slide(&bare, 0, &render::RenderOpts { scale: 1.0, ..Default::default() }); + let [r, g, b, _] = rgba(&img, (vx + 4.0) as u32, (vy + 4.0) as u32); + assert!(r < 60 && g < 60 && b < 60, "the dark frame behind the glyph, not {:?}", (r, g, b)); + let mut silent = bare.clone(); + Arc::make_mut(&mut silent.slides[2]).shapes.pop(); + let opts = render::RenderOpts { scale: 1.0, ..Default::default() }; + assert_ne!(render::render_slide(&bare, 2, &opts).pixels, render::render_slide(&silent, 2, &opts).pixels, "the speaker glyph is drawn"); +} + +/// Clip bytes that would trip a decoder (`hostile_movie`, `hostile_sound`) +/// ride along on every path: opened, inspected, drawn with and without a +/// build step, presented, saved, exported every way, edited, undone and +/// redone. That proves nothing the service runs decodes them. The four +/// commands that would decode them stay held through the door. +#[test] +fn hostile_clip_bytes_are_carried_on_every_path_and_never_decoded() { + let w = World::new(); + let (movie, noise) = (hostile_movie(), hostile_sound()); + let deck = with_clips(&movie, &noise, false); + w.put("hostile.pptx", &pptx(&deck)); + w.put("hostile.deckcraft", &native(&deck)); + for path in ["hostile.pptx", "hostile.deckcraft"] { + // The file methods, the agent's tools among them. + w.ok("info", json!({ "path": path })); + w.ok("text", json!({ "path": path })); + for out in ["r.png", "c.pptx", "c.deckcraft", "c.txt", "c.pdf"] { + let (method, args) = if out.ends_with(".png") { ("render", json!({ "path": path, "out": format!("{path}-{out}") })) } else { ("convert", json!({ "path": path, "out": format!("{path}-{out}") })) }; + w.ok(method, args); + } + w.ok("run", json!({ "path": path, "cmds": [{ "id": "document.inspect" }, { "id": "slide.inspect", "params": { "index": 0 } }] })); + for id in ["media.info", "media.posterFrame", "insert.video", "insert.audio"] { + let e = w.err("run", json!({ "path": path, "cmds": [{ "id": "slide.go", "params": { "index": 0 } }, { "id": id }] })); + assert!(e.contains(&format!("`{id}` is held back")), "{id}: {e}"); + } + // The open document, on every method that reads, draws or writes. + let doc = w.open(path); + let ids = w.slide_ids(&doc); + w.ok("slides", json!({ "doc": doc })); + for (i, s) in ids.iter().enumerate() { + w.ok("slide", json!({ "doc": doc, "slide": s })); + w.ok("view", json!({ "doc": doc, "slide": s, "width": 640 })); + w.ok("view", json!({ "doc": doc, "slide": s, "width": 640, "step": 0 })); + w.ok("view", json!({ "doc": doc, "slide": s, "width": 320, "edit": true })); + assert!(w.ok("hit", json!({ "doc": doc, "slide": s, "at": [10, 10] })).is_object(), "slide {i}"); + } + w.ok("show", json!({ "doc": doc })); + w.ok("layouts", json!({ "doc": doc })); + w.ok("cover", json!({ "path": path })); + w.ok("notes", json!({ "doc": doc, "slide": ids[0], "text": "Hostile bytes ride along" })); + w.ok("slides_op", json!({ "doc": doc, "op": "duplicate", "slides": [ids[0]] })); + w.ok("undo", json!({ "doc": doc })); + w.ok("undo", json!({ "doc": doc })); + w.ok("redo", json!({ "doc": doc })); + let stem = path.replace('.', "-"); + w.ok("save", json!({ "doc": doc, "path": format!("{stem}-saved.deckcraft") })); + w.ok("export", json!({ "doc": doc, "kind": "pptx", "out": format!("{stem}.pptx") })); + w.ok("export", json!({ "doc": doc, "kind": "pdf", "out": format!("{stem}.pdf") })); + w.ok("export", json!({ "doc": doc, "kind": "images", "out_dir": format!("{stem}-pictures"), "width": 320 })); + w.ok("export", json!({ "doc": doc, "kind": "outline", "out": format!("{stem}.txt") })); + let held = held_deck(&doc); + assert!(held.media.iter().any(|m| *m.data == movie) && held.media.iter().any(|m| *m.data == noise), "{path}: carried unchanged"); + w.ok("close", json!({ "doc": doc })); + } +} + +/// A deck holding more media than the door's 128 MiB, each item small +/// enough to stay inside the guard's bounds, is `too_large:` on opening. +#[test] +fn a_deck_past_the_media_ceiling_is_too_large() { + use deckcraft_model::{MediaId, MediaItem}; + let w = World::new(); + let mut p = sample(1); + let item = guard::RATIO_GRACE as usize; + for k in 0..(MAX_RUN_MEDIA as usize / item + 8) { + let mut data = vec![0u8; item]; + data[..8].copy_from_slice(&(k as u64).to_le_bytes()); + let id = MediaId(p.alloc_id()); + p.media.push(MediaItem { id, name: format!("filler{k}.bin"), content_type: "application/octet-stream".into(), data: Arc::new(data), link: None }); + } + let bytes = native(&p); + assert!(bytes.len() < 4 << 20, "a small file: {} bytes", bytes.len()); + assert_eq!(guard::check("filled.deckcraft", &bytes), Ok(guard::Format::Native), "inside the guard's bounds"); + w.put("filled.deckcraft", &bytes); + for method in ["open", "cover"] { + let e = w.err(method, json!({ "path": "filled.deckcraft" })); + coded(&e, "too_large"); + assert!(e.contains("bytes of media"), "{method}: {e}"); + } + assert_eq!(docs::open_count(), 0); +} + /// The import guard passes what the engine itself writes, at the sizes the /// service allows: a native deck's `presentation.json` repeats itself from /// slide to slide, so its compression ratio is the one to watch. diff --git a/apps/deck/host-service/src/guard.rs b/apps/deck/host-service/src/guard.rs index dd49d2fb..93676a18 100644 --- a/apps/deck/host-service/src/guard.rs +++ b/apps/deck/host-service/src/guard.rs @@ -42,18 +42,24 @@ //! other zip (`.key`, `.odp`) is `unsupported:`. //! - Then every entry is inflated, bounded, into a scratch buffer (nothing //! is kept but its first bytes): one that inflates past its declared size -//! is `damaged:`. Any audio or video part is `held:`, found by its -//! extension, by the content type the package declares for it -//! (`[Content_Types].xml`, or a native deck's `presentation.json`), or by -//! its first bytes (a movie or sound container's signature). -//! - So is a zip inside the zip, except an Office package's embedded object +//! is `damaged:`. +//! - Audio and video parts pass like any other part. The engine's readers +//! keep them as bytes (a package's media with its own poster picture), +//! and nothing the service runs decodes them: deckcraft's decoders +//! (`deckcraft-media`) are a dependency of its engine only, reached only +//! through `media.info`, `media.posterFrame`, `insert.audio` and +//! `insert.video`, which the door holds back (#448). The renderer draws a +//! clip's poster, or a film or speaker glyph, and the writers copy its +//! bytes. The media a deck holds counts against the session's 128 MiB +//! once it opens (`too_large:`, `crate::Held`). +//! - A zip inside the zip is `held:`, except an Office package's embedded object //! ([`EMBEDDINGS`]): a chart's workbook or an embedded document is a zip //! that neither the service nor the engine ever unzips (deckcraft's only //! zip readers are its package and native readers; it reads a chart from //! the chart's own cached values, and keeps only an embedded object's //! XML and preview). Such a part passes, held like every entry to the -//! bounds above (entries, name, declared and actual size, ratio, totals) -//! and to the media checks; its own entries are never read, by anyone. +//! bounds above (entries, name, declared and actual size, ratio, +//! totals); its own entries are never read, by anyone. //! - **An OLE compound file** (`D0 CF 11 E0`): one whose directory holds an //! `EncryptedPackage` stream is a password-protected Office file //! (`protected:`); any other is a legacy `.ppt` or not a presentation at @@ -63,11 +69,10 @@ //! //! The guard reads only the bytes the service already read (at most the //! service's 64 MiB a file). Beyond the directory's entries it allocates a -//! 64 KiB inflate buffer, the package's content-type list (at most -//! [`MAX_TYPES_BYTES`]) and, for an OLE file, a sector table of at most one -//! entry a sector; the engine's zip reader, run on a directory the guard -//! has bounded, holds one record an entry. Engine-side bounds stay tracked -//! in #448. +//! 64 KiB inflate buffer and, for an OLE file, a sector table of at most +//! one entry a sector; the engine's zip reader, run on a directory the +//! guard has bounded, holds one record an entry. Engine-side bounds stay +//! tracked in #448. use std::io::Read; @@ -86,8 +91,6 @@ pub(crate) const MAX_RATIO: u64 = 100; /// Entries up to this size are not held to [`MAX_RATIO`]: a small blank /// picture or printer setting compresses far better than that. pub(crate) const RATIO_GRACE: u64 = 100 << 10; -/// The most of `[Content_Types].xml` the guard reads for media types. -pub(crate) const MAX_TYPES_BYTES: u64 = 1 << 20; /// The most extensible data a ZIP64 end record may carry beyond its 44 /// bytes of fields: the engine's zip reader allocates all of it, and an /// honest writer leaves it empty. @@ -177,13 +180,6 @@ struct Entry { offset: u64, } -impl Entry { - fn extension(&self) -> String { - let file = self.name.rsplit('/').next().unwrap_or(""); - file.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default() - } -} - /// A zip's central directory: its entries, and where it starts. #[derive(Debug)] struct Directory { @@ -572,53 +568,6 @@ fn inflate(b: &[u8], e: &Entry, keep: bool, watch: &mut dyn FnMut(&[u8])) -> Res Ok(out) } -/// File extensions of movies and sounds. -const MEDIA_EXTENSIONS: &[&str] = &[ - "mp4", "m4v", "mov", "qt", "wmv", "asf", "avi", "webm", "mkv", "mka", "mpg", "mpeg", "mpe", "m1v", "m2v", "3gp", "3g2", "flv", "ogv", "ogg", - "oga", "opus", "mp3", "wav", "wave", "m4a", "m4b", "aac", "wma", "flac", "aif", "aiff", "aifc", "mid", "midi", "au", "snd", "caf", "amr", - "swf", "mts", "m2ts", -]; - -/// The kind of movie or sound container `head` starts with, by its -/// signature (strong signatures only: a two-byte frame sync would also -/// match the first bytes of obfuscated fonts and other binary parts). -fn media_signature(head: &[u8]) -> Option<&'static str> { - let at = |i: usize, sig: &[u8]| head.get(i..i + sig.len()) == Some(sig); - if at(4, b"ftyp") { - Some("an MP4 or QuickTime movie or sound") - } else if at(0, b"RIFF") && at(8, b"WAVE") { - Some("a WAV sound") - } else if at(0, b"RIFF") && at(8, b"AVI ") { - Some("an AVI movie") - } else if at(0, b"OggS") { - Some("an Ogg movie or sound") - } else if at(0, b"fLaC") { - Some("a FLAC sound") - } else if at(0, &[0x1A, 0x45, 0xDF, 0xA3]) { - Some("a Matroska or WebM movie or sound") - } else if at(0, &[0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11]) { - Some("a Windows Media movie or sound") - } else if at(0, b"ID3") { - Some("an MP3 sound") - } else if at(0, b"FORM") && (at(8, b"AIFF") || at(8, b"AIFC")) { - Some("an AIFF sound") - } else if at(0, b"caff") { - Some("a Core Audio sound") - } else if at(0, b"MThd") { - Some("a MIDI sound") - } else if at(0, b"FLV\x01") { - Some("a Flash video") - } else if at(0, b".snd") { - Some("an AU sound") - } else if at(0, &[0, 0, 1, 0xBA]) || at(0, &[0, 0, 1, 0xB3]) { - Some("an MPEG movie") - } else if at(0, b"#!AMR") { - Some("an AMR sound") - } else { - None - } -} - /// Whether `head` starts a zip archive: a zip inside the zip. fn zip_signature(head: &[u8]) -> bool { head.starts_with(b"PK\x03\x04") || head.starts_with(b"PK\x05\x06") || head.starts_with(b"PK\x07\x08") @@ -636,100 +585,6 @@ fn embedded_object(name: &str) -> bool { lower.strip_prefix(EMBEDDINGS).is_some_and(|rest| !rest.contains('\\') && rest.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..")) } -/// Pairs of names, lowercased. -type Pairs = Vec<(String, String)>; - -/// `[Content_Types].xml`'s declarations: (extension, type) defaults and -/// (part name, type) overrides, lowercased, part names without their -/// leading `/`. -fn content_types(xml: &str) -> (Pairs, Pairs) { - let mut defaults = Vec::new(); - let mut overrides = Vec::new(); - let mut rest = xml; - while let Some(lt) = rest.find('<') { - rest = &rest[lt + 1..]; - let end = rest.find('>').unwrap_or(rest.len()); - let tag = &rest[..end]; - let name_end = tag.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(tag.len()); - let (name, attrs) = tag.split_at(name_end); - let attr = |key: &str| -> Option { - let mut a = attrs; - while let Some(i) = a.find(key) { - let before_ok = a[..i].chars().next_back().is_none_or(char::is_whitespace); - let after = a[i + key.len()..].trim_start(); - if before_ok && after.starts_with('=') { - let value = after[1..].trim_start(); - let quote = value.chars().next()?; - if quote == '"' || quote == '\'' { - let body = &value[1..]; - return body.find(quote).map(|j| body[..j].to_string()); - } - } - a = &a[i + key.len()..]; - } - None - }; - match name { - "Default" => { - if let (Some(ext), Some(ct)) = (attr("Extension"), attr("ContentType")) { - defaults.push((ext.to_ascii_lowercase(), ct.to_ascii_lowercase())); - } - } - "Override" => { - if let (Some(part), Some(ct)) = (attr("PartName"), attr("ContentType")) { - overrides.push((part.trim_start_matches('/').to_ascii_lowercase(), ct.to_ascii_lowercase())); - } - } - _ => {} - } - rest = &rest[end.min(rest.len())..]; - } - (defaults, overrides) -} - -/// A streaming search for `needles` across the chunks of one entry. -struct Scan { - needles: &'static [&'static [u8]], - tail: Vec, - found: bool, -} - -impl Scan { - fn new(needles: &'static [&'static [u8]]) -> Scan { - Scan { needles, tail: Vec::new(), found: false } - } - - fn feed(&mut self, chunk: &[u8]) { - if self.found { - return; - } - let longest = self.needles.iter().map(|n| n.len()).max().unwrap_or(1); - let mut window = std::mem::take(&mut self.tail); - window.extend_from_slice(chunk); - let first: Vec = self.needles.iter().filter_map(|n| n.first().copied()).collect(); - let mut i = 0; - while i < window.len() { - match window[i..].iter().position(|c| first.contains(c)) { - Some(k) => { - let at = i + k; - if self.needles.iter().any(|n| window[at..].starts_with(n)) { - self.found = true; - return; - } - i = at + 1; - } - None => break, - } - } - let keep = window.len().saturating_sub(longest - 1); - self.tail = window.split_off(keep); - } -} - -/// How a native deck's `presentation.json` names the content type of a -/// movie or a sound (`deckcraft_format` writes it compact). -const NATIVE_MEDIA_TYPES: &[&[u8]] = &[b"\"contentType\":\"audio/", b"\"contentType\":\"video/"]; - /// The guard for a zip (module doc). fn zip(b: &[u8]) -> Result { let dir = directory(b)?; @@ -747,51 +602,12 @@ fn zip(b: &[u8]) -> Result { } else { return Err(fail(Code::Unsupported, "this zip is not a presentation the deck engine reads (.pptx or .deckcraft)")); }; - // The content types a package declares, whichever reader takes it. - let (defaults, overrides) = match package { - Some(types) => { - if types.declared > MAX_TYPES_BYTES { - return Err(held(format!("the presentation's content-type list is larger than the {MAX_TYPES_BYTES} bytes the deck service reads (#448)"))); - } - let xml = inflate(b, types, true, &mut |_| {})?.kept.unwrap_or_default(); - content_types(&String::from_utf8_lossy(&xml)) - } - None => (Vec::new(), Vec::new()), - }; for e in entries { - let mut scan = Scan::new(NATIVE_MEDIA_TYPES); - let watch_json = e.name == "presentation.json"; - let inflated = inflate(b, e, false, &mut |chunk| { - if watch_json { - scan.feed(chunk) - } - })?; + let inflated = inflate(b, e, false, &mut |_| {})?; // An Office package's embedded object may be a zip: nothing unzips it. if zip_signature(&inflated.head) && !(format == Format::Pptx && embedded_object(&e.name)) { return Err(held(format!("the presentation holds a zip inside its zip (`{}`), which the deck service does not open (#448)", shown(&e.name)))); } - let lower = e.name.to_ascii_lowercase(); - let declared_type = overrides - .iter() - .find(|(part, _)| *part == lower) - .or_else(|| defaults.iter().find(|(ext, _)| *ext == e.extension())) - .map(|(_, ct)| ct.as_str()); - let media = if MEDIA_EXTENSIONS.contains(&e.extension().as_str()) { - Some("a movie or sound file") - } else if declared_type.is_some_and(|ct| ct.starts_with("audio/") || ct.starts_with("video/")) { - Some("a part its package says is a movie or sound") - } else { - media_signature(&inflated.head) - }; - if let Some(what) = media { - return Err(held(format!( - "the presentation holds video or audio (`{}`, {what}), which the deck service does not open yet (#448)", - shown(&e.name) - ))); - } - if scan.found { - return Err(held("the presentation holds video or audio, which the deck service does not open yet (#448)")); - } } Ok(format) } @@ -1229,7 +1045,7 @@ mod tests { } #[test] - fn embedded_objects_pass_and_other_nested_zips_and_media_are_held() { + fn embedded_objects_and_media_pass_and_other_nested_zips_are_held() { let inner = zip(&[Part::deflated("[Content_Types].xml", b""), Part::deflated("xl/workbook.xml", b"")]); // A chart's workbook, an embedded document or object: zips that // nothing unzips, under the package's embeddings. @@ -1269,22 +1085,30 @@ mod tests { bomb[body + 64..body + 128].fill(0xFF); let e = coded(check("bomb.pptx", &bomb), "held"); assert!(e.contains("100 to 1"), "{e}"); - // Media under the embeddings is still media. - coded(check("movie.pptx", &package(vec![Part::stored("ppt/embeddings/clip.mp4", b"\0\0\0\x18ftypisom")], "")), "held"); - // Media by extension, by declared type, by signature; in a native - // deck by signature and by its manifest's content type. - let e = coded(check("movie.pptx", &package(vec![Part::stored("ppt/media/media1.mp4", b"\0\0\0\0data")], "")), "held"); - assert!(e.contains("video or audio") && e.contains("ppt/media/media1.mp4"), "{e}"); - coded(check("movie.pptx", &package(vec![Part::stored("ppt/media/clip.bin", b"0123")], "")), "held"); - coded(check("sound.pptx", &package(vec![Part::stored("ppt/media/a.dat", b"0123")], "")), "held"); - coded(check("sound.pptx", &package(vec![Part::deflated("ppt/media/a.bin", b"RIFF\x10\0\0\0WAVEfmt ")], "")), "held"); - coded(check("movie.pptx", &package(vec![Part::stored("ppt/media/b.bin", b"\0\0\0\x18ftypisom")], "")), "held"); - coded(check("sound.deckcraft", &native(vec![Part::stored("media/9", b"ID3\x04\0\0\0")], "{}")), "held"); + // Audio and video pass like any other part, however they show: by + // extension, by declared type, by signature, under the embeddings, + // in a native deck by signature and by its manifest's content type. + // Nothing the service runs decodes them (module doc). + let movie = b"\0\0\0\x18ftypisom".as_slice(); + for (name, part, types) in [ + ("movie.pptx", Part::stored("ppt/media/media1.mp4", movie), ""), + ("movie.pptx", Part::stored("ppt/media/clip.bin", b"0123"), ""), + ("sound.pptx", Part::stored("ppt/media/a.dat", b"0123"), ""), + ("sound.pptx", Part::deflated("ppt/media/a.bin", b"RIFF\x10\0\0\0WAVEfmt "), ""), + ("movie.pptx", Part::stored("ppt/embeddings/clip.mp4", movie), ""), + ] { + assert_eq!(check(name, &package(vec![part], types)), Ok(Format::Pptx), "{name}"); + } + assert_eq!(check("sound.deckcraft", &native(vec![Part::stored("media/9", b"ID3\x04\0\0\0")], "{}")), Ok(Format::Native)); let json = "{\"presentation\":{\"media\":[{\"id\":9,\"name\":\"a.wav\",\"contentType\":\"audio/wav\"}]}}"; - let e = coded(check("sound.deckcraft", &native(vec![], json)), "held"); - assert!(e.contains("video or audio"), "by the manifest's content type: {e}"); - // A type declared for an extension no part has is no media. - assert_eq!(check("deck.pptx", &package(vec![], "")), Ok(Format::Pptx)); + assert_eq!(check("sound.deckcraft", &native(vec![], json)), Ok(Format::Native)); + // Media is still held to every bound: a movie that lies about its + // size, or compresses past the ratio, is refused. + let liar = Part { declared: Some(10), ..Part::deflated("ppt/media/media1.mp4", &[movie, &[0u8; 4096]].concat()) }; + coded(check("movie.pptx", &package(vec![liar], "")), "damaged"); + let bombed = package(vec![Part::deflated("ppt/media/media1.mp4", &[movie.to_vec(), vec![0u8; 16 << 20]].concat())], ""); + let e = coded(check("movie.pptx", &bombed), "held"); + assert!(e.contains("100 to 1"), "{e}"); } /// The two parts every package below starts with. @@ -1405,19 +1229,19 @@ mod tests { coded(check("t.pptx", &with([0xCAFEu16.to_le_bytes(), 9u16.to_le_bytes()].concat())), "damaged"); // ZIP64: a field of 24 bytes gives all three values over the 32-bit // ones, to the engine's reader and so to the guard. Here it moves an - // innocent slide onto a movie no header names, which the guard then + // innocent slide onto a zip no header names, which the guard then // finds as that reader would. - let movie = b"\0\0\0\x18ftypisom-a-movie".to_vec(); - let n = movie.len() as u64; + let hidden = zip(&[Part::stored("a", b"b")]); + let n = hidden.len() as u64; let moved = field(0x0001, &[n.to_le_bytes(), n.to_le_bytes(), 0u64.to_le_bytes()].concat()); let parts = vec![ - Part { hidden: true, ..Part::stored("hidden.bin", &movie) }, + Part { hidden: true, ..Part::stored("hidden.bin", &hidden) }, Part::deflated("[Content_Types].xml", b""), Part::deflated("ppt/presentation.xml", b""), Part { extra: moved, ..Part::stored("ppt/slides/slide1.xml", b"") }, ]; let e = coded(check("moved.pptx", &zip(&parts)), "held"); - assert!(e.contains("video or audio") && e.contains("ppt/slides/slide1.xml"), "{e}"); + assert!(e.contains("zip inside its zip") && e.contains("ppt/slides/slide1.xml"), "{e}"); // And a ZIP64 field too short for what it must give. let short = Part { declared: Some(0xFFFF_FFFF), extra: field(0x0001, &[0u8; 4]), ..Part::stored("ppt/x.xml", b"x") }; coded(check("short.pptx", &package(vec![short], "")), "damaged"); @@ -1450,17 +1274,4 @@ mod tests { assert_eq!(ours, zip_of("deckcraft-pptx")); assert_eq!(ours, zip_of("deckcraft-format")); } - - #[test] - fn the_streaming_scan_finds_a_needle_across_chunks() { - const NEEDLES: &[&[u8]] = &[b"\"contentType\":\"audio/"]; - let mut scan = Scan::new(NEEDLES); - scan.feed(b"{\"media\":[{\"content"); - assert!(!scan.found); - scan.feed(b"Type\":\"audio/wav\"}]}"); - assert!(scan.found); - let mut scan = Scan::new(NEEDLES); - scan.feed(b"\"contentType\":\"image/png\""); - assert!(!scan.found); - } } diff --git a/docs/adr/0013-craft-engines-as-pinned-services.md b/docs/adr/0013-craft-engines-as-pinned-services.md index e84c8451..810528f3 100644 --- a/docs/adr/0013-craft-engines-as-pinned-services.md +++ b/docs/adr/0013-craft-engines-as-pinned-services.md @@ -391,7 +391,7 @@ Each engine's surface: | Engine | The system agent's tools | What its door runs beyond `safe` ids | Caps (one call) | | --- | --- | --- | --- | | word | `word.info`, `word.run` | 328 of 389 ids. Reads: `insert.picture`, `picture.change` (`path`). | Tables ≤ 10,000 cells; pages 72–1584 pt a side; a replacement grows the text ≤ 1,000× (chained, ≤ 10,000×); the document ≤ 500,000 characters, 50,000 paragraphs and 128 MiB of pictures; a PDF ≤ 10,000 pages. | -| deck | `deck.info`, `deck.run` | 198 of 222. Reads: `insert.picture`, `picture.change` (`path`). Held back until deckcraft bounds its media and zip parsing (hostile data can abort the shell, #448): `insert.audio`, `insert.video`, `media.info`, `media.posterFrame`, `file.openBytes`. Apps' own requests also reach Quick Deck v2's open documents (`apps/quickdeck/design/SERVICE.md`): each method's commands, with arguments the service builds, pass the same door under the method's own most commands a call, and are folded into one undo step; `shape.fill` and `design.background` run there without `picture` or `path`; the picture reads take a PNG, JPEG, WebP, GIF, TIFF or BMP in the app's storage. Every file the service opens, on every path, first passes its own import guard: a zip's directory read with bounds, only in a shape the engine's own zip reader (the `zip` crate deckcraft uses) reads alike and then checked against that reader's reading, and a zip inside it (except an embedded object under `ppt/embeddings/`, such as a chart's workbook, which nothing unzips), audio and video, an encrypted package and a legacy `.ppt` refused before the engine reads a byte. | Tables ≤ 5,625 cells; charts ≤ 10,000 points; a slide ≤ 1920 × 1080 pt of area; the presentations ≤ 500 slides, 20,000 shapes and 1,000,000 characters; rasters ≤ 160 MP a call, 4096² each. Apps' own requests: ≤ 8 open documents a caller, 32 in all; a drawing 48–3840 px wide, ≤ 4096 px a side and 16 MP; ≤ 500 slides or shapes named a call; a table fill ≤ 1,024 cells; notes ≤ 20,000 characters; a picture ≤ 50 MP; a PDF export ≤ 160 MP, an image export ≤ 64 slides and 256 MP; a render cache of ≤ 16 MiB and 64 files, cleared before a write would fail for room. The import guard: ≤ 10,000 entries, 64 MiB an entry, 256 MiB in all, 100 to 1 past 100 KiB. | +| deck | `deck.info`, `deck.run` | 198 of 222. Reads: `insert.picture`, `picture.change` (`path`). Held back until deckcraft bounds its media and zip parsing (hostile data can abort the shell, #448): `insert.audio`, `insert.video`, `media.info`, `media.posterFrame`, `file.openBytes`. Apps' own requests also reach Quick Deck v2's open documents (`apps/quickdeck/design/SERVICE.md`): each method's commands, with arguments the service builds, pass the same door under the method's own most commands a call, and are folded into one undo step; `shape.fill` and `design.background` run there without `picture` or `path`; the picture reads take a PNG, JPEG, WebP, GIF, TIFF or BMP in the app's storage. Every file the service opens, on every path, first passes its own import guard: a zip's directory read with bounds, only in a shape the engine's own zip reader (the `zip` crate deckcraft uses) reads alike and then checked against that reader's reading, and a zip inside it (except an embedded object under `ppt/embeddings/`, such as a chart's workbook, which nothing unzips), an encrypted package and a legacy `.ppt` refused before the engine reads a byte. A deck's audio and video open as bytes the service carries and never decodes (the decoders are reached only through the held commands above); its media counts against the session's 128 MiB. | Tables ≤ 5,625 cells; charts ≤ 10,000 points; a slide ≤ 1920 × 1080 pt of area; the presentations ≤ 500 slides, 20,000 shapes and 1,000,000 characters; rasters ≤ 160 MP a call, 4096² each. Apps' own requests: ≤ 8 open documents a caller, 32 in all; a drawing 48–3840 px wide, ≤ 4096 px a side and 16 MP; ≤ 500 slides or shapes named a call; a table fill ≤ 1,024 cells; notes ≤ 20,000 characters; a picture ≤ 50 MP; a PDF export ≤ 160 MP, an image export ≤ 64 slides and 256 MP; a render cache of ≤ 16 MiB and 64 files, cleared before a write would fail for room. The import guard: ≤ 10,000 entries, 64 MiB an entry, 256 MiB in all, 100 to 1 past 100 KiB. | | cad | `cad.info`, `cad.run` | 288 of 295. `setvar` is refused: it sets variables by name, and no name is reviewed. | Arrays and copies ≤ 10,000 copies, multiplied across the call ≤ 10,000; polygons ≤ 1,024 sides; spline fit points ≤ 2,000; hatch and linetype scale ≥ 0.0001; drawings ≤ 200,000 objects; a render ≤ about a second of drawing work, estimated first. | | light | `light.info`, `light.run` | 189 of 239. Nothing more. | Originals ≤ 64 MP; exports ≤ 16 MP (AVIF ≤ 4); ≤ 16 photos (virtual copies included); ≤ 16 masks, 256 strokes and 64 spots; a crop ≥ 1% a side. | | film | `film.info`, `film.run` | 525 of 675. Read: `captions.import` (`path`). Built-ins only: `effects.apply`, both transition commands, `effects.setDefaultTransition`, `mixer.addInsert`, `presets.apply`, `lumetri.applyPreset`, `essentialSound.applyPreset`. | Sequences ≤ 4096 a side and 9.4 MP, ≤ 120 fps, ≤ 96 kHz; speed 1–10,000%; durations ≤ 24 h; a call adds ≤ 5,000 elements; analyses ≤ 18,000 frames; exports ≤ 18,000 frames. | diff --git a/docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md b/docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md index 99c4850b..428ab5ad 100644 --- a/docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md +++ b/docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md @@ -313,7 +313,7 @@ App Hub 的 card-host)。 | 引擎 | 系统代理的工具 | 入口在 `safe` id 之外还运行什么 | 上限(单次调用) | | --- | --- | --- | --- | | word | `word.info`、`word.run` | 389 个 id 中的 328 个。读取:`insert.picture`、`picture.change`(`path`)。 | 表格 ≤ 10,000 个单元格;页面每边 72–1584 pt;一次替换使文本最多增长 1,000 倍(连续替换合计 ≤ 10,000 倍);文档 ≤ 500,000 个字符、50,000 个段落、128 MiB 图片;PDF ≤ 10,000 页。 | -| deck | `deck.info`、`deck.run` | 222 个中的 198 个。读取:`insert.picture`、`picture.change`(`path`)。在 deckcraft 限制其媒体和 zip 解析之前暂缓开放(恶意数据可能让 Shell 进程中止,#448):`insert.audio`、`insert.video`、`media.info`、`media.posterFrame`、`file.openBytes`。应用自己的请求还能使用 Quick Deck v2 的打开文档(`apps/quickdeck/design/SERVICE.md`):每个方法的命令参数由服务自己构造,在该方法自己的每次调用命令数上限之内通过同一个入口,并合并为一个撤销步骤;`shape.fill` 和 `design.background` 在这里运行时不带 `picture` 或 `path`;图片读取只接受应用存储中的 PNG、JPEG、WebP、GIF、TIFF 或 BMP。服务打开的每个文件,无论经由哪条路径,都先通过它自己的导入检查:有界地读取 zip 的目录,只接受引擎自己的 zip 读取器(deckcraft 使用的 `zip` crate)会以同样方式读取的结构,并与该读取器的读取结果逐项比对,并在引擎读到任何字节之前拒绝其中嵌套的 zip(`ppt/embeddings/` 下的嵌入对象除外,例如图表的工作簿,没有任何组件会解压它)、音频和视频、加密包以及旧版 `.ppt`。 | 表格 ≤ 5,625 个单元格;图表 ≤ 10,000 个数据点;单张幻灯片面积 ≤ 1920 × 1080 pt;演示文稿 ≤ 500 张幻灯片、20,000 个形状、1,000,000 个字符;每次调用的栅格化 ≤ 160 MP,每张 ≤ 4096²。应用自己的请求:每个调用方 ≤ 8 个打开的文档,合计 ≤ 32 个;绘制宽 48–3840 px、每边 ≤ 4096 px 且 ≤ 16 MP;每次调用列出的幻灯片或形状 ≤ 500 个;一次表格填充 ≤ 1,024 个单元格;备注 ≤ 20,000 个字符;图片 ≤ 50 MP;PDF 导出 ≤ 160 MP,图像导出 ≤ 64 张幻灯片、合计 ≤ 256 MP;渲染缓存 ≤ 16 MiB 和 64 个文件,写入会因空间不足失败之前先清空。导入检查:≤ 10,000 个条目,每个条目 ≤ 64 MiB,合计 ≤ 256 MiB,超过 100 KiB 的条目压缩比 ≤ 100 比 1。 | +| deck | `deck.info`、`deck.run` | 222 个中的 198 个。读取:`insert.picture`、`picture.change`(`path`)。在 deckcraft 限制其媒体和 zip 解析之前暂缓开放(恶意数据可能让 Shell 进程中止,#448):`insert.audio`、`insert.video`、`media.info`、`media.posterFrame`、`file.openBytes`。应用自己的请求还能使用 Quick Deck v2 的打开文档(`apps/quickdeck/design/SERVICE.md`):每个方法的命令参数由服务自己构造,在该方法自己的每次调用命令数上限之内通过同一个入口,并合并为一个撤销步骤;`shape.fill` 和 `design.background` 在这里运行时不带 `picture` 或 `path`;图片读取只接受应用存储中的 PNG、JPEG、WebP、GIF、TIFF 或 BMP。服务打开的每个文件,无论经由哪条路径,都先通过它自己的导入检查:有界地读取 zip 的目录,只接受引擎自己的 zip 读取器(deckcraft 使用的 `zip` crate)会以同样方式读取的结构,并与该读取器的读取结果逐项比对,并在引擎读到任何字节之前拒绝其中嵌套的 zip(`ppt/embeddings/` 下的嵌入对象除外,例如图表的工作簿,没有任何组件会解压它)、加密包以及旧版 `.ppt`。演示文稿中的音频和视频作为字节随文档打开,服务只携带、从不解码(解码器只能经由上面暂缓开放的命令到达);其媒体计入会话的 128 MiB 上限。 | 表格 ≤ 5,625 个单元格;图表 ≤ 10,000 个数据点;单张幻灯片面积 ≤ 1920 × 1080 pt;演示文稿 ≤ 500 张幻灯片、20,000 个形状、1,000,000 个字符;每次调用的栅格化 ≤ 160 MP,每张 ≤ 4096²。应用自己的请求:每个调用方 ≤ 8 个打开的文档,合计 ≤ 32 个;绘制宽 48–3840 px、每边 ≤ 4096 px 且 ≤ 16 MP;每次调用列出的幻灯片或形状 ≤ 500 个;一次表格填充 ≤ 1,024 个单元格;备注 ≤ 20,000 个字符;图片 ≤ 50 MP;PDF 导出 ≤ 160 MP,图像导出 ≤ 64 张幻灯片、合计 ≤ 256 MP;渲染缓存 ≤ 16 MiB 和 64 个文件,写入会因空间不足失败之前先清空。导入检查:≤ 10,000 个条目,每个条目 ≤ 64 MiB,合计 ≤ 256 MiB,超过 100 KiB 的条目压缩比 ≤ 100 比 1。 | | cad | `cad.info`、`cad.run` | 295 个中的 288 个。`setvar` 被拒绝:它按名称设置变量,而没有任何名称经过审查。 | 阵列和复制 ≤ 10,000 份,一次调用内相乘 ≤ 10,000;多边形 ≤ 1,024 条边;样条拟合点 ≤ 2,000;填充和线型比例 ≥ 0.0001;图形 ≤ 200,000 个对象;渲染约 ≤ 一秒的绘制工作量,先估算再绘制。 | | light | `light.info`、`light.run` | 239 个中的 189 个。没有其他。 | 原图 ≤ 64 MP;导出 ≤ 16 MP(AVIF ≤ 4);≤ 16 张照片(含虚拟副本);≤ 16 个蒙版、256 笔画笔、64 个污点;裁剪每边 ≥ 1%。 | | film | `film.info`、`film.run` | 675 个中的 525 个。读取:`captions.import`(`path`)。只运行内置项:`effects.apply`、两个转场命令、`effects.setDefaultTransition`、`mixer.addInsert`、`presets.apply`、`lumetri.applyPreset`、`essentialSound.applyPreset`。 | 序列每边 ≤ 4096 且 ≤ 9.4 MP,≤ 120 fps,≤ 96 kHz;速度 1%–10,000%;时长 ≤ 24 小时;一次调用最多添加 5,000 个元素;分析 ≤ 18,000 帧;导出 ≤ 18,000 帧。 | From 8a4670171bdb9dab8c25aa8ba78b10e33b6b95a3 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:17:08 +0800 Subject: [PATCH 2/4] SERVICE.md: decks with audio and video open (#511) Co-Authored-By: Claude Opus 5.5 --- apps/quickdeck/design/SERVICE.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/quickdeck/design/SERVICE.md b/apps/quickdeck/design/SERVICE.md index c287ce61..b3038795 100644 --- a/apps/quickdeck/design/SERVICE.md +++ b/apps/quickdeck/design/SERVICE.md @@ -69,7 +69,7 @@ commands it runs (engine d0e57d7e; classes from `skill/safety.json`). | `damaged:` | the engine couldn't read it; the engine's reason follows | | `unsupported:` | not a presentation the engine reads: `.ppt`, `.key`, `.odp`, PDF | | `protected:` | an encrypted `.pptx`, which needs a password; v2 doesn't open these | - | `held:` | the deck holds audio or video, or its zip is beyond the import guard (#448) | + | `held:` | its zip is beyond the import guard (#448) | | `too_large:` | over a cap below | | `storage_full:` | the storage has no room | | `too_many_open:` | the caller already has 8 open documents | @@ -136,8 +136,7 @@ commands it runs (engine d0e57d7e; classes from `skill/safety.json`). agent's `deck.info`. It refuses with `held:` more than 10,000 entries, any entry declaring over 64 MiB uncompressed, a declared total over 256 MiB, a compression ratio over 100 to 1 (parts up to 100 KiB are exempt), a name - over 1,024 bytes, a zip inside the zip outside `ppt/embeddings/`, and any - audio or video part (by content type, extension or file signature). In a + over 1,024 bytes, and a zip inside the zip outside `ppt/embeddings/`. In a `.pptx`, an embedded Office object under `ppt/embeddings/` (a chart's workbook) is a zip too; it stays within every other bound, and neither the service nor the engine ever unzips it. A native deck has no embedded @@ -148,6 +147,11 @@ commands it runs (engine d0e57d7e; classes from `skill/safety.json`). package is `unsupported:`. An OLE file (`D0 CF 11 E0`) is `protected:` when it holds an `EncryptedPackage` stream, and `unsupported:` otherwise (a legacy `.ppt`). +- **Audio and video** open. The service carries a deck's clips as bytes: it + draws a clip's poster (or a film or speaker glyph), and saves and exports + them unchanged. Nothing decodes or plays them, and `media.info`, + `media.posterFrame`, `insert.audio` and `insert.video` stay held. A deck + holding more than 128 MiB of media is `too_large:`. ## Reading and drawing @@ -434,6 +438,8 @@ changes; where they differ from the sections above, they win. whose content types come late (as LibreOffice writes them) opens. - **`deck.hit`** treats `selected: []` as no selection. - **Pictures** whose size can't be read are `invalid:`. +- **Audio and video (#511)** open, carried as bytes and never decoded, as + "Audio and video" above says; that lifts decision 4's media refusal. ## Inspector states (#507) From 03dc8c16a2e035d945373b79973dbbadc60edd01 Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:24:49 +0800 Subject: [PATCH 3/4] Quick Deck: a held deck's words no longer name video or audio Since #511 a deck with audio or video opens; held: now means a zip inside the zip, or a part past the import guard's bounds (#448). Wording only, no layout change: - main.splash: the open view's explanation names a zip inside the presentation or parts larger or more unusual than Quick Deck takes, and says to save a simpler copy; the library card says "Can't open this file" - dev fixture: the held deck's refusal is the guard's nested-zip words - READMEs (both languages): the held state's description - model test: the held explanation names no media Co-Authored-By: Claude Opus 5.5 --- apps/quickdeck/README.md | 4 ++-- apps/quickdeck/README.zh-CN.md | 2 +- apps/quickdeck/bundle/main.splash | 4 ++-- apps/quickdeck/dev-fixture/make_fixture.py | 2 +- crates/shell/src/quickdeck_model_tests.rs | 13 +++++++++++++ 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/quickdeck/README.md b/apps/quickdeck/README.md index 8acc2cb6..20d8b9b6 100644 --- a/apps/quickdeck/README.md +++ b/apps/quickdeck/README.md @@ -61,8 +61,8 @@ unsaved changes asks: Save, Don't save, or Cancel. States every screen handles: an empty library, a slide still rendering, a damaged deck ("Couldn't open this deck" and the engine's reason), a protected presentation (OctoSense keeps passwords on host sheets and there is none for -presentations, so it is not opened), one the engine holds back (video or -audio, or parts beyond the service's import guard, #448), one it doesn't read +presentations, so it is not opened), one the engine holds back (a zip +inside it, or parts beyond the service's import guard, #448), one it doesn't read (.ppt, .key, .odp, PDF), eight decks already open, storage full (the bar under the tabs, and the status line goes red), unsaved changes when closing, and no engine on the device ("The deck engine isn't available here"), or an diff --git a/apps/quickdeck/README.zh-CN.md b/apps/quickdeck/README.zh-CN.md index 15654308..36f45d1f 100644 --- a/apps/quickdeck/README.zh-CN.md +++ b/apps/quickdeck/README.zh-CN.md @@ -34,7 +34,7 @@ Quick Deck(`os.quickdeck`)是 OctoSense 桌面版的演示文稿应用:演 Undo 和 Redo 使用引擎的历史记录;Save 写入原生 `.deckcraft` 文件(`deck.undo`、`deck.redo`、`deck.save`、`deck.state`)。关闭有未保存更改的标签时会询问:保存、不保存或取消。 -每个页面都会处理这些状态:空的演示文稿库、正在渲染的幻灯片、损坏的演示文稿("Couldn't open this deck" 及引擎给出的原因)、受密码保护的演示文稿(OctoSense 只在宿主的表单中处理密码,目前没有演示文稿用的表单,因此不打开)、引擎暂不打开的演示文稿(含视频或音频,或超出服务导入防护的部分,#448)、引擎不读取的格式(.ppt、.key、.odp、PDF)、已打开八个演示文稿、存储已满(标签栏下方出现提示条,状态栏变红)、关闭时有未保存的更改,以及设备上没有引擎("The deck engine isn't available here")或引擎版本较旧("This OctoSense's deck engine is older than Quick Deck")。 +每个页面都会处理这些状态:空的演示文稿库、正在渲染的幻灯片、损坏的演示文稿("Couldn't open this deck" 及引擎给出的原因)、受密码保护的演示文稿(OctoSense 只在宿主的表单中处理密码,目前没有演示文稿用的表单,因此不打开)、引擎暂不打开的演示文稿(内含 zip,或超出服务导入防护的部分,#448)、引擎不读取的格式(.ppt、.key、.odp、PDF)、已打开八个演示文稿、存储已满(标签栏下方出现提示条,状态栏变红)、关闭时有未保存的更改,以及设备上没有引擎("The deck engine isn't available here")或引擎版本较旧("This OctoSense's deck engine is older than Quick Deck")。 ## 从本设备打开演示文稿 diff --git a/apps/quickdeck/bundle/main.splash b/apps/quickdeck/bundle/main.splash index 4a59dbae..aa22e8ba 100644 --- a/apps/quickdeck/bundle/main.splash +++ b/apps/quickdeck/bundle/main.splash @@ -750,7 +750,7 @@ fn font_mark(chosen, scheme, pairs, major, minor){ } fn open_detail(state){ if state == "protected" { return "Quick Deck doesn't open password-protected files: OctoSense keeps passwords on its own sheets, and there isn't one for presentations." } - if state == "held" { return "It holds video or audio, or parts larger or more unusual than the deck engine reads safely yet (#448). Remove the media, or save a smaller copy, in the app that made it, then open it again." } + if state == "held" { return "It holds a zip inside the presentation, or parts larger or more unusual than Quick Deck takes (#448). Save a simpler copy in the app that made it, then open it again." } if state == "unsupported" { return "Word documents, Excel workbooks, older PowerPoint (.ppt), Keynote (.key), OpenDocument (.odp) and PDF files can't be opened here. Save the presentation as .pptx in the app that made it." } if state == "too_large" { return "It's beyond what the deck engine opens on this device." } if state == "full" { return "Close another deck's tab, then open this one again." } @@ -1275,7 +1275,7 @@ fn slide_count(v){ } fn doc_meta(d){ if d.state == "protected" { return "Protected with a password" } - if d.state == "held" { return "Has video or audio" } + if d.state == "held" { return "Can't open this file" } if d.state == "unsupported" { return "Not a .pptx presentation" } if d.state != "" { return "Couldn't open this deck" } let m = deck_meta(d.slides, d.bytes) diff --git a/apps/quickdeck/dev-fixture/make_fixture.py b/apps/quickdeck/dev-fixture/make_fixture.py index 86734440..6eac3a22 100644 --- a/apps/quickdeck/dev-fixture/make_fixture.py +++ b/apps/quickdeck/dev-fixture/make_fixture.py @@ -534,7 +534,7 @@ def main(): refusals = { "decks/Damaged deck.deckcraft": "damaged: the engine couldn't read it: the archive's central directory is cut short", "imports/Payroll 2025.pptx": "protected: this presentation is encrypted and needs a password", - "imports/Town hall recording.pptx": "held: the presentation holds video, which Quick Deck can't open yet (#448)", + "imports/Town hall recording.pptx": "held: the presentation holds a zip inside its zip (`ppt/media/recording.zip`), which the deck service does not open (#448)", "imports/Minutes 2019.pptx": "unsupported: this is a legacy PowerPoint file (.ppt), not a .pptx presentation", } for path, error in refusals.items(): diff --git a/crates/shell/src/quickdeck_model_tests.rs b/crates/shell/src/quickdeck_model_tests.rs index c8f95182..5b5b48dc 100644 --- a/crates/shell/src/quickdeck_model_tests.rs +++ b/crates/shell/src/quickdeck_model_tests.rs @@ -478,6 +478,19 @@ fn the_footer_and_the_marks_follow_the_deck() { assert_eq!(fonts("", "Office", "Calibri Light", "Calibri"), json!(""), "an imported deck's own fonts"); } +#[test] +fn a_held_deck_is_explained_without_media() { + // #511: audio and video open now; held: is a zip inside the zip or a + // part past the import guard's bounds. + let detail = quickdeck_model("open_detail(\"held\").to_json()"); + let text = detail.as_str().unwrap(); + assert!(text.contains("zip") && text.contains("simpler copy") && !text.contains("video") && !text.contains("audio"), "{text}"); + // The library card's own line (`doc_meta`, after the app's start, which + // this harness leaves out) names no media either. + assert!(QUICK_DECK.contains("if d.state == \"held\" { return \"Can't open this file\" }")); + assert!(!QUICK_DECK.contains("video or audio")); +} + #[test] fn an_office_document_that_isnt_a_presentation_is_explained() { // #507: a .docx or .xlsx answers unsupported:. From dab0c0e0f06575f800a3c5a631cba28cf4d45cee Mon Sep 17 00:00:00 2001 From: ymote <151983+ymote@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:44:19 +0800 Subject: [PATCH 4/4] deck: the media ceiling before the engine reads, and on every path Review of #511 (minor): the 128 MiB media ceiling ran only after the engine read, on deck.open and deck.cover, and on deck.run only with commands; info, text, render and convert never checked it. The package reader copies each media part out, so the peak could be twice the media. - guard: before the engine reads, the declared total of a zip's binary parts (everything but its XML and relationship parts, and a native deck's manifest and mimetype) past 128 MiB is too_large:, in the session ceiling's words - read_guarded and read_deck: the deck's own media against the same ceiling right after the engine reads, so all seven paths answer alike (media_ceiling; media_over and too_large_deck give one set of words to the guard, the paths and the session's Held::over) - test: a native deck and a package just past 128 MiB are refused on all seven paths with no engine read; one just under opens on all seven Co-Authored-By: Claude Opus 5.5 --- apps/deck/host-service/src/covers.rs | 4 +- apps/deck/host-service/src/doc_tests.rs | 77 ++++++++++++++++++++----- apps/deck/host-service/src/docs.rs | 2 +- apps/deck/host-service/src/guard.rs | 24 +++++++- apps/deck/host-service/src/lib.rs | 27 ++++++++- 5 files changed, 112 insertions(+), 22 deletions(-) diff --git a/apps/deck/host-service/src/covers.rs b/apps/deck/host-service/src/covers.rs index 5373b268..adcf8d5e 100644 --- a/apps/deck/host-service/src/covers.rs +++ b/apps/deck/host-service/src/covers.rs @@ -35,7 +35,7 @@ use serde_json::{json, Value as Json}; use crate::args; use crate::cache; -use crate::codes::{fail, invalid, too_large, Code}; +use crate::codes::{fail, invalid, Code}; use crate::reading; use crate::Ctx; @@ -79,7 +79,7 @@ pub(crate) fn cover(a: &Json, cx: &Ctx) -> Result { let mut session = deckcraft_engine::Session::new(); engine_file::add_opened(&mut session, &name, None, deck); if let Some(why) = crate::Held::of(&session).over() { - return Err(too_large(format!("the deck holds {why}, more than the deck service opens"))); + return Err(crate::too_large_deck(why)); } let deck = session.doc().map_err(|_| fail(Code::Damaged, "the engine opened no deck"))?.doc.clone(); if deck.slides.is_empty() { diff --git a/apps/deck/host-service/src/doc_tests.rs b/apps/deck/host-service/src/doc_tests.rs index 9542ad24..a7c4e22b 100644 --- a/apps/deck/host-service/src/doc_tests.rs +++ b/apps/deck/host-service/src/doc_tests.rs @@ -933,30 +933,75 @@ fn hostile_clip_bytes_are_carried_on_every_path_and_never_decoded() { } } -/// A deck holding more media than the door's 128 MiB, each item small -/// enough to stay inside the guard's bounds, is `too_large:` on opening. +/// A deck whose binary parts declare just past the door's 128 MiB of media, +/// each part inside every other bound of the guard, is `too_large:` on all +/// seven paths before the engine reads a byte (the guard's pre-read bound, +/// in the session ceiling's words), whether its media is a native deck's or +/// a package's parts. One just under opens on every path. #[test] -fn a_deck_past_the_media_ceiling_is_too_large() { +fn a_deck_past_the_media_ceiling_is_too_large_before_the_engine_reads() { use deckcraft_model::{MediaId, MediaItem}; let w = World::new(); - let mut p = sample(1); let item = guard::RATIO_GRACE as usize; - for k in 0..(MAX_RUN_MEDIA as usize / item + 8) { + let filler = |k: usize| { let mut data = vec![0u8; item]; data[..8].copy_from_slice(&(k as u64).to_le_bytes()); - let id = MediaId(p.alloc_id()); - p.media.push(MediaItem { id, name: format!("filler{k}.bin"), content_type: "application/octet-stream".into(), data: Arc::new(data), link: None }); + data + }; + let native_with = |n: usize| { + let mut p = sample(1); + for k in 0..n { + let id = MediaId(p.alloc_id()); + p.media.push(MediaItem { id, name: format!("filler{k}.bin"), content_type: "application/octet-stream".into(), data: Arc::new(filler(k)), link: None }); + } + native(&p) + }; + let over = MAX_RUN_MEDIA as usize / item + 1; + assert!((over * item) as u64 > MAX_RUN_MEDIA && ((over - 1) * item) as u64 <= MAX_RUN_MEDIA, "just over, and just under"); + let package = fx::package((0..over).map(|k| Part::deflated(&format!("ppt/media/filler{k}.bin"), &filler(k))).collect(), ""); + let paths = |name: &str| { + vec![ + ("open", json!({ "path": name })), + ("cover", json!({ "path": name })), + ("info", json!({ "path": name })), + ("text", json!({ "path": name })), + ("render", json!({ "path": name, "out": format!("{name}.png") })), + ("convert", json!({ "path": name, "out": format!("{name}.txt") })), + ("run", json!({ "path": name, "cmds": [] })), + ] + }; + for (name, bytes) in [("over.deckcraft", native_with(over)), ("over.pptx", package)] { + assert!(bytes.len() < 4 << 20, "{name}: a small file, {} bytes", bytes.len()); + w.put(name, &bytes); + let before = engine_reads(); + for (method, args) in paths(name) { + let e = w.err(method, args); + coded(&e, "too_large"); + assert!(e.contains(&format!("bytes of media, more than the {MAX_RUN_MEDIA} the door allows")), "{name} {method}: {e}"); + } + assert_eq!(engine_reads(), before, "{name}: refused before the engine read a byte"); } - let bytes = native(&p); - assert!(bytes.len() < 4 << 20, "a small file: {} bytes", bytes.len()); - assert_eq!(guard::check("filled.deckcraft", &bytes), Ok(guard::Format::Native), "inside the guard's bounds"); - w.put("filled.deckcraft", &bytes); - for method in ["open", "cover"] { - let e = w.err(method, json!({ "path": "filled.deckcraft" })); - coded(&e, "too_large"); - assert!(e.contains("bytes of media"), "{method}: {e}"); + w.put("under.deckcraft", &native_with(over - 1)); + let before = engine_reads(); + for (method, args) in paths("under.deckcraft") { + w.ok(method, args); } - assert_eq!(docs::open_count(), 0); + assert_eq!(engine_reads(), before + 7, "just under: every path read it"); + assert_eq!(docs::open_count(), 1); + // After the engine reads, every path also holds the deck's own media to + // the same ceiling, in the same words (`media_ceiling`). + let deck = |n: usize| { + let mut p = sample(1); + for k in 0..n { + let id = MediaId(p.alloc_id()); + p.media.push(MediaItem { id, name: format!("filler{k}.bin"), content_type: "application/octet-stream".into(), data: Arc::new(filler(k)), link: None }); + } + p + }; + let e = media_ceiling(&deck(over)).unwrap_err(); + coded(&e, "too_large"); + assert!(e.contains("bytes of media"), "{e}"); + assert_eq!(media_ceiling(&deck(over - 1)), Ok(())); } /// The import guard passes what the engine itself writes, at the sizes the diff --git a/apps/deck/host-service/src/docs.rs b/apps/deck/host-service/src/docs.rs index 647c3587..78389a11 100644 --- a/apps/deck/host-service/src/docs.rs +++ b/apps/deck/host-service/src/docs.rs @@ -501,7 +501,7 @@ pub(crate) fn open(a: &Json, cx: &Ctx) -> Result { let mut session = app_session(); engine_file::add_opened(&mut session, &rel, None, deck); if let Some(why) = crate::Held::of(&session).over() { - return Err(too_large(format!("the deck holds {why}, more than the deck service opens"))); + return Err(crate::too_large_deck(why)); } let file = (format == Format::Native).then(|| rel.clone()); let mut doc = new_doc(cx, owner, session, file)?; diff --git a/apps/deck/host-service/src/guard.rs b/apps/deck/host-service/src/guard.rs index 93676a18..52434450 100644 --- a/apps/deck/host-service/src/guard.rs +++ b/apps/deck/host-service/src/guard.rs @@ -50,8 +50,12 @@ //! through `media.info`, `media.posterFrame`, `insert.audio` and //! `insert.video`, which the door holds back (#448). The renderer draws a //! clip's poster, or a film or speaker glyph, and the writers copy its -//! bytes. The media a deck holds counts against the session's 128 MiB -//! once it opens (`too_large:`, `crate::Held`). +//! bytes. Before the engine reads, the declared total of a zip's binary +//! parts (everything but its XML and relationship parts, and a native +//! deck's manifest) is held to the session's 128 MiB of media +//! (`too_large:`, in the session ceiling's words, [`binary_total`]), so +//! the engine never copies out more; after it reads, every path checks +//! the deck's media against the same ceiling. //! - A zip inside the zip is `held:`, except an Office package's embedded object //! ([`EMBEDDINGS`]): a chart's workbook or an embedded document is a zip //! that neither the service nor the engine ever unzips (deckcraft's only @@ -568,6 +572,19 @@ fn inflate(b: &[u8], e: &Entry, keep: bool, watch: &mut dyn FnMut(&[u8])) -> Res Ok(out) } +/// What a zip's binary parts declare in all: every entry but a text part +/// of the model (an Office package's XML and relationship parts, a native +/// deck's `presentation.json` and `mimetype`). The media a deck holds once +/// read is some of these, so a total past the session's media ceiling is +/// refused before the engine copies any of it out. +fn binary_total(entries: &[Entry]) -> u64 { + let text = |name: &str| { + let lower = name.to_ascii_lowercase(); + lower.ends_with(".xml") || lower.ends_with(".rels") || lower == "presentation.json" || lower == "mimetype" + }; + entries.iter().filter(|e| !text(&e.name)).map(|e| e.declared).fold(0u64, u64::saturating_add) +} + /// Whether `head` starts a zip archive: a zip inside the zip. fn zip_signature(head: &[u8]) -> bool { head.starts_with(b"PK\x03\x04") || head.starts_with(b"PK\x05\x06") || head.starts_with(b"PK\x07\x08") @@ -590,6 +607,9 @@ fn zip(b: &[u8]) -> Result { let dir = directory(b)?; read_alike(b, &dir)?; let entries = &dir.entries; + if let Some(why) = crate::media_over(binary_total(entries)) { + return Err(crate::too_large_deck(why)); + } // What it is (module doc): a native deck by deckcraft's own marker, // which the engine tries first, else an Office package by its // content-type list in the directory both readers read alike. The diff --git a/apps/deck/host-service/src/lib.rs b/apps/deck/host-service/src/lib.rs index e0e36e98..3267cdcc 100644 --- a/apps/deck/host-service/src/lib.rs +++ b/apps/deck/host-service/src/lib.rs @@ -665,6 +665,7 @@ pub(crate) fn read_guarded(area: &Area, rel: &str, key: &str, m: &str) -> Result fail(Code::Damaged, format!("the deck engine couldn't read it: {words}")) } })?; + media_ceiling(&deck)?; Ok((name, deck, format)) } @@ -722,6 +723,7 @@ fn read_deck(args: &Json, area: &Area, method: &str) -> Result<(Session, String) let bytes = std::fs::read(&path).map_err(|e| format!("deck.{method}: {rel}: {e}"))?; let format = guard::check(&rel, &bytes)?; let doc = engine_read(&rel, &bytes, format).map_err(|e| format!("deck.{method}: {e}"))?; + media_ceiling(&doc)?; let mut s = Session::new(); engine_file::add_opened(&mut s, &rel, None, doc); Ok((s, rel)) @@ -1036,7 +1038,30 @@ impl Held { if let Some((n, max, what)) = counts.into_iter().find(|(n, max, _)| n > max) { return Some(format!("{n} {what}, more than the {max} the door allows")); } - (self.media > MAX_RUN_MEDIA).then(|| format!("{} bytes of media, more than the {MAX_RUN_MEDIA} the door allows", self.media)) + media_over(self.media) + } +} + +/// The media ceiling's words (the door's [`MAX_RUN_MEDIA`]), wherever it is +/// met: before the engine reads (the import guard's declared total of a +/// file's media and other binary parts), after it reads (the deck's media, +/// on every path that opens a file), and in a session ([`Held::over`]). +pub(crate) fn media_over(bytes: u64) -> Option { + (bytes > MAX_RUN_MEDIA).then(|| format!("{bytes} bytes of media, more than the {MAX_RUN_MEDIA} the door allows")) +} + +/// A deck past one of the session's ceilings, as every path answers it. +pub(crate) fn too_large_deck(why: impl std::fmt::Display) -> String { + codes::too_large(format!("the deck holds {why}, more than the deck service opens")) +} + +/// The media ceiling on a deck the engine has just read, before anything is +/// done with it: the same words on all seven paths (`deck.open`, +/// `deck.cover` and the file methods). +fn media_ceiling(deck: &Presentation) -> Result<(), String> { + match media_over(deck.media.iter().map(|m| m.data.len() as u64).sum()) { + Some(why) => Err(too_large_deck(why)), + None => Ok(()), } }