Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
the article body of a web page, Readability-style, dropping navigation, sidebars, comments, share
bars and related-link blocks. Off by default; include selectors take precedence; falls back to the
full page when no article-like block is found.
- Every resource limit is configurable outside Rust: CLI flags (`--max-output-size`, `--timeout`,
`--max-nesting-depth`, `--max-nodes`, archive / sheet / redirect flags; `--max-size` kept as an
alias of `--max-input-size`), Python `markkit.Limits(...)` passed as `limits=` to every function,
and flat Node.js and WASM option keys (`maxOutputSize`, `maxNodes`, `maxNestingDepth`, plus
`timeout` in Node.js).
- Node.js and WASM options gain `baseUrl` and `mainContent`; Python `extract_html_sync_with_options`
gains `base_url=` and `main_content=`, matching the core builder.

### Security

Expand All @@ -57,7 +64,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
thousands of rows); `max_nodes` (5 000 000) caps parsed HTML DOM / DOCX XML nodes;
`max_nesting_depth` (256) is configurable, with a hard ceiling of 512 that keeps deep input off
the host stack. Violations return `Error::ResourceLimit` naming the limit.

- `extract_url` (CLI, Python and Node `extract_url` too) now refuses non-public destinations by default:
loopback, private, link-local / cloud metadata and other reserved addresses, checked on the URL,
after DNS resolution and on every redirect. Only `http`/`https` are fetched. Redirects are capped
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,9 @@ let markkit = Markkit::builder()
.build();
```

Each limit also has a builder setter (`.max_nodes(..)`, `.timeout(..)`, ...).
Each limit also has a builder setter (`.max_nodes(..)`, `.timeout(..)`, ...), a CLI
flag (`--max-nodes`, `--timeout`, ...; see `markkit extract --help`), a
`markkit.Limits(...)` argument in Python and flat option keys in Node.js and WASM.
Other options: `include_hidden_sheets`, `allow_private_networks`, `allow_host`.

## Performance
Expand Down
143 changes: 135 additions & 8 deletions crates/markkit-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use std::io::Read;

use anyhow::Result;
use clap::{Parser, Subcommand};
use markkit::Markkit;
use std::time::Duration;

use clap::{Args, Parser, Subcommand};
use markkit::{Limits, Markkit};

#[derive(Parser)]
#[command(
Expand Down Expand Up @@ -38,10 +40,6 @@ enum Commands {
#[arg(long = "exclude", short = 'e')]
exclude_selectors: Vec<String>,

/// Maximum input size in bytes
#[arg(long, default_value = "52428800")]
max_size: usize,

/// Output as JSON with metadata
#[arg(long)]
json: bool,
Expand All @@ -54,9 +52,82 @@ enum Commands {
/// Keep only the detected main content (article body) of HTML pages
#[arg(long)]
main_content: bool,

#[command(flatten)]
limits: LimitArgs,
},
}

/// Resource limits; unset flags keep the library defaults (see `markkit::Limits`).
#[derive(Args, Debug, Default)]
#[command(next_help_heading = "Limits")]
struct LimitArgs {
/// Maximum input (and fetched body) size in bytes [default: 50 MiB]
#[arg(long, alias = "max-size", value_name = "BYTES")]
max_input_size: Option<usize>,
/// Maximum Markdown output size in bytes [default: 128 MiB]
#[arg(long, value_name = "BYTES")]
max_output_size: Option<usize>,
/// Time budget for fetch + parse + render, in seconds [default: 60]
#[arg(long, value_name = "SECS", value_parser = parse_secs)]
timeout: Option<Duration>,
/// Maximum element nesting, hard-capped at 512 [default: 256]
#[arg(long, value_name = "N")]
max_nesting_depth: Option<usize>,
/// Maximum parsed HTML DOM / DOCX XML nodes [default: 5000000]
#[arg(long, value_name = "N")]
max_nodes: Option<usize>,
/// Maximum entries in a ZIP container [default: 10000]
#[arg(long, value_name = "N")]
max_archive_entries: Option<usize>,
/// Maximum decompressed bytes per ZIP entry [default: 64 MiB]
#[arg(long, value_name = "BYTES")]
max_archive_entry_size: Option<u64>,
/// Maximum decompressed bytes per ZIP container [default: 256 MiB]
#[arg(long, value_name = "BYTES")]
max_archive_total_size: Option<u64>,
/// Maximum data rows rendered per sheet [default: 10000]
#[arg(long, value_name = "N")]
max_sheet_rows: Option<usize>,
/// Maximum columns rendered per sheet [default: 256]
#[arg(long, value_name = "N")]
max_sheet_cols: Option<usize>,
/// Maximum XLS/ODS cell grid [default: 4000000]
#[arg(long, value_name = "N")]
max_spreadsheet_cells: Option<u64>,
/// Maximum redirects followed when fetching a URL [default: 5]
#[arg(long, value_name = "N")]
max_redirects: Option<usize>,
}

impl LimitArgs {
fn apply(&self, mut l: Limits) -> Limits {
macro_rules! set {
($($f:ident),*) => {$( if let Some(v) = self.$f { l.$f = v; } )*};
}
set!(
max_input_size,
max_output_size,
timeout,
max_nesting_depth,
max_nodes,
max_archive_entries,
max_archive_entry_size,
max_archive_total_size,
max_sheet_rows,
max_sheet_cols,
max_spreadsheet_cells,
max_redirects
);
l
}
}

fn parse_secs(s: &str) -> Result<Duration, String> {
let secs: f64 = s.parse().map_err(|e| format!("{e}"))?;
Duration::try_from_secs_f64(secs).map_err(|e| format!("{e}"))
}

#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
Expand All @@ -68,7 +139,7 @@ async fn main() -> Result<()> {
stdin,
include_selectors,
exclude_selectors,
max_size,
limits,
json: output_json,
base_url,
main_content,
Expand All @@ -80,7 +151,7 @@ async fn main() -> Result<()> {
}

let mut builder = Markkit::builder()
.max_input_size(max_size)
.limits(limits.apply(Limits::default()))
.main_content(main_content);

for sel in &include_selectors {
Expand Down Expand Up @@ -141,4 +212,60 @@ mod tests {
fn verify_cli() {
Cli::command().debug_assert();
}

fn limits_from(args: &[&str]) -> Limits {
let cli = Cli::try_parse_from(["markkit", "extract", "x"].iter().chain(args)).unwrap();
let Commands::Extract { limits, .. } = cli.command;
limits.apply(Limits::default())
}

#[test]
fn unset_limit_flags_keep_defaults() {
assert_eq!(limits_from(&[]), Limits::default());
}

#[test]
fn every_limit_flag_maps() {
let l = limits_from(&[
"--max-input-size=1",
"--max-output-size=2",
"--timeout=0.5",
"--max-nesting-depth=3",
"--max-nodes=4",
"--max-archive-entries=5",
"--max-archive-entry-size=6",
"--max-archive-total-size=7",
"--max-sheet-rows=8",
"--max-sheet-cols=9",
"--max-spreadsheet-cells=10",
"--max-redirects=11",
]);
let want = Limits::default()
.max_input_size(1)
.max_output_size(2)
.timeout(Duration::from_millis(500))
.max_nesting_depth(3)
.max_nodes(4)
.max_archive_entries(5)
.max_archive_entry_size(6)
.max_archive_total_size(7)
.max_sheet_rows(8)
.max_sheet_cols(9)
.max_spreadsheet_cells(10)
.max_redirects(11);
assert_eq!(l, want);
}

#[test]
fn legacy_max_size_alias() {
assert_eq!(limits_from(&["--max-size=9"]).max_input_size, 9);
}

#[test]
fn bad_timeout_rejected() {
for bad in ["-1", "nan", "abc"] {
let arg = format!("--timeout={bad}");
assert!(Cli::try_parse_from(["markkit", "extract", "x", &arg]).is_err());
}
}
}
21 changes: 21 additions & 0 deletions crates/markkit-cli/tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,24 @@ fn missing_source_errors() {

assert!(!output.status.success());
}

#[test]
fn limit_flags_enforced() {
let mut child = markkit_bin()
.args(["extract", "--stdin", "--max-output-size", "5"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("failed to spawn markkit");
child
.stdin
.take()
.unwrap()
.write_all(b"<p>hello world, this is long</p>")
.unwrap();
let output = child.wait_with_output().unwrap();
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("max_output_size"), "stderr: {stderr}");
}
4 changes: 3 additions & 1 deletion crates/markkit-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ const doc = extractDocumentSync(fs.readFileSync('report.docx'), { fileName: 'rep
console.log(doc.title, doc.format, doc.warnings, doc.markdown);
```

