Skip to content

Commit 72785a6

Browse files
committed
release: v2.6.0 bounded Node preflight and SmartArt inventory
1 parent b1b247e commit 72785a6

19 files changed

Lines changed: 523 additions & 144 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,24 @@
22

33
All notable changes to DeckProbe are documented here.
44

5+
## [2.6.0] - 2026-09-09
6+
7+
### Added
8+
9+
- Node `probeFile(path, options, runtime)` with a 2500 ms default host deadline,
10+
same-version native selection, WASM Worker fallback, cancellation, bounded input/output,
11+
concurrency limits and independent host metrics. Existing two-argument calls are unchanged.
12+
- CLI `--max-input-bytes` for paths, stdin and JSONL sources.
13+
- Exact `powerpoint.smartart_data_part_count` inventory using existing Content Types/ZIP indexes.
14+
- Native/WASM parity, input cap, cancellation/deadline and semantic SmartArt regression checks.
15+
516
## [Unreleased]
617

18+
### Changed
19+
20+
- Keep the npm package lightweight by omitting source maps and TypeScript source
21+
files from published tarballs, with a packaging regression check.
22+
723
## [2.5.0] - 2026-09-03
824

925
### Added

‎Cargo.lock‎

Lines changed: 11 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ members = [
1515
resolver = "2"
1616

1717
[workspace.package]
18-
version = "2.5.0"
18+
version = "2.6.0"
1919
edition = "2024"
2020
license = "Apache-2.0"
2121
repository = "https://github.com/deckflow/deckprobe"

‎crates/deckprobe-cli/src/main.rs‎

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::collections::BTreeMap;
22
use std::fs::File;
3-
use std::io::{BufRead, Read, Write};
3+
use std::io::{BufRead, Read, Seek, SeekFrom, Write};
44
use std::path::{Path, PathBuf};
55
use std::process::ExitCode;
66

@@ -175,7 +175,11 @@ struct Cli {
175175
)]
176176
max_archive_entries: Option<usize>,
177177

178-
/// Wall-clock budget in milliseconds.
178+
/// Maximum source file or stdin size before parsing (separate from physical read budget).
179+
#[arg(long, value_name = "BYTES", help_heading = "Resource limits")]
180+
max_input_bytes: Option<u64>,
181+
182+
/// Cooperative engine budget in milliseconds; not a hard process deadline.
179183
#[arg(
180184
short = 'T',
181185
long,
@@ -424,7 +428,21 @@ fn run(cli: Cli) -> deckprobe_core::Result<u8> {
424428
None => {}
425429
}
426430

427-
let options = probe_options(&cli)?;
431+
if cli.max_input_bytes == Some(0) {
432+
return Err(DeckProbeError::InvalidRequest(
433+
"max-input-bytes must be positive".into(),
434+
));
435+
}
436+
let mut options = probe_options(&cli)?;
437+
if cli.input.as_ref().is_some_and(|p| p.as_os_str() == "-")
438+
&& let Some(cap) = cli.max_input_bytes
439+
{
440+
let current = options
441+
.budget
442+
.max_physical_bytes
443+
.unwrap_or(Budget::for_level(options.level).max_physical_bytes);
444+
options.budget.max_physical_bytes = Some(current.min(cap));
445+
}
428446
if cli.jsonl {
429447
return run_jsonl(&cli, &options);
430448
}
@@ -447,7 +465,7 @@ fn run(cli: Cli) -> deckprobe_core::Result<u8> {
447465
"--stdin-name is only valid when INPUT is '-'".to_owned(),
448466
));
449467
}
450-
std::sync::Arc::new(PathSource::open(input, None)?)
468+
std::sync::Arc::new(PathSource::bounded(input, None, cli.max_input_bytes)?)
451469
};
452470

