Check the canvas size before narrowing it to an int

A canvas side was cast to int with no range check, so a number that did
not fit came back as one that did: `-s 4294968296x600` rendered a
perfectly plausible 1000x600, and a hand-edited project file could ask
for a width the reader turned into something else (or, for a double out
of range, into undefined behaviour).

Both front-ends now check the side before the cast, against one shared
bound in chain.hpp — the 1..20000 the GUI spin buttons already offered,
so the help text, the CLI error, the project reader and the interface
all move together.

The tests gain a `canvas size` section covering the round trip at both
ends of the range and the refusals on either side of it, through the
project reader and through `-s`; the test binary picks up the CLI parser
to do it, which stays GUI-free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-18 16:49:44 +02:00
co-authored by Claude Opus 5
parent 82e730221e
commit 67417727e7
6 changed files with 129 additions and 9 deletions
+3 -1
View File
@@ -1,4 +1,6 @@
add_executable(effector_tests core_tests.cpp)
# The CLI parser joins the core here: it is GUI-free too, and the canvas size
# it produces has to obey the same bounds a project file does.
add_executable(effector_tests core_tests.cpp ../src/cli/options.cpp)
target_link_libraries(effector_tests PRIVATE effector_core)
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
+91
View File
@@ -7,9 +7,12 @@
#include <fstream>
#include <iostream>
#include <mutex>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "cli/options.hpp"
#include "core/chain.hpp"
#include "core/project.hpp"
#include "core/registry.hpp"
@@ -44,6 +47,28 @@ effector::Image gradient(int width, int height, int shade) {
return image;
}
// A canvas taken from a project file, written by hand rather than by
// projectToJson, so a size no writer would produce can be fed to the reader.
std::string projectWithCanvas(const std::string& width, const std::string& height) {
return "{\"canvas\": {\"width\": " + width + ", \"height\": " + height +
"},\n \"chain\": [{\"effect\": \"blur\"}]}";
}
// The canvas a `-s` value ends up as, or nothing when the parser refuses it.
std::optional<std::pair<int, int>> canvasFromSize(const std::string& size) {
char program[] = "effector";
char flag[] = "-s";
std::vector<char> value(size.begin(), size.end());
value.push_back('\0');
char* argv[] = {program, flag, value.data()};
try {
const auto options = effector::parseOptions(3, argv);
return std::pair<int, int>{options.width, options.height};
} catch (const effector::EffectError&) {
return std::nullopt;
}
}
bool sameBytes(const effector::Image& a, const effector::Image& b) {
if (a.width() != b.width() || a.height() != b.height()) return false;
return std::equal(a.data(), a.data() + a.byteSize(), b.data());
@@ -430,6 +455,72 @@ int main() {
std::filesystem::remove_all(dir);
}
std::cout << "canvas size\n";
{
// The sizes the front-ends actually offer make the round trip whole.
int kept = 0;
for (const auto [w, h] : {std::pair<int, int>{1, 1},
{640, 400},
{1920, 1080},
{kMaxCanvasSide, kMaxCanvasSide}}) {
const auto back = projectFromJson(
projectWithCanvas(std::to_string(w), std::to_string(h)));
if (back.width == w && back.height == h) ++kept;
}
check(kept == 4, "every canvas in range is read back exactly");
Project project;
project.stages.push_back({effectByName("blur"), {}});
project.width = kMaxCanvasSide;
project.height = 3;
const auto written = projectFromJson(projectToJson(project));
check(written.width == kMaxCanvasSide && written.height == 3,
"the largest canvas survives a write and a read");
// A side that does not fit in an int must not be narrowed into one
// that does: 4294968296 truncates to a perfectly plausible 1000.
int refusals = 0;
for (const char* bad : {"4294968296", "2147483648", "99999999999999999999", "-5", "0",
"20001", "800.5", "\"800\"", "true"}) {
try {
projectFromJson(projectWithCanvas(bad, "600"));
} catch (const EffectError&) {
++refusals;
}
}
check(refusals == 9, "a canvas width an int cannot hold is refused, not wrapped");
refusals = 0;
for (const char* bad : {"4294968296", "-5", "0", "20001", "800.5"}) {
try {
projectFromJson(projectWithCanvas("800", bad));
} catch (const EffectError&) {
++refusals;
}
}
check(refusals == 5, "the same bounds apply to the canvas height");
// A canvas the file does not mention keeps the defaults rather than
// failing: an old project is still readable.
const auto bare = projectFromJson("{\"chain\": [{\"effect\": \"blur\"}]}");
check(bare.width == 1920 && bare.height == 1080, "a project with no canvas keeps 1920x1080");
// The CLI narrows the same way, from a long rather than a double.
const auto plain = canvasFromSize("800x600");
check(plain && plain->first == 800 && plain->second == 600, "-s 800x600 is stored as given");
const auto big = canvasFromSize(std::to_string(kMaxCanvasSide) + "x" +
std::to_string(kMaxCanvasSide));
check(big && big->first == kMaxCanvasSide, "-s takes the largest canvas");
check(!canvasFromSize("4294968296x600"), "-s refuses a width that does not fit in an int");
check(!canvasFromSize("2147483648x600"), "-s refuses a width just past INT_MAX");
check(!canvasFromSize("99999999999999999999x600"), "-s refuses a width past a long");
check(!canvasFromSize("800x4294968296"), "-s refuses a height that does not fit in an int");
check(!canvasFromSize("20001x600") && !canvasFromSize("800x20001"),
"-s refuses a side past the canvas limit");
check(!canvasFromSize("0x600") && !canvasFromSize("-5x600"),
"-s refuses a side that is not positive");
}
std::cout << "image io\n";
{
const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";