Fields: `maxInputSize`, `maxProcessingSecs`, `preserveLinks`, `preserveImages`,
Fields: `maxInputSize`, `maxOutputSize`, `timeout` (seconds, covers fetch, parsing and
rendering; `maxProcessingSecs` is the whole-second form), `maxNodes`, `maxNestingDepth`,
`baseUrl`, `mainContent`, `preserveLinks`, `preserveImages`,
`includeSelectors`, `excludeSelectors` (added to the defaults), `maxArchiveEntries`,
`maxArchiveEntrySize`, `maxArchiveTotalSize`, `maxSheetRows`, `maxSheetCols`,
`maxSpreadsheetCells`, `includeHiddenSheets`, `allowPrivateNetworks` (trusted URLs only),
Expand Down
20 changes: 20 additions & 0 deletions crates/markkit-js/__test__/options.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,29 @@ test("input size limit", () => {
assert.throws(() => m.extractHtmlSync("<p>too long</p>", { maxInputSize: 4 }), /Resource limit/);
});

test("resource limit options", () => {
const html = "<p>hello world</p>".repeat(200);
assert.throws(() => m.extractHtmlSync(html, { maxOutputSize: 100 }), /max_output_size/);
assert.throws(() => m.extractHtmlSync(html, { maxNodes: 50 }), /max_nodes/);
assert.throws(() => m.extractHtmlSync(html, { timeout: 0 }), /timeout/);
assert.ok(m.extractHtmlSync(html, { timeout: 1e300, maxOutputSize: 1 << 20, maxNodes: 10000 }).includes("hello"));
const deep = "<blockquote>".repeat(20) + "<p>Deep</p>" + "</blockquote>".repeat(20);
assert.ok(!m.extractHtmlSync(deep, { maxNestingDepth: 5 }).includes("> ".repeat(6)));
});