453471
let report = probe(source, options)?;
@@ -565,7 +583,7 @@ fn run_jsonl(cli: &Cli, options: &ProbeOptions) -> deckprobe_core::Result<u8> {
565583
"JSONL line {line_number} is invalid: {error}"
566584
))
567585
})
568-
.and_then(|input| jsonl_source(input, options))
586+
.and_then(|input| jsonl_source(input, options, cli.max_input_bytes))
569587
.and_then(|source| probe(source, options.clone()));
570588
match outcome {
571589
Ok(report) => {
@@ -593,27 +611,46 @@ fn run_jsonl(cli: &Cli, options: &ProbeOptions) -> deckprobe_core::Result<u8> {
593611
fn jsonl_source(
594612
input: JsonlInput,
595613
options: &ProbeOptions,
614+
max_input_bytes: Option<u64>,
596615
) -> deckprobe_core::Result<std::sync::Arc<dyn ProbeSource>> {
597616
match input {
598-
JsonlInput::Path(path) => Ok(std::sync::Arc::new(PathSource::open(path, None)?)),
617+
JsonlInput::Path(path) => Ok(std::sync::Arc::new(PathSource::bounded(
618+
path,
619+
None,
620+
max_input_bytes,
621+
)?)),
599622
JsonlInput::Record {
600623
path: Some(path),
601624
name,
602625
data_base64: None,
603-
} => Ok(std::sync::Arc::new(PathSource::open(path, name)?)),
626+
} => Ok(std::sync::Arc::new(PathSource::bounded(
627+
path,
628+
name,
629+
max_input_bytes,
630+
)?)),
604631
JsonlInput::Record {
605632
path: None,
606633
name: Some(name),
607634
data_base64: Some(data),
608635
} => {
636+
let mut budget = Budget::for_level(options.level);
637+
options.budget.apply_to(&mut budget);
638+
let limit = max_input_bytes
639+
.unwrap_or(u64::MAX)
640+
.min(budget.max_physical_bytes);
641+
if data.len() as u64 > limit.saturating_add(2).saturating_div(3).saturating_mul(4) {
642+
return Err(DeckProbeError::BudgetExceeded(
643+
"JSONL input exceeds size limit".into(),
644+
));
645+
}
609646
let bytes = base64::engine::general_purpose::STANDARD
610647
.decode(data)
611648
.map_err(|error| {
612649
DeckProbeError::InvalidRequest(format!("invalid JSONL base64 payload: {error}"))
613650
})?;
614651
let mut budget = Budget::for_level(options.level);
615652
options.budget.apply_to(&mut budget);
616-
if bytes.len() as u64 > budget.max_physical_bytes {
653+
if bytes.len() as u64 > limit {
617654
return Err(DeckProbeError::BudgetExceeded(format!(
618655
"JSONL byte payload size {} exceeds physical read budget {}",
619656
bytes.len(),
@@ -636,15 +673,23 @@ struct PathSource {
636673
path: PathBuf,
637674
display_name: String,
638675
file_size: u64,
676+
max_input_bytes: Option<u64>,
677+
modified: Option<std::time::SystemTime>,
639678
}
640679

641680
impl PathSource {
642-
fn open(
681+
fn bounded(
643682
path: impl Into<PathBuf>,
644683
display_name: Option<String>,
684+
max_input_bytes: Option<u64>,
645685
) -> deckprobe_core::Result<Self> {
646686
let path = path.into();
647687
let metadata = std::fs::metadata(&path)?;
688+
if max_input_bytes.is_some_and(|cap| metadata.len() > cap) {
689+
return Err(DeckProbeError::BudgetExceeded(
690+
"source exceeds max-input-bytes".into(),
691+
));
692+
}
648693
if !metadata.is_file() {
649694
return Err(DeckProbeError::InvalidRequest(format!(
650695
"input is not a regular file: {}",
@@ -661,6 +706,8 @@ impl PathSource {
661706
path,
662707
display_name,
663708
file_size: metadata.len(),
709+
max_input_bytes,
710+
modified: metadata.modified().ok(),
664711
})
665712
}
666713
}
@@ -679,7 +726,46 @@ impl ProbeSource for PathSource {
679726
}
680727

681728
fn open(&self) -> deckprobe_core::Result<BoxedProbeReader> {
682-
Ok(Box::new(File::open(&self.path)?))
729+
let file = File::open(&self.path)?;
730+
if self.max_input_bytes.is_some() {
731+
let metadata = file.metadata()?;
732+
if !metadata.is_file()
733+
|| metadata.len() != self.file_size
734+
|| metadata.modified().ok() != self.modified
735+
{
736+
return Err(DeckProbeError::Io(std::io::Error::other(
737+
"source changed during probe",
738+
)));
739+
}
740+
return Ok(Box::new(CheckedFile {
741+
file,
742+
size: self.file_size,
743+
modified: self.modified,
744+
}));
745+
}
746+
Ok(Box::new(file))
747+
}
748+
}
749+
750+
struct CheckedFile {
751+
file: File,
752+
size: u64,
753+
modified: Option<std::time::SystemTime>,
754+
}
755+
impl Read for CheckedFile {
756+
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
757+
let meta = self.file.metadata()?;
758+
if meta.len() != self.size || meta.modified().ok() != self.modified {
759+
return Err(std::io::Error::other("source changed during probe"));
760+
}
761+
let position = self.file.stream_position()?;
762+
let len = buf.len().min(self.size.saturating_sub(position) as usize);
763+
self.file.read(&mut buf[..len])
764+
}
765+
}
766+
impl Seek for CheckedFile {
767+
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
768+
self.file.seek(pos)
683769
}
684770
}
685771

‎crates/deckprobe-engine/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,7 @@ fn is_asset_target(target: &str) -> bool {
614614
"media",
615615
"font",
616616
"attachment",
617+
"smartart",
617618
"embedded",
618619
]
619620
.iter()

‎crates/deckprobe-format-ooxml/src/lib.rs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,33 @@ impl<R: Read + Seek> OoxmlSession<R> {
419419
Ok(parts)
420420
}
421421

422+
pub fn unique_content_type_part_count(
423+
&mut self,
424+
context: &ProbeContext,
425+
content_type: &str,
426+
) -> Result<usize> {
427+
if self.content_types_cache.is_none() {
428+
self.part_content_types(context)?;
429+
}
430+
let types = self
431+
.content_types_cache
432+
.as_ref()
433+
.expect("content types loaded");
434+
let mut found = BTreeSet::new();
435+
for name in &self.entry_names {
436+
context.check_time()?;
437+
let name = name.trim_start_matches('/').to_ascii_lowercase();
438+
let effective = types.overrides.get(&name).or_else(|| {
439+
name.rsplit_once('.')
440+
.and_then(|(_, ext)| types.defaults.get(ext))
441+
});
442+
if effective.is_some_and(|value| value.eq_ignore_ascii_case(content_type)) {
443+
found.insert(name);
444+
}
445+
}
446+
Ok(found.len())
447+
}
448+
422449
pub fn unique_image_asset_part_count(
423450
&mut self,
424451
context: &ProbeContext,

0 commit comments

Comments
 (0)