Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor a2bdea55ce address review: register the manifest path before validation and keep the Chinese manifest fallback optional 2026-11-04 01:44:41 +00:00
Dylan M. Taylor 50852ceff1 ui-egui: embed craft-fonts files only from inside the checkout
The build hook canonicalised each manifest row's file and embedded it
with include_bytes! wherever it led, so an absolute path, a .. path or
a symlink in a craft-fonts checkout could put any readable file of the
build machine into the binary. Manifests and font files must now
resolve inside the canonical checkout directory.
2026-11-03 15:48:03 +00:00
4 changed files with 22 additions and 54 deletions
+1 -15
View File
@@ -332,14 +332,6 @@ impl NumberFormat {
None => Formatted { text: t.to_string(), color: None, fill: None, numeric: false },
}
}
/// [`Self::format_text`]'s text, or `None` once it passes `max_units` UTF-16 code units.
fn format_text_within(&self, t: &str, max_units: usize) -> Option<String> {
match self.inner.text_section.and_then(|i| self.inner.sections.get(i)) {
Some(sec) => render::render_text_within(sec, t, max_units).map(|out| out.text),
None => (t.encode_utf16().count() <= max_units).then(|| t.to_string()),
}
}
}
/// Formats a cell value for display.
@@ -433,10 +425,6 @@ impl NumberFormat {
/// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is
/// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text
/// section, or are returned as is.
/// Longest text a cell holds, in UTF-16 code units (Excel's 32,767 characters, which XLSB also
/// stores that way), and so the longest `TEXT` result.
const MAX_CELL_TEXT_CHARS: usize = 32_767;
pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, CellError> {
if code.is_empty() {
return Ok(String::new());
@@ -452,9 +440,7 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, C
Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text),
Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) {
Some(n) => n,
// Every `@` repeats the text: a result past a cell's limit is `#VALUE!`, so nested
// TEXT calls can't multiply it further.
None => return fmt.format_text_within(&t, MAX_CELL_TEXT_CHARS).ok_or(CellError::Value),
None => return Ok(fmt.format_text(&t).text),
},
Value::Array(_) => return Err(CellError::Value),
};
-29
View File
@@ -45,35 +45,6 @@ pub(crate) fn render_text(sec: &Section, text: &str) -> Out {
out
}
/// [`render_text`], but `None` once the output passes `max_units` UTF-16 code units (a cell's
/// limit), checked before each piece is appended, so an oversized result is never built.
pub(crate) fn render_text_within(sec: &Section, text: &str, max_units: usize) -> Option<Out> {
let text_units = text.encode_utf16().count();
let mut out = Out::default();
let mut units = 0usize;
for t in &sec.toks {
match t {
Tok::At => {
units = units.saturating_add(text_units);
if units > max_units {
return None;
}
out.push(text);
}
Tok::Fill(c) => out.fill(*c),
other => {
let literal = other.literal_text();
units = units.saturating_add(literal.encode_utf16().count());
if units > max_units {
return None;
}
out.push(&literal);
}
}
}
Some(out)
}
/// Renders a number through a General section (literals around the General keyword).
pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out {
let mut out = Out::default();
-7
View File
@@ -396,13 +396,6 @@ fn text_fn() {
assert_eq!(text_function(&Value::Number(1234.5), "$#,##0.00", S), Ok("$1,234.50".into()));
assert_eq!(text_function(&Value::from("5"), "0.00", S), Ok("5.00".into()));
assert_eq!(text_function(&Value::from("abc"), "0.00", S), Ok("abc".into()));
// Each `@` repeats the text: up to a cell's 32,767 characters, then #VALUE!.
let at = "@".repeat(255);
assert_eq!(text_function(&Value::from("x".repeat(128).as_str()), &at, S).map(|s| s.len()), Ok(128 * 255));
assert_eq!(text_function(&Value::from("x".repeat(32_000).as_str()), &at, S), Err(CellError::Value));
// The limit counts UTF-16 units: 16,384 emoji are 32,768 of them, one too many.
assert_eq!(text_function(&Value::from("\u{1F600}".repeat(16_383).as_str()), "@", S).map(|s| s.chars().count()), Ok(16_383));
assert_eq!(text_function(&Value::from("\u{1F600}".repeat(16_384).as_str()), "@", S), Err(CellError::Value));
assert_eq!(text_function(&Value::Error(CellError::NA), "0", S), Err(CellError::NA));
assert_eq!(text_function(&Value::Number(46302.0), "yyyy", S), Ok("2026".into()));
assert_eq!(text_function(&Value::Number(0.5), "General", S), Ok("0.5".into()));
+21 -3
View File
@@ -76,7 +76,21 @@ fn emit_craft_fonts() {
/// One `CraftFont { .. }` initialiser per manifest row GridCraft may embed.
fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> Result<String, String> {
let manifest = dir.join("fonts/manifest.txt");
// Every file embedded comes from inside the checkout: a manifest row (or a symlink) naming a
// path outside it would put that file of the build machine into the binary.
let root = dir.canonicalize().map_err(|e| format!("{}: {e}", dir.display()))?;
let inside = |path: std::path::PathBuf| -> Result<std::path::PathBuf, String> {
let real = path.canonicalize().map_err(|e| format!("{}: {e}", path.display()))?;
if real.starts_with(&root) { Ok(real) } else { Err(format!("{} is outside the craft-fonts checkout {}", path.display(), root.display())) }
};
// Registered before validation, by the path as given (and its folder, to see the manifest appear
// or a symlink replaced): a build that skipped the fonts must rebuild once they are there.
let manifest_path = dir.join("fonts/manifest.txt");
println!("cargo::rerun-if-changed={}", manifest_path.display());
if let Some(parent) = manifest_path.parent() {
println!("cargo::rerun-if-changed={}", parent.display());
}
let manifest = inside(manifest_path)?;
println!("cargo::rerun-if-changed={}", manifest.display());
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
let rows = craft_fonts_policy::parse_manifest(&text)?;
@@ -84,7 +98,7 @@ fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> Result<Stri
if required {
craft_fonts_policy::check_required(&rows)?;
}
let chinese = if wasm { Vec::new() } else { chinese_font_rows(dir, required)? };
let chinese = if wasm { Vec::new() } else { chinese_font_rows(&root, required)? };
for row in rows.iter().chain(&chinese) {
if craft_fonts_policy::barred(&row.family) {
println!("cargo::warning=craft-fonts: not embedding {} {} (repository asset policy)", row.family, row.style);
@@ -92,7 +106,7 @@ fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> Result<Stri
}
let mut out = String::new();
for row in craft_fonts_policy::embeddable(&rows, wasm).into_iter().chain(craft_fonts_policy::embeddable(&chinese, wasm)) {
let path = dir.join(&row.file).canonicalize().map_err(|e| format!("{}: {e}", row.file))?;
let path = inside(root.join(&row.file)).map_err(|e| format!("{}: {e}", row.file))?;
println!("cargo::rerun-if-changed={}", path.display());
let scripts: Vec<String> = row.scripts.iter().map(|s| format!("{s:?}")).collect();
let family = &row.family;
@@ -120,6 +134,10 @@ fn chinese_font_rows(dir: &std::path::Path, required: bool) -> Result<Vec<craft_
Ok(Vec::new())
}
};
// `dir` is the canonical checkout: the manifest itself must not lead out of it either.
if manifest.canonicalize().is_ok_and(|real| !real.starts_with(dir)) {
return optional(format!("{} is outside the craft-fonts checkout", manifest.display()));
}
match std::fs::read_to_string(&manifest) {
Ok(text) => match craft_fonts_policy::parse_manifest(&text).and_then(|rows| {
craft_fonts_policy::check_declared_scripts(&rows)?;