forked from 1b-agent/opencadstudio-upstream
Bug: Custom lineweight table entries in a CTB are keyed by a number parsed from the file and stored without an upper bound, but the only reader (resolve_lineweight) indexes through entry.lineweight: u8. A key beyond u8::MAX (e.g. 18446744073709551615 in a crafted file) caused an out-of-bounds-style failure or a multi-GB table allocation when the style was applied. Reproduce: 1. Craft a CTB whose custom lineweight table uses a huge key (usize::MAX). 2. Apply the style or open plot settings. 3. Before: overflow panic (or a giant allocation). 4. After: the key is out of range, the line is ignored like any other malformed CTB line, and the style loads. Fix: Keys are bounded to u8::MAX when parsed - exactly the range the u8-typed reader can ever use. In-range keys behave as before; keys 256 and above are dropped. Tests: a usize::MAX key does not overflow, an in-range key is kept and applied, 256 is ignored.
1130 lines
45 KiB
Rust
1130 lines
45 KiB
Rust
//! Plot Style Table — CTB (color-based) and STB (named) file support.
|
||
//!
|
||
//! CTB files map indexed drawing colors (ACI, 1-255) to pen properties:
|
||
//! RGB color override, lineweight, and screening percentage.
|
||
//!
|
||
//! File format: a fixed 60-byte header followed by zlib-compressed text.
|
||
//!
|
||
//! STB files follow the same format but use named styles instead of
|
||
//! ACI indices; they are read into a `Vec<NamedPlotStyle>`.
|
||
|
||
use rustc_hash::FxHashMap as HashMap;
|
||
use std::io::Read;
|
||
use std::path::{Component, Path};
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
use std::path::PathBuf;
|
||
|
||
pub const DEFAULT_PLOT_STYLE: &str = "ocad.ctb";
|
||
pub const MONOCHROME_PLOT_STYLE: &str = "monochrome.ctb";
|
||
pub const GRAYSCALE_PLOT_STYLE: &str = "Grayscale.ctb";
|
||
pub const FILL_PATTERNS_PLOT_STYLE: &str = "Fill Patterns.ctb";
|
||
pub const SCREENING_100_PLOT_STYLE: &str = "Screening 100%.ctb";
|
||
pub const SCREENING_75_PLOT_STYLE: &str = "Screening 75%.ctb";
|
||
pub const SCREENING_50_PLOT_STYLE: &str = "Screening 50%.ctb";
|
||
pub const SCREENING_25_PLOT_STYLE: &str = "Screening 25%.ctb";
|
||
|
||
const STANDARD_PLOT_STYLES: &[&str] = &[
|
||
GRAYSCALE_PLOT_STYLE,
|
||
FILL_PATTERNS_PLOT_STYLE,
|
||
SCREENING_100_PLOT_STYLE,
|
||
SCREENING_75_PLOT_STYLE,
|
||
SCREENING_50_PLOT_STYLE,
|
||
SCREENING_25_PLOT_STYLE,
|
||
];
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub fn plot_styles_dir() -> Result<PathBuf, String> {
|
||
crate::config::config_dir()
|
||
.map(|path| path.join("plotstyles"))
|
||
.ok_or_else(|| "Plot styles folder could not be resolved".to_string())
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub fn ensure_plot_styles_dir() -> Result<PathBuf, String> {
|
||
let dir = plot_styles_dir()?;
|
||
std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
|
||
for (name, bytes) in [
|
||
(
|
||
DEFAULT_PLOT_STYLE,
|
||
include_bytes!("../../assets/plotstyles/ocad.ctb").as_slice(),
|
||
),
|
||
(
|
||
MONOCHROME_PLOT_STYLE,
|
||
include_bytes!("../../assets/plotstyles/monochrome.ctb").as_slice(),
|
||
),
|
||
] {
|
||
let path = dir.join(name);
|
||
if !path.exists() {
|
||
std::fs::write(path, bytes).map_err(|error| error.to_string())?;
|
||
}
|
||
}
|
||
Ok(dir)
|
||
}
|
||
|
||
/// CTB files available to the Plot dialog.
|
||
pub fn available_ctb_names() -> Vec<String> {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let Ok(dir) = ensure_plot_styles_dir() else {
|
||
let mut names = vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()];
|
||
names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string()));
|
||
return names;
|
||
};
|
||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||
let mut names = vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()];
|
||
names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string()));
|
||
return names;
|
||
};
|
||
let mut names: Vec<String> = entries
|
||
.filter_map(Result::ok)
|
||
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file()))
|
||
.filter_map(|entry| {
|
||
let path = entry.path();
|
||
path.extension()
|
||
.is_some_and(|ext| ext.eq_ignore_ascii_case("ctb"))
|
||
.then(|| entry.file_name().to_string_lossy().into_owned())
|
||
})
|
||
.collect();
|
||
names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string()));
|
||
names.sort_by_key(|name| name.to_ascii_lowercase());
|
||
names.dedup_by(|left, right| left.eq_ignore_ascii_case(right));
|
||
names
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
let mut names = vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()];
|
||
names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string()));
|
||
names
|
||
}
|
||
}
|
||
|
||
// ── Standard lineweight table (index → mm) ───────────────────────────────────
|
||
|
||
/// Lineweight table: index value → mm, matching the stored LWEIGHT codes.
|
||
/// Index 0 = 0.00 mm (hairline), others follow the DXF lineweight enum.
|
||
pub const LW_TABLE: &[f32] = &[
|
||
0.00, 0.05, 0.09, 0.10, 0.13, 0.15, 0.18, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50,
|
||
0.53, 0.60, 0.65, 0.70, 0.80, 0.90, 1.00, 1.06, 1.20, 1.40, 1.58, 2.00, 2.11,
|
||
];
|
||
|
||
// ── Per-color entry ───────────────────────────────────────────────────────────
|
||
|
||
/// A single entry in a CTB or STB plot style table.
|
||
#[derive(Debug, Clone)]
|
||
pub struct PlotStyleEntry {
|
||
pub name: String,
|
||
pub localized_name: String,
|
||
pub description: String,
|
||
/// If `Some([r,g,b])`, override the entity color with this RGB value (0..255).
|
||
/// If `None`, use the object color.
|
||
pub color: Option<[u8; 3]>,
|
||
/// Lineweight index into the table. 0 (and legacy 255) = object lineweight.
|
||
pub lineweight: u8,
|
||
/// Screen percentage 0–100 (100 = opaque).
|
||
pub screening: u8,
|
||
pub color_policy: u8,
|
||
pub physical_pen_number: u16,
|
||
pub virtual_pen_number: u16,
|
||
pub linepattern_size: f32,
|
||
pub linetype: u8,
|
||
pub adaptive_linetype: bool,
|
||
pub fill_style: u8,
|
||
pub end_style: u8,
|
||
pub join_style: u8,
|
||
}
|
||
|
||
impl Default for PlotStyleEntry {
|
||
fn default() -> Self {
|
||
PlotStyleEntry {
|
||
name: String::new(),
|
||
localized_name: String::new(),
|
||
description: String::new(),
|
||
color: None,
|
||
lineweight: 0,
|
||
screening: 100,
|
||
color_policy: 1,
|
||
physical_pen_number: 0,
|
||
virtual_pen_number: 0,
|
||
linepattern_size: 0.5,
|
||
linetype: 31,
|
||
adaptive_linetype: true,
|
||
fill_style: 73,
|
||
end_style: 4,
|
||
join_style: 5,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Plot Style Table ──────────────────────────────────────────────────────────
|
||
|
||
/// A loaded CTB or STB plot style table.
|
||
#[derive(Debug, Clone)]
|
||
#[allow(dead_code)]
|
||
pub struct PlotStyleTable {
|
||
/// File name (without path), e.g. "monochrome.ctb".
|
||
pub name: String,
|
||
/// Whether this is a named-style (STB) table rather than color-based (CTB).
|
||
pub is_stb: bool,
|
||
pub description: String,
|
||
pub scale_factor: f32,
|
||
pub apply_factor: bool,
|
||
pub custom_lineweight_display_units: u8,
|
||
pub lineweights: Vec<f32>,
|
||
/// For CTB: entries indexed by ACI (index 0 unused; 1..=255 are valid).
|
||
pub aci_entries: Vec<PlotStyleEntry>, // 256 entries, index = ACI
|
||
/// For STB: named style entries.
|
||
pub named_entries: HashMap<String, PlotStyleEntry>,
|
||
/// What had to be tolerated to read the file (a bad checksum, a length
|
||
/// that did not add up, descriptions in a Windows code page): the file
|
||
/// loaded, but the user deserves to know it was not quite as written.
|
||
pub load_warnings: Vec<String>,
|
||
}
|
||
|
||
impl PlotStyleTable {
|
||
/// Create an identity CTB table (no overrides for any color).
|
||
pub fn identity(name: impl Into<String>) -> Self {
|
||
PlotStyleTable {
|
||
name: name.into(),
|
||
is_stb: false,
|
||
description: String::new(),
|
||
scale_factor: 1.0,
|
||
apply_factor: false,
|
||
custom_lineweight_display_units: 0,
|
||
lineweights: LW_TABLE.to_vec(),
|
||
aci_entries: (0..=255).map(|_| PlotStyleEntry::default()).collect(),
|
||
named_entries: HashMap::default(),
|
||
load_warnings: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Load a CTB or STB file from disk.
|
||
pub fn load(path: &Path) -> Result<Self, String> {
|
||
let raw = std::fs::read(path).map_err(|e| e.to_string())?;
|
||
let name = path
|
||
.file_name()
|
||
.unwrap_or_default()
|
||
.to_string_lossy()
|
||
.into_owned();
|
||
Self::from_bytes(name, &raw)
|
||
}
|
||
|
||
pub fn from_bytes(name: impl Into<String>, raw: &[u8]) -> Result<Self, String> {
|
||
let name = name.into();
|
||
let is_stb = name.to_ascii_lowercase().ends_with(".stb");
|
||
let (text, warnings) = decompress_ctb(raw)?;
|
||
let mut table = parse_plot_style_text(&text, name, is_stb)?;
|
||
table.load_warnings = warnings;
|
||
Ok(table)
|
||
}
|
||
|
||
pub fn builtin(name: &str) -> Result<Self, String> {
|
||
match name.to_ascii_lowercase().as_str() {
|
||
DEFAULT_PLOT_STYLE => Self::from_bytes(
|
||
DEFAULT_PLOT_STYLE,
|
||
include_bytes!("../../assets/plotstyles/ocad.ctb"),
|
||
),
|
||
MONOCHROME_PLOT_STYLE => Self::from_bytes(
|
||
MONOCHROME_PLOT_STYLE,
|
||
include_bytes!("../../assets/plotstyles/monochrome.ctb"),
|
||
),
|
||
"grayscale.ctb" => {
|
||
let mut table = Self::identity(GRAYSCALE_PLOT_STYLE);
|
||
for aci in 1..=255u8 {
|
||
if let Some((r, g, b)) = codec::types::aci_to_rgb(aci) {
|
||
let gray = if aci == 7 {
|
||
0
|
||
} else {
|
||
(0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32)
|
||
.round() as u8
|
||
};
|
||
table.aci_entries[aci as usize].color = Some([gray, gray, gray]);
|
||
}
|
||
}
|
||
Ok(table)
|
||
}
|
||
"fill patterns.ctb" => {
|
||
let mut table = Self::identity(FILL_PATTERNS_PLOT_STYLE);
|
||
for aci in 1..=9usize {
|
||
table.aci_entries[aci].fill_style = 63 + aci as u8;
|
||
}
|
||
Ok(table)
|
||
}
|
||
"screening 100%.ctb" => Ok(Self::screening(SCREENING_100_PLOT_STYLE, 100)),
|
||
"screening 75%.ctb" => Ok(Self::screening(SCREENING_75_PLOT_STYLE, 75)),
|
||
"screening 50%.ctb" => Ok(Self::screening(SCREENING_50_PLOT_STYLE, 50)),
|
||
"screening 25%.ctb" => Ok(Self::screening(SCREENING_25_PLOT_STYLE, 25)),
|
||
_ => Err(format!("Unknown built-in plot style: {name}")),
|
||
}
|
||
}
|
||
|
||
fn screening(name: &str, percent: u8) -> Self {
|
||
let mut table = Self::identity(name);
|
||
for entry in table.aci_entries.iter_mut().skip(1) {
|
||
entry.screening = percent;
|
||
}
|
||
table
|
||
}
|
||
|
||
/// Load one CTB by file name from the per-user plot styles folder.
|
||
pub fn load_named(name: &str) -> Result<Self, String> {
|
||
let path = Path::new(name);
|
||
if path.components().count() != 1
|
||
|| !matches!(path.components().next(), Some(Component::Normal(_)))
|
||
{
|
||
return Err(format!("Invalid plot style name: {name}"));
|
||
}
|
||
if path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("stb")) {
|
||
return Err(crate::tf!(
|
||
"Named plot style tables (.stb) are not supported yet: {name}"
|
||
)
|
||
.into_owned());
|
||
}
|
||
if !path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("ctb")) {
|
||
return Err(format!("Invalid plot style name: {name}"));
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let dir = ensure_plot_styles_dir()?;
|
||
let matched = std::fs::read_dir(&dir)
|
||
.map_err(|error| error.to_string())?
|
||
.filter_map(Result::ok)
|
||
.find(|entry| {
|
||
entry
|
||
.file_name()
|
||
.to_string_lossy()
|
||
.eq_ignore_ascii_case(name)
|
||
})
|
||
.map(|entry| entry.path());
|
||
return match matched {
|
||
Some(path) => Self::load(&path),
|
||
None => Self::builtin(name).map_err(|_| {
|
||
// Not a built-in either: say where a copy would be found.
|
||
let folder = dir.display();
|
||
crate::tf!("{name} was not found in the plot styles folder ({folder}).")
|
||
.into_owned()
|
||
}),
|
||
};
|
||
}
|
||
|
||
#[cfg(target_arch = "wasm32")]
|
||
Self::builtin(name)
|
||
}
|
||
|
||
/// Write this table to disk as a CTB/STB file.
|
||
#[allow(dead_code)]
|
||
pub fn save(&self, path: &Path) -> Result<(), String> {
|
||
let text = self.to_text();
|
||
let compressed = compress_ctb(text.as_bytes())?;
|
||
std::fs::write(path, compressed).map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// Resolve the effective print RGB color for the given ACI index.
|
||
/// Returns None if no override (use object color).
|
||
pub fn resolve_color(&self, aci: u8) -> Option<[f32; 3]> {
|
||
let entry = self.aci_entries.get(aci as usize)?;
|
||
let mut color = entry
|
||
.color
|
||
.map(|[r, g, b]| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]);
|
||
if entry.color_policy & 2 != 0 {
|
||
let rgb = color.or_else(|| {
|
||
if aci == 7 {
|
||
Some([0.0; 3])
|
||
} else {
|
||
codec::types::aci_to_rgb(aci).map(|(r, g, b)| {
|
||
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]
|
||
})
|
||
}
|
||
})?;
|
||
let gray = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
|
||
color = Some([gray; 3]);
|
||
}
|
||
color
|
||
}
|
||
|
||
/// Resolve the effective lineweight in mm for the given ACI index.
|
||
/// Returns None if no override (use object lineweight).
|
||
pub fn resolve_lineweight(&self, aci: u8) -> Option<f32> {
|
||
let entry = self.aci_entries.get(aci as usize)?;
|
||
if matches!(entry.lineweight, 0 | 255) {
|
||
None
|
||
} else {
|
||
self.lineweights.get(entry.lineweight as usize).copied().map(|weight| {
|
||
if self.apply_factor {
|
||
weight * self.scale_factor.max(0.0)
|
||
} else {
|
||
weight
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Resolve the screening factor for the given ACI index.
|
||
pub fn resolve_screening(&self, aci: u8) -> f32 {
|
||
self.aci_entries
|
||
.get(aci as usize)
|
||
.map(|entry| entry.screening.min(100) as f32 / 100.0)
|
||
.unwrap_or(1.0)
|
||
}
|
||
|
||
// ── Internal serialisation ────────────────────────────────────────────
|
||
|
||
fn to_text(&self) -> String {
|
||
let mut s = String::new();
|
||
let description = self.description.replace(['\r', '\n'], " ");
|
||
s.push_str(&format!("description=\"{description}\n"));
|
||
s.push_str("aci_table_available=TRUE\n");
|
||
// The factor scales every lineweight when applied, so write it in full.
|
||
s.push_str(&format!("scale_factor={}\n", full_real(self.scale_factor)));
|
||
s.push_str(&format!(
|
||
"apply_factor={}\n",
|
||
if self.apply_factor { "TRUE" } else { "FALSE" }
|
||
));
|
||
s.push_str(&format!(
|
||
"custom_lineweight_display_units={}\n",
|
||
self.custom_lineweight_display_units
|
||
));
|
||
s.push_str("aci_table{\n");
|
||
for index in 0..255 {
|
||
s.push_str(&format!(" {index}=\"Color_{}\n", index + 1));
|
||
}
|
||
s.push_str("}\nplot_style{\n");
|
||
for (index, entry) in self.aci_entries.iter().enumerate().skip(1).take(255) {
|
||
let style_index = index - 1;
|
||
let style_name = if entry.name.is_empty() {
|
||
format!("Color_{index}")
|
||
} else {
|
||
entry.name.replace(['\r', '\n'], " ")
|
||
};
|
||
let localized_name = if entry.localized_name.is_empty() {
|
||
style_name.clone()
|
||
} else {
|
||
entry.localized_name.replace(['\r', '\n'], " ")
|
||
};
|
||
let description = entry.description.replace(['\r', '\n'], " ");
|
||
s.push_str(&format!(" {style_index}{{\n"));
|
||
s.push_str(&format!(" name=\"{style_name}\n"));
|
||
s.push_str(&format!(" localized_name=\"{localized_name}\n"));
|
||
s.push_str(&format!(" description=\"{description}\n"));
|
||
if let Some(rgb) = entry.color {
|
||
let packed = packed_rgb(rgb);
|
||
s.push_str(&format!(" color={packed}\n mode_color={packed}\n"));
|
||
} else {
|
||
s.push_str(" color=-1\n");
|
||
}
|
||
s.push_str(&format!(" color_policy={}\n", entry.color_policy));
|
||
s.push_str(&format!(
|
||
" physical_pen_number={}\n virtual_pen_number={}\n",
|
||
entry.physical_pen_number, entry.virtual_pen_number
|
||
));
|
||
s.push_str(&format!(" screen={}\n", entry.screening));
|
||
s.push_str(&format!(
|
||
" linepattern_size={}\n linetype={}\n adaptive_linetype={}\n",
|
||
entry.linepattern_size,
|
||
entry.linetype,
|
||
if entry.adaptive_linetype { "TRUE" } else { "FALSE" }
|
||
));
|
||
s.push_str(&format!(" lineweight={}\n", entry.lineweight));
|
||
s.push_str(&format!(
|
||
" fill_style={}\n end_style={}\n join_style={}\n }}\n",
|
||
entry.fill_style, entry.end_style, entry.join_style
|
||
));
|
||
}
|
||
s.push_str("}\ncustom_lineweight_table{\n");
|
||
for (index, weight) in self.lineweights.iter().enumerate() {
|
||
s.push_str(&format!(" {index}={}\n", full_real(*weight)));
|
||
}
|
||
s.push_str("}\n");
|
||
s
|
||
}
|
||
}
|
||
|
||
// ── Deflate helpers ───────────────────────────────────────────────────────────
|
||
|
||
/// Decompress a CTB/STB file's raw bytes into the text content, with the
|
||
/// things that had to be tolerated on the way.
|
||
///
|
||
/// Files written by other applications are read as they are, not as the
|
||
/// container says they should be: a checksum or a length that does not add
|
||
/// up is reported and read past (the text is either there or the zlib
|
||
/// stream fails on its own), a zlib stream with a bad trailer is retried as
|
||
/// raw deflate, and text that is not UTF-8 is read as Windows-1252 — the
|
||
/// code page those applications write descriptions in.
|
||
fn decompress_ctb(data: &[u8]) -> Result<(String, Vec<String>), String> {
|
||
const PREFIX: &[u8] = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec";
|
||
let mut warnings = Vec::new();
|
||
let mut decoded;
|
||
if data.starts_with(PREFIX) {
|
||
if data.len() < 60 {
|
||
return Err("CTB header is truncated".into());
|
||
}
|
||
let checksum = u32::from_le_bytes(data[48..52].try_into().unwrap());
|
||
let text_len = u32::from_le_bytes(data[52..56].try_into().unwrap()) as usize;
|
||
let compressed_len = u32::from_le_bytes(data[56..60].try_into().unwrap()) as usize;
|
||
let available = data.len() - 60;
|
||
let payload = if compressed_len > available {
|
||
warnings.push(format!(
|
||
"the header announces {compressed_len} compressed bytes \
|
||
but the file holds {available}"
|
||
));
|
||
&data[60..]
|
||
} else {
|
||
&data[60..60 + compressed_len]
|
||
};
|
||
if adler32(payload) != checksum {
|
||
warnings.push("the compressed payload's checksum does not match".into());
|
||
}
|
||
decoded = inflate_lenient(payload, &mut warnings)?;
|
||
// A byte or a few short is a header that counted its trailing NUL
|
||
// differently; far short of the announced text is a cut-off file,
|
||
// which the inflater does not always notice on its own.
|
||
const TOLERATED_SHORTFALL: usize = 16;
|
||
if decoded.len() + TOLERATED_SHORTFALL < text_len {
|
||
return Err(format!(
|
||
"CTB text is incomplete: the header announces {text_len} bytes \
|
||
but only {} could be read",
|
||
decoded.len()
|
||
));
|
||
}
|
||
if decoded.len() != text_len {
|
||
warnings.push(format!(
|
||
"the header announces {text_len} bytes of text but {} were read",
|
||
decoded.len()
|
||
));
|
||
}
|
||
} else {
|
||
let split_at = data
|
||
.iter()
|
||
.position(|&b| b == b'\n')
|
||
.map(|p| p + 1)
|
||
.unwrap_or(0);
|
||
let payload = &data[split_at..];
|
||
if payload.starts_with(&[0x78]) {
|
||
decoded = inflate_lenient(payload, &mut warnings)?;
|
||
} else {
|
||
use flate2::read::DeflateDecoder;
|
||
decoded = inflate_capped(
|
||
DeflateDecoder::new(payload),
|
||
"legacy CTB deflate decompress",
|
||
)?;
|
||
}
|
||
}
|
||
while decoded.last() == Some(&0) {
|
||
decoded.pop();
|
||
}
|
||
let (text, code_page) = decode_plot_style_text(decoded);
|
||
if let Some(note) = code_page {
|
||
warnings.push(note);
|
||
}
|
||
Ok((text, warnings))
|
||
}
|
||
|
||
/// Far above any real plot-style table (a CTB is a text config file), so a
|
||
/// crafted few-hundred-KB payload cannot inflate until memory runs out —
|
||
/// flate2's readers have no output budget of their own. Same idea as the
|
||
/// `STREAM_LIMIT` in `dgn8.rs`.
|
||
const MAX_INFLATED_BYTES: u64 = 64 * 1024 * 1024;
|
||
|
||
/// One decompression with a hard output budget: `read_to_end` grows its
|
||
/// `Vec` without bound, so the reader is capped and a payload that reaches
|
||
/// past the cap is refused — an allocation failure is an *abort*, not a
|
||
/// catchable panic. The reader is allowed one byte past the cap:
|
||
/// `take(cap)` stops *at* the cap, which cannot distinguish "exactly cap"
|
||
/// (within the contract — "exceeds means exceeds") from "at least cap",
|
||
/// so `cap + 1` makes the boundary unambiguous.
|
||
fn inflate_capped(reader: impl Read, what: &str) -> Result<Vec<u8>, String> {
|
||
let mut out = Vec::new();
|
||
let read = reader
|
||
.take(MAX_INFLATED_BYTES + 1)
|
||
.read_to_end(&mut out)
|
||
.map_err(|e| format!("{what}: {e}"))?;
|
||
if read as u64 > MAX_INFLATED_BYTES {
|
||
return Err(format!(
|
||
"{what}: inflated data exceeds the {MAX_INFLATED_BYTES}-byte limit"
|
||
));
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Inflate a zlib stream, falling back to the raw deflate data behind its
|
||
/// two-byte header when the stream's own trailer is wrong.
|
||
fn inflate_lenient(payload: &[u8], warnings: &mut Vec<String>) -> Result<Vec<u8>, String> {
|
||
use flate2::read::{DeflateDecoder, ZlibDecoder};
|
||
match inflate_capped(ZlibDecoder::new(payload), "CTB zlib decompress") {
|
||
Ok(decoded) => Ok(decoded),
|
||
Err(zlib_error) if payload.len() > 2 => {
|
||
let raw = inflate_capped(DeflateDecoder::new(&payload[2..]), "CTB zlib decompress")
|
||
.map_err(|_| zlib_error)?;
|
||
warnings.push("the zlib stream is damaged; its deflate data was read directly".into());
|
||
Ok(raw)
|
||
}
|
||
Err(zlib_error) => Err(zlib_error),
|
||
}
|
||
}
|
||
|
||
/// The table's text out of its bytes: UTF-8 when it is, else Windows-1252
|
||
/// (Latin-1 with the 0x80–0x9F block the code page defines), which is what
|
||
/// applications on Western systems write into descriptions. Other code
|
||
/// pages come out as readable but wrong characters in descriptions only —
|
||
/// pens and colours are numbers and unaffected. The second value says when
|
||
/// the fallback was taken.
|
||
pub fn decode_plot_style_text(bytes: Vec<u8>) -> (String, Option<String>) {
|
||
match String::from_utf8(bytes) {
|
||
Ok(text) => (text, None),
|
||
Err(error) => {
|
||
let text = error
|
||
.into_bytes()
|
||
.into_iter()
|
||
.map(windows_1252_char)
|
||
.collect();
|
||
(
|
||
text,
|
||
Some("descriptions were read as Windows-1252 text (the file is not UTF-8)".into()),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One Windows-1252 byte as a character: Latin-1 except for the 0x80–0x9F
|
||
/// block, which the code page fills with punctuation and accented letters.
|
||
fn windows_1252_char(byte: u8) -> char {
|
||
const HIGH: [char; 32] = [
|
||
'\u{20AC}', '\u{FFFD}', '\u{201A}', '\u{0192}', '\u{201E}', '\u{2026}', '\u{2020}',
|
||
'\u{2021}', '\u{02C6}', '\u{2030}', '\u{0160}', '\u{2039}', '\u{0152}', '\u{FFFD}',
|
||
'\u{017D}', '\u{FFFD}', '\u{FFFD}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}',
|
||
'\u{2022}', '\u{2013}', '\u{2014}', '\u{02DC}', '\u{2122}', '\u{0161}', '\u{203A}',
|
||
'\u{0153}', '\u{FFFD}', '\u{017E}', '\u{0178}',
|
||
];
|
||
match byte {
|
||
0x80..=0x9F => HIGH[usize::from(byte - 0x80)],
|
||
_ => char::from(byte),
|
||
}
|
||
}
|
||
|
||
/// Compress plot-style text content as a CTB/STB file.
|
||
fn compress_ctb(text: &[u8]) -> Result<Vec<u8>, String> {
|
||
use flate2::{write::ZlibEncoder, Compression};
|
||
use std::io::Write;
|
||
const PREFIX: &[u8] = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec";
|
||
let mut body = text.to_vec();
|
||
body.push(0);
|
||
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
|
||
enc.write_all(&body).map_err(|e| e.to_string())?;
|
||
let compressed = enc.finish().map_err(|e| e.to_string())?;
|
||
let mut out = PREFIX.to_vec();
|
||
out.extend_from_slice(&adler32(&compressed).to_le_bytes());
|
||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||
out.extend_from_slice(&(compressed.len() as u32).to_le_bytes());
|
||
out.extend_from_slice(&compressed);
|
||
Ok(out)
|
||
}
|
||
|
||
fn adler32(bytes: &[u8]) -> u32 {
|
||
const MOD: u32 = 65_521;
|
||
let mut a = 1u32;
|
||
let mut b = 0u32;
|
||
for byte in bytes {
|
||
a = (a + u32::from(*byte)) % MOD;
|
||
b = (b + a) % MOD;
|
||
}
|
||
(b << 16) | a
|
||
}
|
||
|
||
/// A real number written in full, keeping the decimal point on a whole value.
|
||
fn full_real(value: f32) -> String {
|
||
let mut text = value.to_string();
|
||
if text.chars().all(|c| c.is_ascii_digit() || c == '-') {
|
||
text.push_str(".0");
|
||
}
|
||
text
|
||
}
|
||
|
||
fn packed_rgb([r, g, b]: [u8; 3]) -> i32 {
|
||
u32::from_be_bytes([0xC2, r, g, b]) as i32
|
||
}
|
||
|
||
// ── Text parser ───────────────────────────────────────────────────────────────
|
||
|
||
fn parse_plot_style_text(text: &str, name: String, is_stb: bool) -> Result<PlotStyleTable, String> {
|
||
if text.lines().any(|line| line.trim() == "begin_plot_style") {
|
||
return parse_legacy_plot_style_text(text, name, is_stb);
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct PendingStyle {
|
||
index: usize,
|
||
name: String,
|
||
entry: PlotStyleEntry,
|
||
color: Option<i32>,
|
||
mode_color: Option<i32>,
|
||
}
|
||
|
||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||
enum Section {
|
||
Root,
|
||
Other,
|
||
PlotStyles,
|
||
Lineweights,
|
||
}
|
||
|
||
let mut aci_entries: Vec<PlotStyleEntry> =
|
||
(0..=255).map(|_| PlotStyleEntry::default()).collect();
|
||
let mut named_entries: HashMap<String, PlotStyleEntry> = HashMap::default();
|
||
let mut description = String::new();
|
||
let mut scale_factor = 1.0f32;
|
||
let mut apply_factor = false;
|
||
let mut custom_lineweight_display_units = 0u8;
|
||
let mut lineweights = Vec::<f32>::new();
|
||
let mut section = Section::Root;
|
||
let mut current: Option<PendingStyle> = None;
|
||
|
||
for line in text.lines() {
|
||
let line = line.trim();
|
||
if line.is_empty() {
|
||
continue;
|
||
}
|
||
if line == "plot_style{" {
|
||
section = Section::PlotStyles;
|
||
continue;
|
||
}
|
||
if line == "custom_lineweight_table{" {
|
||
section = Section::Lineweights;
|
||
continue;
|
||
}
|
||
if line.ends_with('{') {
|
||
if section == Section::PlotStyles && current.is_none() {
|
||
if let Ok(index) = line.trim_end_matches('{').trim().parse::<usize>() {
|
||
let default_name = format!("Color_{}", index + 1);
|
||
let mut style = PendingStyle {
|
||
index,
|
||
name: default_name.clone(),
|
||
..Default::default()
|
||
};
|
||
style.entry.name = default_name.clone();
|
||
style.entry.localized_name = default_name;
|
||
current = Some(style);
|
||
continue;
|
||
}
|
||
}
|
||
section = Section::Other;
|
||
continue;
|
||
}
|
||
if line == "}" {
|
||
if let Some(mut style) = current.take() {
|
||
let packed = style.mode_color.or(style.color);
|
||
style.entry.color = packed.and_then(unpack_plot_color);
|
||
if is_stb {
|
||
named_entries.insert(style.name, style.entry);
|
||
} else if style.index < 255 {
|
||
aci_entries[style.index + 1] = style.entry;
|
||
}
|
||
} else {
|
||
section = Section::Root;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
let Some((key, value)) = line.split_once('=') else {
|
||
continue;
|
||
};
|
||
let key = key.trim();
|
||
let value = value.trim().trim_start_matches('"');
|
||
if let Some(style) = current.as_mut() {
|
||
match key {
|
||
"name" => {
|
||
style.name = value.to_string();
|
||
style.entry.name = value.to_string();
|
||
}
|
||
"localized_name" => style.entry.localized_name = value.to_string(),
|
||
"description" => {
|
||
style.entry.description = value.to_string();
|
||
}
|
||
"screen" => {
|
||
if let Ok(v) = value.parse::<u8>() {
|
||
style.entry.screening = v.min(100);
|
||
}
|
||
}
|
||
"lineweight" => {
|
||
if let Ok(v) = value.parse::<u8>() {
|
||
style.entry.lineweight = v;
|
||
}
|
||
}
|
||
"color" => style.color = value.parse::<i32>().ok(),
|
||
"mode_color" => style.mode_color = value.parse::<i32>().ok(),
|
||
"color_policy" => style.entry.color_policy = value.parse().unwrap_or(1),
|
||
"physical_pen_number" => {
|
||
style.entry.physical_pen_number = value.parse().unwrap_or(0)
|
||
}
|
||
"virtual_pen_number" => {
|
||
style.entry.virtual_pen_number = value.parse().unwrap_or(0)
|
||
}
|
||
"linepattern_size" => {
|
||
style.entry.linepattern_size = value.parse().unwrap_or(0.5)
|
||
}
|
||
"linetype" => style.entry.linetype = value.parse().unwrap_or(31),
|
||
"adaptive_linetype" => {
|
||
style.entry.adaptive_linetype = value.eq_ignore_ascii_case("TRUE")
|
||
}
|
||
"fill_style" => style.entry.fill_style = value.parse().unwrap_or(73),
|
||
"end_style" => style.entry.end_style = value.parse().unwrap_or(4),
|
||
"join_style" => style.entry.join_style = value.parse().unwrap_or(5),
|
||
_ => {}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
match section {
|
||
Section::Root => match key {
|
||
"description" => description = value.to_string(),
|
||
"scale_factor" => scale_factor = value.parse().unwrap_or(1.0),
|
||
"apply_factor" => apply_factor = value.eq_ignore_ascii_case("TRUE"),
|
||
"custom_lineweight_display_units" => {
|
||
custom_lineweight_display_units = value.parse().unwrap_or(0)
|
||
}
|
||
_ => {}
|
||
},
|
||
Section::Lineweights => {
|
||
// The key is file-controlled: `18446744073709551615` made
|
||
// `index + 1` overflow (debug panic, release wrap to
|
||
// `resize(0)` then OOB), and `500000000` resized a 2 GB
|
||
// table. Entries past 255 are unreachable — the consumer
|
||
// indexes with `PlotStyleEntry.lineweight: u8` — so
|
||
// oversized keys are ignored like other malformed lines.
|
||
if let (Ok(index), Ok(weight)) = (key.parse::<usize>(), value.parse::<f32>()) {
|
||
if index <= u8::MAX as usize {
|
||
if lineweights.len() <= index {
|
||
lineweights.resize(index + 1, 0.0);
|
||
}
|
||
lineweights[index] = weight;
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
if lineweights.is_empty() {
|
||
lineweights = LW_TABLE.to_vec();
|
||
}
|
||
|
||
Ok(PlotStyleTable {
|
||
name,
|
||
is_stb,
|
||
description,
|
||
scale_factor,
|
||
apply_factor,
|
||
custom_lineweight_display_units,
|
||
lineweights,
|
||
aci_entries,
|
||
named_entries,
|
||
load_warnings: Vec::new(),
|
||
})
|
||
}
|
||
|
||
fn unpack_plot_color(packed: i32) -> Option<[u8; 3]> {
|
||
if matches!(packed, -1 | -1_006_632_961 | -1_056_964_608) {
|
||
return None;
|
||
}
|
||
let bytes = (packed as u32).to_be_bytes();
|
||
Some([bytes[1], bytes[2], bytes[3]])
|
||
}
|
||
|
||
fn parse_legacy_plot_style_text(
|
||
text: &str,
|
||
name: String,
|
||
is_stb: bool,
|
||
) -> Result<PlotStyleTable, String> {
|
||
let mut table = PlotStyleTable::identity(name);
|
||
table.is_stb = is_stb;
|
||
let mut style_index = 1usize;
|
||
let mut current: Option<PlotStyleEntry> = None;
|
||
let mut current_name = String::new();
|
||
for line in text.lines().map(str::trim) {
|
||
if line == "begin_plot_style" {
|
||
current = Some(PlotStyleEntry::default());
|
||
current_name = format!("Color_{style_index}");
|
||
continue;
|
||
}
|
||
if line == "end_plot_style" {
|
||
if let Some(entry) = current.take() {
|
||
if is_stb {
|
||
table.named_entries.insert(current_name.clone(), entry);
|
||
} else if style_index <= 255 {
|
||
table.aci_entries[style_index] = entry;
|
||
style_index += 1;
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
let Some(entry) = current.as_mut() else {
|
||
continue;
|
||
};
|
||
let Some((key, value)) = line.split_once('=') else {
|
||
continue;
|
||
};
|
||
let value = value.trim();
|
||
match key.trim() {
|
||
"description" => {
|
||
entry.description = value.to_string();
|
||
if !value.is_empty() {
|
||
current_name = value.to_string();
|
||
}
|
||
}
|
||
"screen" => entry.screening = value.parse::<u8>().unwrap_or(100).min(100),
|
||
"lineweight" => entry.lineweight = value.parse().unwrap_or(0),
|
||
"color1" if value.starts_with('#') && value.len() == 7 => {
|
||
entry.color = Some([
|
||
u8::from_str_radix(&value[1..3], 16).unwrap_or(0),
|
||
u8::from_str_radix(&value[3..5], 16).unwrap_or(0),
|
||
u8::from_str_radix(&value[5..7], 16).unwrap_or(0),
|
||
]);
|
||
}
|
||
"color1" => entry.color = value.parse::<i32>().ok().and_then(unpack_plot_color),
|
||
_ => {}
|
||
}
|
||
}
|
||
Ok(table)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod round_trip_tests {
|
||
use super::{compress_ctb, PlotStyleTable};
|
||
|
||
/// What SAVE writes, read back the way the Plot dialog loads it.
|
||
fn saved_and_reloaded(table: &PlotStyleTable) -> PlotStyleTable {
|
||
let bytes = compress_ctb(table.to_text().as_bytes()).expect("compress");
|
||
PlotStyleTable::from_bytes(table.name.clone(), &bytes).expect("reload")
|
||
}
|
||
|
||
/// Saving a table keeps its global scale factor, which scales every
|
||
/// lineweight while apply_factor is on. It used to be cut to one decimal.
|
||
#[test]
|
||
fn the_scale_factor_survives_a_save() {
|
||
let mut table = PlotStyleTable::identity("scaled.ctb");
|
||
table.apply_factor = true;
|
||
table.scale_factor = 0.25;
|
||
table.aci_entries[1].lineweight = 13; // 0.50 mm
|
||
let reloaded = saved_and_reloaded(&table);
|
||
assert_eq!(reloaded.scale_factor, 0.25);
|
||
assert_eq!(reloaded.resolve_lineweight(1), Some(0.125));
|
||
}
|
||
|
||
/// A whole factor is still written with its decimal point.
|
||
#[test]
|
||
fn a_whole_scale_factor_keeps_its_decimal_point() {
|
||
let table = PlotStyleTable::identity("plain.ctb");
|
||
assert!(table.to_text().contains("\nscale_factor=1.0\n"));
|
||
assert_eq!(saved_and_reloaded(&table).scale_factor, 1.0);
|
||
}
|
||
|
||
/// Saving keeps each custom lineweight as the table holds it. They used to
|
||
/// be cut to two decimals, so a 0.035 mm pen came back as a 0.04 mm one.
|
||
#[test]
|
||
fn custom_lineweights_survive_a_save() {
|
||
let mut table = PlotStyleTable::identity("pens.ctb");
|
||
table.lineweights[1] = 0.035;
|
||
table.aci_entries[1].lineweight = 1;
|
||
let reloaded = saved_and_reloaded(&table);
|
||
assert_eq!(reloaded.lineweights, table.lineweights);
|
||
assert_eq!(reloaded.resolve_lineweight(1), Some(0.035));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod lenient_loading_tests {
|
||
use super::{compress_ctb, decode_plot_style_text, PlotStyleTable};
|
||
|
||
fn table_with_description(description: &str) -> PlotStyleTable {
|
||
let mut table = PlotStyleTable::identity("foreign.ctb");
|
||
table.description = description.to_string();
|
||
table.aci_entries[1].lineweight = 13;
|
||
table
|
||
}
|
||
|
||
fn compressed(table: &PlotStyleTable) -> Vec<u8> {
|
||
compress_ctb(table.to_text().as_bytes()).expect("compress")
|
||
}
|
||
|
||
/// The pen data must survive every tolerated defect.
|
||
fn assert_pens_kept(table: &PlotStyleTable) {
|
||
assert_eq!(table.aci_entries[1].lineweight, 13);
|
||
}
|
||
|
||
#[test]
|
||
fn a_wrong_checksum_is_reported_not_fatal() {
|
||
let mut bytes = compressed(&table_with_description("ok"));
|
||
bytes[48] ^= 0xFF;
|
||
let table = PlotStyleTable::from_bytes("foreign.ctb", &bytes).expect("loads");
|
||
assert_pens_kept(&table);
|
||
assert_eq!(table.load_warnings.len(), 1, "{:?}", table.load_warnings);
|
||
assert!(table.load_warnings[0].contains("checksum"));
|
||
}
|
||
|
||
#[test]
|
||
fn a_text_length_off_by_one_and_a_short_payload_field_are_tolerated() {
|
||
let mut bytes = compressed(&table_with_description("ok"));
|
||
let text_len = u32::from_le_bytes(bytes[52..56].try_into().unwrap());
|
||
bytes[52..56].copy_from_slice(&(text_len - 1).to_le_bytes());
|
||
let table = PlotStyleTable::from_bytes("foreign.ctb", &bytes).expect("loads");
|
||
assert_pens_kept(&table);
|
||
assert!(table.load_warnings.iter().any(|w| w.contains("bytes of text")));
|
||
|
||
let mut bytes = compressed(&table_with_description("ok"));
|
||
let compressed_len = u32::from_le_bytes(bytes[56..60].try_into().unwrap());
|
||
bytes[56..60].copy_from_slice(&(compressed_len + 100).to_le_bytes());
|
||
let table = PlotStyleTable::from_bytes("foreign.ctb", &bytes).expect("loads");
|
||
assert_pens_kept(&table);
|
||
assert!(table.load_warnings.iter().any(|w| w.contains("compressed bytes")));
|
||
}
|
||
|
||
#[test]
|
||
fn windows_1252_descriptions_are_read_with_a_note() {
|
||
// "Descripción" and "Ελληνικά" are not what a Western file holds; a
|
||
// Western one holds e.g. "Plumas – señal" in Windows-1252.
|
||
let text = table_with_description("marker").to_text();
|
||
let mut bytes = text.into_bytes();
|
||
let marker = bytes.windows(6).position(|w| w == b"marker").unwrap();
|
||
let cp1252 = [
|
||
0x50, 0x6C, 0x75, 0x6D, 0x61, 0x73, 0x20, 0x96, 0x20, 0x73, 0x65, 0xF1, 0x61, 0x6C,
|
||
];
|
||
bytes.splice(marker..marker + 6, cp1252);
|
||
let compressed = compress_ctb(&bytes).unwrap();
|
||
let table = PlotStyleTable::from_bytes("foreign.ctb", &compressed).expect("loads");
|
||
assert_pens_kept(&table);
|
||
assert_eq!(table.description, "Plumas – señal");
|
||
assert!(table.load_warnings.iter().any(|w| w.contains("Windows-1252")));
|
||
// Clean UTF-8 gets no note.
|
||
let (text, note) = decode_plot_style_text("Ελληνικά".as_bytes().to_vec());
|
||
assert_eq!(text, "Ελληνικά");
|
||
assert!(note.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn a_truncated_stream_is_still_an_error() {
|
||
let bytes = compressed(&table_with_description("ok"));
|
||
let cut = &bytes[..bytes.len() / 2];
|
||
let error = PlotStyleTable::from_bytes("foreign.ctb", cut).unwrap_err();
|
||
assert!(error.contains("incomplete"), "{error}");
|
||
}
|
||
|
||
#[test]
|
||
fn named_tables_and_missing_files_get_a_reason() {
|
||
let error = PlotStyleTable::load_named("styles.stb").unwrap_err();
|
||
assert!(error.contains(".stb"), "{error}");
|
||
let error = PlotStyleTable::load_named("nowhere-to-be-found.ctb").unwrap_err();
|
||
assert!(error.contains("nowhere-to-be-found.ctb"), "{error}");
|
||
assert!(PlotStyleTable::load_named("../escape.ctb").is_err());
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod inflate_limit_tests {
|
||
use super::{decompress_ctb, MAX_INFLATED_BYTES};
|
||
use std::io::Write;
|
||
|
||
/// A payload that lands exactly on the cap is within the contract
|
||
/// ("exceeds means exceeds"): it must load, not be refused. `take(cap)`
|
||
/// alone can't tell "exactly cap" from "at least cap" — the reader has
|
||
/// to be allowed one byte past the limit.
|
||
#[test]
|
||
fn an_inflate_of_exactly_the_cap_is_accepted() {
|
||
let mut encoder =
|
||
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||
encoder
|
||
.write_all(&vec![b' '; MAX_INFLATED_BYTES as usize])
|
||
.expect("compress");
|
||
let payload = encoder.finish().expect("finish");
|
||
let mut data = vec![b'\n'];
|
||
data.extend(payload);
|
||
let out = decompress_ctb(&data).expect("exactly the cap must be accepted");
|
||
assert_eq!(out.0.len() as u64, MAX_INFLATED_BYTES);
|
||
}
|
||
|
||
/// One byte over the cap is refused with the same limit error the
|
||
/// oversized bombs produce.
|
||
#[test]
|
||
fn an_inflate_one_byte_over_the_cap_is_refused() {
|
||
let mut encoder =
|
||
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||
encoder
|
||
.write_all(&vec![b' '; (MAX_INFLATED_BYTES + 1) as usize])
|
||
.expect("compress");
|
||
let payload = encoder.finish().expect("finish");
|
||
let mut data = vec![b'\n'];
|
||
data.extend(payload);
|
||
let error = decompress_ctb(&data).expect_err("one byte over must be refused");
|
||
assert!(error.contains("limit"), "{error}");
|
||
}
|
||
|
||
/// Twice the 64 MiB inflate cap the fix introduces — a payload that is
|
||
/// tiny compressed (spaces) but must not be inflated whole.
|
||
fn bomb_plaintext() -> Vec<u8> {
|
||
vec![b' '; 128 * 1024 * 1024]
|
||
}
|
||
|
||
/// A crafted CTB is a few hundred KB of zlib that expands to hundreds of
|
||
/// MB; `read_to_end` has no output budget, so the `Vec` grows until the
|
||
/// allocator fails and the process **aborts** (an abort is not a
|
||
/// catchable panic). Both the zlib path and the legacy raw-deflate path
|
||
/// must stop at the cap instead.
|
||
#[test]
|
||
fn a_zlib_bomb_is_refused_at_the_inflate_limit() {
|
||
let mut encoder =
|
||
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||
encoder.write_all(&bomb_plaintext()).expect("compress");
|
||
let payload = encoder.finish().expect("finish");
|
||
assert_eq!(payload[0], 0x78, "zlib header for the inflate_lenient path");
|
||
|
||
let mut data = vec![b'\n'];
|
||
data.extend(payload);
|
||
let error = decompress_ctb(&data).expect_err("zlib bomb must be refused");
|
||
assert!(error.contains("limit"), "{error}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_legacy_deflate_bomb_is_refused_at_the_inflate_limit() {
|
||
let mut encoder =
|
||
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
|
||
encoder.write_all(&bomb_plaintext()).expect("compress");
|
||
let payload = encoder.finish().expect("finish");
|
||
assert_ne!(payload[0], 0x78, "raw deflate takes the legacy DeflateDecoder path");
|
||
|
||
let mut data = vec![b'\n'];
|
||
data.extend(payload);
|
||
let error = decompress_ctb(&data).expect_err("deflate bomb must be refused");
|
||
assert!(error.contains("limit"), "{error}");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod lineweight_table_tests {
|
||
use super::{parse_plot_style_text, LW_TABLE};
|
||
|
||
fn parse(text: &str) -> super::PlotStyleTable {
|
||
parse_plot_style_text(text, "crafted.ctb".into(), false).expect("parses")
|
||
}
|
||
|
||
/// The table key is file-controlled: `18446744073709551615` made
|
||
/// `index + 1` overflow — debug builds panic, release wraps to
|
||
/// `resize(0)` and then panics OOB at `lineweights[usize::MAX]` — and
|
||
/// `500000000` resized a 2 GB table (allocation abort). Entries past
|
||
/// 255 are unreachable anyway: `PlotStyleEntry.lineweight` is a `u8`.
|
||
#[test]
|
||
fn oversized_lineweight_table_keys_are_ignored() {
|
||
let table = parse("custom_lineweight_table{\n18446744073709551615=0.5\n}\n");
|
||
assert_eq!(table.lineweights, LW_TABLE.to_vec());
|
||
}
|
||
|
||
/// In-range keys still extend the table; 256 (beyond `u8`) is ignored.
|
||
#[test]
|
||
fn in_range_lineweight_table_keys_are_kept() {
|
||
let table = parse("custom_lineweight_table{\n30=0.7\n}\n");
|
||
assert_eq!(table.lineweights.len(), 31);
|
||
assert_eq!(table.lineweights[30], 0.7);
|
||
|
||
let table = parse("custom_lineweight_table{\n256=0.7\n}\n");
|
||
assert_eq!(table.lineweights, LW_TABLE.to_vec());
|
||
}
|
||
}
|