test("baseUrl and mainContent options", () => {
const page = '<body><nav><a href="/n">menu</a></nav><article><h1>T</h1>' + "<p>Long article text, with commas, and more words here.</p>".repeat(8) + '<p><a href="/a">rel</a></p></article><aside>side</aside></body>';
const md = m.extractHtmlSync(page, { baseUrl: "https://x.test/d/", mainContent: true });
assert.ok(md.includes("(https://x.test/a)"));
assert.ok(!md.includes("side"));
assert.ok(m.extractHtmlSync(page).includes("side"));
});

test("invalid numeric options are rejected", () => {
assert.throws(() => m.extractHtmlSync("<p>x</p>", { maxArchiveTotalSize: -1 }), /invalid options/);
assert.throws(() => m.extractHtmlSync("<p>x</p>", { maxSpreadsheetCells: 1.5 }), /invalid options/);
assert.throws(() => m.extractHtmlSync("<p>x</p>", { timeout: -1 }), /invalid options/);
assert.throws(() => m.extractHtmlSync("<p>x</p>", { timeout: NaN }), /invalid options/);
});

test("extractDocument returns metadata", async () => {
Expand Down
47 changes: 46 additions & 1 deletion crates/markkit-js/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,22 @@ use napi_derive::napi;
pub struct MarkkitOptions {
/// Maximum input size in bytes (default 50 MiB).
pub max_input_size: Option<u32>,
/// Upper bound for `extractUrl` in seconds (default 60).
/// Wall-clock budget per call in whole seconds (default 60). Prefer `timeout`.
pub max_processing_secs: Option<u32>,
/// Wall-clock budget per call in seconds, fractions allowed (default 60);
/// covers URL fetch, parsing and rendering. Wins over `maxProcessingSecs`.
pub timeout: Option<f64>,
/// Maximum Markdown output in bytes (default 128 MiB).
pub max_output_size: Option<u32>,
/// Maximum parsed nodes (default 5 000 000).
pub max_nodes: Option<u32>,
/// Maximum element nesting (default 256, capped at 512).
pub max_nesting_depth: Option<u32>,
/// Absolute URL relative links and images resolve against (`extractUrl`
/// uses the final response URL).
pub base_url: Option<String>,
/// Keep only the detected main content (article body) of HTML pages.
pub main_content: Option<bool>,
pub preserve_links: Option<bool>,
pub preserve_images: Option<bool>,
/// CSS selectors to include (empty = whole document).
Expand Down Expand Up @@ -98,6 +112,19 @@ fn to_u64(v: f64, field: &str) -> napi::Result<u64> {
}
}

/// Seconds as a JS number; non-negative (values too large for `Duration` mean no deadline).
fn to_duration(v: f64) -> napi::Result<std::time::Duration> {
std::time::Duration::try_from_secs_f64(v).or_else(|_| {
if v.is_nan() || v < 0.0 {
Err(napi::Error::from_reason(
"invalid options: `timeout` must be a non-negative number of seconds",
))
} else {
Ok(std::time::Duration::MAX)
}
})
}

fn markkit(options: Option<MarkkitOptions>) -> napi::Result<markkit::Markkit> {
let o = options.unwrap_or_default();
let mut b = markkit::Markkit::builder();
Expand All @@ -107,6 +134,24 @@ fn markkit(options: Option<MarkkitOptions>) -> napi::Result<markkit::Markkit> {
if let Some(v) = o.max_processing_secs {
b = b.max_processing_secs(v.into());
}
if let Some(v) = o.timeout {
b = b.timeout(to_duration(v)?);
}
if let Some(v) = o.max_output_size {
b = b.max_output_size(v as usize);
}
if let Some(v) = o.max_nodes {
b = b.max_nodes(v as usize);
}
if let Some(v) = o.max_nesting_depth {
b = b.max_nesting_depth(v as usize);
}
if let Some(v) = o.base_url {
b = b.base_url(v);
}
if let Some(v) = o.main_content {
b = b.main_content(v);
}
if let Some(v) = o.preserve_links {
b = b.preserve_links(v);
}
Expand Down
9 changes: 9 additions & 0 deletions crates/markkit-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,17 @@ import markkit

print(markkit.extract_html_sync("<h1>Hello</h1>"))
# # Hello

# Resource limits (all optional, generous defaults); breaches raise RuntimeError
limits = markkit.Limits(timeout=10.0, max_output_size=8 << 20, max_nesting_depth=128)
markkit.extract_html_sync("<h1>Hello</h1>", limits=limits)
```

`Limits` fields: `max_input_size`, `max_output_size`, `timeout` (seconds),
`max_nesting_depth`, `max_nodes`, `max_archive_entries`, `max_archive_entry_size`,
`max_archive_total_size`, `max_sheet_rows`, `max_sheet_cols`,
`max_spreadsheet_cells`, `max_redirects`. Every function accepts `limits=`.

Publication verifies wheel metadata, installs the actual wheel into a clean
virtual environment and exercises native extraction before upload.

Expand Down
Loading
Loading