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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ The release run heads these entries with the version and opens a fresh

## Unreleased

- WebAssembly rejects fractional, nonfinite and out-of-range element IDs,
sheet coordinates, rendering limits and view indices before conversion.

- Apple CSV decoding rejects multi-character and multibyte delimiters.
Roots without a text-root interface are exposed as `Element`, not `TextRoot`.

Expand Down
19 changes: 19 additions & 0 deletions wasm/src/odr_wasm.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@

#include <emscripten/val.h>

#include <algorithm>
#include <cmath>
#include <concepts>
#include <cstdint>
#include <limits>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>

Expand All @@ -19,6 +24,20 @@ namespace odr::wasm {

using Handle = std::uint32_t;

/// Rejects fractional, out-of-range and imprecise JavaScript integers.
template <std::integral T> T checked_integer(const double value) {
constexpr double exact = 9007199254740991.0; // 2^53 - 1
const double minimum =
std::max(static_cast<double>(std::numeric_limits<T>::lowest()), -exact);
const double maximum =
std::min(static_cast<double>(std::numeric_limits<T>::max()), exact);
if (!std::isfinite(value) || std::trunc(value) != value || value < minimum ||
value > maximum) {
throw std::invalid_argument("number is not a representable integer");
}
return static_cast<T>(value);
}

/// One open document. Owns everything reachable from it, because the pieces do
/// not own each other: `HtmlView` holds a bare pointer into its service, so the
/// service has to outlive the views.
Expand Down
55 changes: 30 additions & 25 deletions wasm/src/wasm_document.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ emscripten::val recalculate(const Handle handle) {
/// the page as `data-odr-id`, and a plain number needs no handle.
Element element_of(Session &session, const double identifier) {
const Element element = document_of(session).element_by_id(
static_cast<ElementIdentifier>(identifier));
checked_integer<ElementIdentifier>(identifier));
if (!element) {
throw std::invalid_argument("element not found");
}
Expand Down Expand Up @@ -168,48 +168,53 @@ emscripten::val set_paragraph_style(const Handle handle, const double id,
emscripten::val edit_style(const Handle handle, const std::string &op,
const std::string &place,
const emscripten::val style) {
return guarded([&] {
Session &s = session(handle);
if (style.isUndefined() || style.isNull() ||
style.typeOf().as<std::string>() != "object") {
throw std::invalid_argument(op + " takes a style object");
}
const std::string json =
emscripten::val::global("JSON").call<std::string>("stringify", style);
document_of(s).edit(R"({"version":2,"ops":[{"op":")" + op + R"(",)" +
place + R"(,"style":)" + json + "}]}");
return ok();
});
Session &s = session(handle);
if (style.isUndefined() || style.isNull() ||
style.typeOf().as<std::string>() != "object") {
throw std::invalid_argument(op + " takes a style object");
}
const std::string json =
emscripten::val::global("JSON").call<std::string>("stringify", style);
document_of(s).edit(R"({"version":2,"ops":[{"op":")" + op + R"(",)" + place +
R"(,"style":)" + json + "}]}");
return ok();
}

std::string index_field(const std::string &name, const double index) {
return '"' + name + R"(":)" +
std::to_string(static_cast<std::uint32_t>(index));
std::to_string(checked_integer<std::uint32_t>(index));
}

emscripten::val set_cell_style(const Handle handle, const double sheet,
const double column, const double row,
const emscripten::val style) {
return edit_style(handle, "setCellStyle",
index_field("sheet", sheet) + "," +
index_field("column", column) + "," +
index_field("row", row),
style);
return guarded([&] {
return edit_style(handle, "setCellStyle",
index_field("sheet", sheet) + "," +
index_field("column", column) + "," +
index_field("row", row),
style);
});
}

emscripten::val set_row_style(const Handle handle, const double sheet,
const double row, const emscripten::val style) {
return edit_style(handle, "setRowStyle",
index_field("sheet", sheet) + "," + index_field("row", row),
style);
return guarded([&] {
return edit_style(
handle, "setRowStyle",
index_field("sheet", sheet) + "," + index_field("row", row), style);
});
}

emscripten::val set_column_style(const Handle handle, const double sheet,
const double column,
const emscripten::val style) {
return edit_style(
handle, "setColumnStyle",
index_field("sheet", sheet) + "," + index_field("column", column), style);
return guarded([&] {
return edit_style(handle, "setColumnStyle",
index_field("sheet", sheet) + "," +
index_field("column", column),
style);
});
}

/// A row or column op, @p axis naming its index, replayed through the
Expand Down
31 changes: 19 additions & 12 deletions wasm/src/wasm_html.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ void read(const emscripten::val &value, const char *key, T &target) {
if (field.isUndefined() || field.isNull()) {
return;
}
target = field.as<T>();
if constexpr (std::integral<T> && !std::same_as<T, bool>) {
target = checked_integer<T>(field.as<double>());
} else {
target = field.as<T>();
}
}

/// @ref read for an enum, which JS carries as its ordinal.
Expand All @@ -33,7 +37,7 @@ void read_enum(const emscripten::val &value, const char *key, T &target) {
if (field.isUndefined() || field.isNull()) {
return;
}
target = static_cast<T>(field.as<int>());
target = static_cast<T>(checked_integer<std::int32_t>(field.as<double>()));
}

/// A css length a caller states as a string, e.g. `"3mm"`.
Expand Down Expand Up @@ -87,8 +91,9 @@ emscripten::val list_views(const Handle handle) {

/// The rendered view as one HTML string, self-contained under the default
/// `embedImages` — which is what lets a viewer drop it into a `blob:` iframe.
emscripten::val render_view(const Handle handle, const std::size_t index) {
emscripten::val render_view(const Handle handle, const double requested_index) {
return guarded([&] {
const std::size_t index = checked_integer<std::size_t>(requested_index);
const Session &s = warm(handle);
if (index >= s.views.size()) {
return error(ErrorCode::unknown,
Expand Down Expand Up @@ -178,7 +183,7 @@ HtmlConfig to_html_config(const emscripten::val &value) {
read(value, "pageRangeBegin", config.page_range_begin);
if (const emscripten::val end = value["pageRangeEnd"];
!end.isUndefined() && !end.isNull()) {
config.page_range_end = end.as<std::uint32_t>();
config.page_range_end = checked_integer<std::uint32_t>(end.as<double>());
}

read_enum(value, "colorScheme", config.color_scheme);
Expand All @@ -187,29 +192,31 @@ HtmlConfig to_html_config(const emscripten::val &value) {
// absent leaves the default in place.
if (const emscripten::val limit = value["spreadsheetLimit"];
!limit.isUndefined()) {
config.spreadsheet_limit = limit.isNull()
? std::optional<TableDimensions>()
: std::optional(TableDimensions(
limit["rows"].as<std::uint32_t>(),
limit["columns"].as<std::uint32_t>()));
config.spreadsheet_limit =
limit.isNull()
? std::optional<TableDimensions>()
: std::optional(TableDimensions(
checked_integer<std::uint32_t>(limit["rows"].as<double>()),
checked_integer<std::uint32_t>(
limit["columns"].as<double>())));
}
if (const emscripten::val buffer = value["spreadsheetStyleBuffer"];
!buffer.isUndefined() && !buffer.isNull()) {
config.spreadsheet_style_buffer =
static_cast<std::uint64_t>(buffer.as<double>());
checked_integer<std::uint64_t>(buffer.as<double>());
}
if (const emscripten::val limit = value["spreadsheetCellLimit"];
!limit.isUndefined()) {
// as a `number`, not a BigInt - a cell budget is nowhere near 2^53
config.spreadsheet_cell_limit =
limit.isNull()
? std::optional<std::uint64_t>()
: std::optional(static_cast<std::uint64_t>(limit.as<double>()));
: std::optional(checked_integer<std::uint64_t>(limit.as<double>()));
}
read_enum(value, "viewportMode", config.viewport_mode);
if (const emscripten::val width = value["viewportWidth"];
!width.isUndefined() && !width.isNull()) {
config.viewport_width = width.as<std::uint32_t>();
config.viewport_width = checked_integer<std::uint32_t>(width.as<double>());
}
if (const emscripten::val zoom = value["initialZoom"];
!zoom.isUndefined() && !zoom.isNull()) {
Expand Down
30 changes: 27 additions & 3 deletions wasm/tests/edit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -315,10 +315,34 @@ describe('edit', () => {
}
});

it('refuses an id the document does not hold', () => {
const doc = odr.open(minimalOdt('hello'));
it('refuses unknown or nonintegral element ids', () => {
const doc = odr.open(minimalOdt('hello'), { editable: true });
try {
assert.throws(() => doc.removeElement(999999), OdrError);
const id = firstEditableRunId(doc.render().html);
for (const invalid of [999999, id + 0.5, -1, NaN, Infinity, 2 ** 64]) {
assert.throws(() => doc.removeElement(invalid), OdrError);
}
assert.match(doc.render().html, /hello/);
} finally {
doc.close();
}
});

it('rejects invalid sheet coordinates before an edit', () => {
const doc = odr.open(minimalOds());
try {
for (const invalid of [-1, 0.5, NaN, Infinity, 2 ** 32]) {
for (const call of [
() => doc.setCellStyle(0, invalid, 0, { bold: true }),
() => doc.setRowStyle(0, invalid, { bold: true }),
() => doc.setColumnStyle(invalid, 0, { bold: true }),
() => doc.insertRows(0, 0, invalid),
() => doc.deleteColumns(0, invalid, 1),
]) {
assert.throws(call, OdrError);
}
}
assert.doesNotMatch(doc.render().html, /font-weight:bold/);
} finally {
doc.close();
}
Expand Down
24 changes: 24 additions & 0 deletions wasm/tests/render.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,30 @@ describe('render', () => {
});
after(() => odr.closeAll());

it('rejects invalid numeric config and view indices', () => {
const bytes = minimalOdt();
for (const invalid of [-1, 0.5, NaN, Infinity, 2 ** 64]) {
for (const config of [
{ pageRangeBegin: invalid }, { pageRangeEnd: invalid },
{ viewportWidth: invalid }, { spreadsheetStyleBuffer: invalid },
{ spreadsheetCellLimit: invalid },
{ spreadsheetLimit: { rows: invalid, columns: 1 } },
{ spreadsheetLimit: { rows: 1, columns: invalid } },
]) {
assert.throws(() => odr.open(bytes, config), OdrError);
}
}
const doc = odr.open(bytes);
try {
for (const invalid of [-1, 0.5, NaN, Infinity, 2 ** 32]) {
assert.throws(() => doc.render(invalid), OdrError);
}
assert.match(doc.render(0).html, /hello/);
} finally {
doc.close();
}
});

it('renders a view to self-contained html', () => {
const doc = odr.open(fixture('mixed-layout.odt'));
try {
Expand Down
Loading