diff --git a/docs/architecture/calibration_workflow.md b/docs/architecture/calibration_workflow.md new file mode 100644 index 0000000..f94809d --- /dev/null +++ b/docs/architecture/calibration_workflow.md @@ -0,0 +1,122 @@ +# Calibration Workflow + +## Status + +```text +CALIBRATION_WORKFLOW=IN_PROGRESS +CALIBRATION_WORKFLOW_INPUT_BOUNDARY_V1=PASS/FROZEN +CURRENT_WORKFLOW_NEXT=EVIDENCE_MATERIALIZATION_V1 +``` + +## Authority + +Calibration Science v1, Calibration Tooling v1 and Calibration Bootstrap v1 +remain FROZEN scientific and import authorities. + +This workflow is bounded orchestration only. It does not introduce a solver, +Project DB schema version, Task kind, Sparse SfM execution or reconstructed +scientific evidence. + +## Frozen flow + +```text +physical calibration acquisition +-> session.l3dcal +-> external Calibration Evidence Solver v1 +-> immutable solver bundle +-> campaign-state evidence +-> Calibration Workflow +-> Calibration Tooling v1 +-> L3DCALB1 v1 +-> Calibration Bootstrap v1 +-> selected execution READY +``` + +## Input Boundary v1 + +`CALIBRATION_WORKFLOW_INPUT_BOUNDARY_V1=PASS/FROZEN`. + +The implementation is exposed through: + +```text +include/lardon3d/calibration_workflow.h +src/calibration_workflow.cpp +``` + +Input Boundary v1 performs no Project DB mutation. + +It accepts only bounded regular files and rejects special files and symlinks +before potentially blocking reads. File access follows the nonblocking, +close-on-exec regular-file discipline. + +The bounded input set is: + +```text +session.l3dcal +session.l3dcal.bundle/detection.json +session.l3dcal.bundle/solve.json +session.l3dcal.bundle/evidence.json +session.l3dcal.bundle/producer.json +L3DCAL_CAMPAIGN_STATE_V1 +``` + +It verifies: + +- regular bounded files; +- SHA-256 identities; +- strict session syntax; +- structurally valid canonical JSON bundle members; +- exact session SHA binding through `producer.json`; +- decoder/version consistency; +- exact optical-state SHA equality; +- exact optical-state token equality; +- campaign-state identity consistency. + +Malformed JSON, oversize files, symlinks, FIFOs, session digest mismatch and +optical-state mismatch are rejected. + +Input Boundary v1 does not: + +- open or mutate Project DB; +- construct `Lardon3DCalibrationToolingEvidence`; +- call Calibration Tooling; +- produce `L3DCALB1`; +- invoke Calibration Bootstrap; +- change selected-execution state. + +## Campaign optical-state evidence + +Project DB v23 retains exact explicit optical configuration identity, including +body, objective and focal state, but Calibration Science v1 requires a broader +scientific key including focus, stabilization and processing/decode state. + +No equality may be inferred between those domains. + +`L3DCAL_CAMPAIGN_STATE_V1` therefore provides immutable external evidence for +the complete Science v1 optical state. A later coordinator stage must verify +this evidence against both the calibration session and each selected Capture's +explicit Project DB optical configuration. + +Absence or disagreement remains `CALIBRATION_UNAVAILABLE`. + +## Current next boundary + +```text +CALIBRATION_WORKFLOW_EVIDENCE_MATERIALIZATION_V1 +``` + +The next stage parses the already validated bundle into bounded in-memory +Science v1 evidence: + +- exact per-view evidence; +- exact repeated full-solve parameters; +- fit parameters; +- global and hold-out validation metrics; +- coordinate-equivalence evidence; +- immutable provenance digests. + +It still performs no Project DB mutation. + +Only after that boundary passes may the workflow bind the exact selected +execution, campaign representations and optical assignments and invoke the +FROZEN Tooling/Bootstrap path. diff --git a/docs/roadmap/roadmap.md b/docs/roadmap/roadmap.md index edce667..8c0de05 100644 --- a/docs/roadmap/roadmap.md +++ b/docs/roadmap/roadmap.md @@ -527,6 +527,8 @@ The solver session contract now also requires explicit measured `white_border >= `CALIBRATION_SOLVER_BUNDLE_REPAIR_V1=PASS/FROZEN`: retained per-view fields are now present in the actual solver output, `producer.json` is valid canonical JSON, solver-configuration records contain real line feeds, and the deterministic self-test checks these contents rather than byte identity alone. +`CALIBRATION_WORKFLOW_INPUT_BOUNDARY_V1=PASS/FROZEN`: the workflow now has a bounded non-mutating input boundary for session, solver bundle and campaign optical-state evidence. It rejects special/symlink/oversize files, invalid JSON, provenance digest mismatches and incompatible optical state. The next boundary is Evidence Materialization v1. + A bounded Tooling correction aligned planarity handling with Calibration Science v1: Science v1 defines a categorical physical planarity attestation, not a numeric flatness threshold. Tooling therefore rejects invented finite `target_flatness_mm` values. `L3DCALB1` v1 and Calibration diff --git a/include/lardon3d/calibration_workflow.h b/include/lardon3d/calibration_workflow.h new file mode 100644 index 0000000..ed876fd --- /dev/null +++ b/include/lardon3d/calibration_workflow.h @@ -0,0 +1,79 @@ +#ifndef LARDON3D_CALIBRATION_WORKFLOW_H +#define LARDON3D_CALIBRATION_WORKFLOW_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +enum { + LARDON3D_CALIBRATION_WORKFLOW_VERSION = 1, + LARDON3D_CALIBRATION_WORKFLOW_MAX_FILE_BYTES = 128 * 1024 * 1024, + LARDON3D_CALIBRATION_WORKFLOW_MAX_SELECTED_ITEMS = 4096, + LARDON3D_CALIBRATION_WORKFLOW_OPTICAL_STATE_TOKEN_CAPACITY = 1025, +}; + +typedef enum { + LARDON3D_CALIBRATION_WORKFLOW_OK = 0, + LARDON3D_CALIBRATION_WORKFLOW_INVALID_ARGUMENT, + LARDON3D_CALIBRATION_WORKFLOW_IO_ERROR, + LARDON3D_CALIBRATION_WORKFLOW_NON_REGULAR_FILE, + LARDON3D_CALIBRATION_WORKFLOW_CAPACITY, + LARDON3D_CALIBRATION_WORKFLOW_MALFORMED_EVIDENCE, + LARDON3D_CALIBRATION_WORKFLOW_PROVENANCE_MISMATCH, +} Lardon3DCalibrationWorkflowResult; + +/* `campaign_state_path` is a canonical external acquisition manifest: + * + * L3DCAL_CAMPAIGN_STATE_V1 + * execution + * optical_configuration + * optical_state + * capture + * + * Capture rows are contiguous and ordered. The later DB-binding stage proves + * that these IDs are exactly the selected execution and that each Capture owns + * the declared explicit optical configuration; this input boundary performs no + * DB access and therefore never treats the manifest alone as that proof. */ +typedef struct { + const char *session_path; + const char *detection_path; + const char *solve_path; + const char *evidence_path; + const char *producer_path; + const char *campaign_state_path; +} Lardon3DCalibrationWorkflowInputFiles; + +typedef struct { + unsigned char session_sha256[32]; + unsigned char detection_sha256[32]; + unsigned char solve_sha256[32]; + unsigned char evidence_sha256[32]; + unsigned char producer_sha256[32]; + unsigned char campaign_state_sha256[32]; + unsigned char optical_state_sha256[32]; + unsigned char solver_executable_sha256[32]; + unsigned char solver_configuration_sha256[32]; + uint64_t selected_execution_id; + uint64_t optical_configuration_id; + uint32_t capture_count; + uint64_t capture_ids[LARDON3D_CALIBRATION_WORKFLOW_MAX_SELECTED_ITEMS]; + char optical_state_token[LARDON3D_CALIBRATION_WORKFLOW_OPTICAL_STATE_TOKEN_CAPACITY]; +} Lardon3DCalibrationWorkflowInputBoundary; + +/* Validate only the immutable external input boundary. This function performs + * no Project DB access, no calibration solve, no Tooling import and no + * selected-execution mutation. Every path is opened O_NONBLOCK/O_NOFOLLOW, + * must resolve to a bounded regular file, and is SHA-256 checked before its + * syntax/provenance is consumed. */ +Lardon3DCalibrationWorkflowResult lardon3d_calibration_workflow_validate_input_boundary( + const Lardon3DCalibrationWorkflowInputFiles *files, + Lardon3DCalibrationWorkflowInputBoundary *boundary); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/meson.build b/meson.build index 0404a6b..fe5899e 100644 --- a/meson.build +++ b/meson.build @@ -180,6 +180,7 @@ lardon3d_app = executable( 'src/optical_profiles.c', 'src/calibration_bootstrap.c', 'src/calibration_tooling.c', + 'src/calibration_workflow.cpp', 'src/sparse_sfm_geometry.cpp', 'src/sparse_sfm_incremental.cpp', 'src/sparse_sfm_bundle_adjustment.cpp', @@ -895,6 +896,18 @@ calibration_tooling_test = executable( test('calibration-tooling', calibration_tooling_test, timeout: 30) +calibration_workflow_test = executable( + 'test-calibration-workflow', + sources: [ + 'tests/test_calibration_workflow.cpp', + 'src/calibration_workflow.cpp', + ], + include_directories: include_directories('include'), + dependencies: [openssl], +) + +test('calibration-workflow', calibration_workflow_test, timeout: 30) + optical_profiles_test = executable( 'test-optical-profiles', sources: [ diff --git a/prompt/02_CURRENT_FROZEN_STATE.md b/prompt/02_CURRENT_FROZEN_STATE.md index 04e33b7..97a3e1a 100644 --- a/prompt/02_CURRENT_FROZEN_STATE.md +++ b/prompt/02_CURRENT_FROZEN_STATE.md @@ -6,7 +6,7 @@ CURRENT_PROJECT_DB_SCHEMA=v25 PRODUCTION_TASK_KINDS=16 USER_FACING_UI_LANGUAGE_NORMALIZATION=PASS -CURRENT_IMPLEMENTATION_CURSOR=1_CALIBRATION_WORKFLOW_COORDINATOR +CURRENT_IMPLEMENTATION_CURSOR=1_CALIBRATION_WORKFLOW_EVIDENCE_MATERIALIZATION ``` ## Authority @@ -31,6 +31,7 @@ Calibration solver white-border evidence PASS/FROZEN Calibration solver producer identity PASS/FROZEN Calibration solver per-view evidence PASS/FROZEN Calibration solver bundle repair PASS/FROZEN +Calibration workflow input boundary PASS/FROZEN Calibration Tooling planarity alignment PASS/FROZEN ``` diff --git a/prompt/12_CALIBRATION.md b/prompt/12_CALIBRATION.md index 039abe3..d76c9a7 100644 --- a/prompt/12_CALIBRATION.md +++ b/prompt/12_CALIBRATION.md @@ -13,14 +13,17 @@ CALIBRATION_SOLVER_PRODUCER_IDENTITY_V1=PASS/FROZEN CALIBRATION_SOLVER_PER_VIEW_EVIDENCE_V1=PASS/FROZEN CALIBRATION_SOLVER_BUNDLE_REPAIR_V1=PASS/FROZEN CALIBRATION_WORKFLOW=IN_PROGRESS -CURRENT_CALIBRATION_NEXT=WORKFLOW_COORDINATOR +CALIBRATION_WORKFLOW_INPUT_BOUNDARY_V1=PASS/FROZEN +CURRENT_CALIBRATION_NEXT=WORKFLOW_EVIDENCE_MATERIALIZATION_V1 ``` ## Authority `docs/architecture/calibration_science_v1.md`, `docs/architecture/calibration_bootstrap.md` and `docs/architecture/calibration_solver_preflight_v1.md` are the current specialized calibration documents. -`docs/architecture/calibration_tooling.md` is the specialized Tooling authority. The public API remains `include/lardon3d/calibration_tooling.h`. +`docs/architecture/calibration_tooling.md` is the specialized Tooling authority. + +`docs/architecture/calibration_workflow.md` is the specialized workflow authority. The public API remains `include/lardon3d/calibration_tooling.h`. A bounded corrective review established that Calibration Science v1 defines target planarity as a categorical physical attestation, not a numeric flatness tolerance. Tooling preserves its public structure layout while requiring `target_flatness_mm` to be NaN, so callers cannot invent a millimetre measurement. The canonical session's `planarity PASS ` evidence is bound through immutable initialization evidence. @@ -54,7 +57,7 @@ dedicated physical calibration acquisition -> real Sparse SfM ``` -The current implementation gap is the workflow coordinator. It consumes the immutable `session.l3dcal` plus `detection.json`, `solve.json` and `evidence.json`, binds them to the exact selected execution and optical state, constructs the bounded Tooling evidence and never manufactures missing physical evidence. +The workflow coordinator is now implemented through its first bounded checkpoint. Input Boundary v1 validates immutable files, hashes, formats and complete optical-state equality without Project DB mutation. The current implementation gap is Evidence Materialization v1. It consumes the immutable `session.l3dcal` plus `detection.json`, `solve.json` and `evidence.json`, binds them to the exact selected execution and optical state, constructs the bounded Tooling evidence and never manufactures missing physical evidence. ## REQUIRED_PRODUCT_TARGET diff --git a/prompt/31_IMPLEMENTATION_ORDER.md b/prompt/31_IMPLEMENTATION_ORDER.md index c597e66..252060f 100644 --- a/prompt/31_IMPLEMENTATION_ORDER.md +++ b/prompt/31_IMPLEMENTATION_ORDER.md @@ -6,7 +6,7 @@ IMPLEMENTATION_ORDER=DEPENDENCY_DRIVEN IMPLEMENTATION_AUTHORIZATION=NO STEP_0_USER_FACING_LANGUAGE_NORMALIZATION=PASS -CURRENT_NEXT=1_CALIBRATION_WORKFLOW_COORDINATOR +CURRENT_NEXT=1_CALIBRATION_WORKFLOW_EVIDENCE_MATERIALIZATION ``` ## Authority @@ -20,7 +20,7 @@ Implementation remains unauthorized until the human explicitly authorizes a tran Default dependency order: 0. user-facing repository/UI language normalization where appropriate — PASS; -1. final usable calibration workflow — IN PROGRESS; current next sub-boundary: workflow coordinator; +1. final usable calibration workflow — IN PROGRESS; Input Boundary v1 PASS/FROZEN; current next sub-boundary: Evidence Materialization v1; 2. dedicated physical calibrated real campaign; 3. real Sparse SfM proof; 4. durable Dense/OpenMVS orchestration; diff --git a/src/calibration_workflow.cpp b/src/calibration_workflow.cpp new file mode 100644 index 0000000..d7f16de --- /dev/null +++ b/src/calibration_workflow.cpp @@ -0,0 +1,483 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr size_t kSha256Bytes = 32; +constexpr size_t kJsonDepthMax = 64; +constexpr size_t kJsonKeyMax = 128; + +struct MappedFile { + int fd = -1; + const unsigned char *data = nullptr; + size_t size = 0; + unsigned char sha256[kSha256Bytes]{}; + + ~MappedFile() { + if (data && size) munmap(const_cast(data), size); + if (fd >= 0) close(fd); + } + MappedFile() = default; + MappedFile(const MappedFile &) = delete; + MappedFile &operator=(const MappedFile &) = delete; +}; + +bool digest_bytes(const unsigned char *data, size_t size, unsigned char output[32]) { + unsigned int length = 0; + return EVP_Digest(data, size, output, &length, EVP_sha256(), nullptr) == 1 && length == 32; +} + +Lardon3DCalibrationWorkflowResult map_regular_file(const char *path, MappedFile *out) { + if (!path || !*path || !out) return LARDON3D_CALIBRATION_WORKFLOW_INVALID_ARGUMENT; + int fd = open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) { + if (errno == ELOOP) return LARDON3D_CALIBRATION_WORKFLOW_NON_REGULAR_FILE; + return LARDON3D_CALIBRATION_WORKFLOW_IO_ERROR; + } + struct stat st{}; + if (fstat(fd, &st) != 0) { + close(fd); + return LARDON3D_CALIBRATION_WORKFLOW_IO_ERROR; + } + if (!S_ISREG(st.st_mode)) { + close(fd); + return LARDON3D_CALIBRATION_WORKFLOW_NON_REGULAR_FILE; + } + if (st.st_size <= 0) { + close(fd); + return LARDON3D_CALIBRATION_WORKFLOW_MALFORMED_EVIDENCE; + } + if (static_cast(st.st_size) > LARDON3D_CALIBRATION_WORKFLOW_MAX_FILE_BYTES) { + close(fd); + return LARDON3D_CALIBRATION_WORKFLOW_CAPACITY; + } + const size_t size = static_cast(st.st_size); + void *mapped = mmap(nullptr, size, PROT_READ, MAP_PRIVATE, fd, 0); + if (mapped == MAP_FAILED) { + close(fd); + return LARDON3D_CALIBRATION_WORKFLOW_IO_ERROR; + } + out->fd = fd; + out->data = static_cast(mapped); + out->size = size; + if (!digest_bytes(out->data, out->size, out->sha256)) + return LARDON3D_CALIBRATION_WORKFLOW_IO_ERROR; + return LARDON3D_CALIBRATION_WORKFLOW_OK; +} + +bool hex_digit(char c, unsigned *value) { + if (c >= '0' && c <= '9') *value = static_cast(c - '0'); + else if (c >= 'a' && c <= 'f') *value = static_cast(c - 'a' + 10); + else if (c >= 'A' && c <= 'F') *value = static_cast(c - 'A' + 10); + else return false; + return true; +} + +bool parse_sha256(std::string_view text, unsigned char output[32]) { + if (text.size() != 64) return false; + for (size_t i = 0; i < 32; ++i) { + unsigned hi = 0, lo = 0; + if (!hex_digit(text[2 * i], &hi) || !hex_digit(text[2 * i + 1], &lo)) return false; + output[i] = static_cast((hi << 4u) | lo); + } + return true; +} + +bool nonzero_sha(const unsigned char value[32]) { + unsigned char any = 0; + for (size_t i = 0; i < 32; ++i) any |= value[i]; + return any != 0; +} + +bool token_ok(std::string_view value) { + if (value.empty() || value.size() >= LARDON3D_CALIBRATION_WORKFLOW_OPTICAL_STATE_TOKEN_CAPACITY) + return false; + for (unsigned char c : value) { + if (!(std::isalnum(c) || c == '_' || c == '-' || c == '.' || c == ':')) return false; + } + return true; +} + +bool parse_u64_any(std::string_view text, uint64_t *value) { + if (!value || text.empty()) return false; + uint64_t parsed = 0; + auto result = std::from_chars(text.data(), text.data() + text.size(), parsed, 10); + if (result.ec != std::errc() || result.ptr != text.data() + text.size()) return false; + *value = parsed; + return true; +} + +bool parse_u64_positive(std::string_view text, uint64_t *value) { + return parse_u64_any(text, value) && *value != 0; +} + +bool parse_u32_any(std::string_view text, uint32_t *value) { + uint64_t parsed = 0; + if (!parse_u64_any(text, &parsed) || parsed > UINT32_MAX) return false; + *value = static_cast(parsed); + return true; +} + +bool parse_finite_double(std::string_view text, double *value) { + if (!value || text.empty() || text.size() > 128) return false; + std::istringstream stream{std::string(text)}; + stream.imbue(std::locale::classic()); + double parsed = 0.0; + stream >> parsed; + if (!stream || !stream.eof() || !std::isfinite(parsed)) return false; + *value = parsed; + return true; +} + +std::vector words(std::string_view line) { + std::vector out; + size_t at = 0; + while (at < line.size()) { + while (at < line.size() && (line[at] == ' ' || line[at] == '\t')) ++at; + if (at == line.size()) break; + size_t end = at; + while (end < line.size() && line[end] != ' ' && line[end] != '\t') ++end; + out.push_back(line.substr(at, end - at)); + at = end; + } + return out; +} + +struct SessionIdentity { + std::string decoder; + std::string decoder_version; + std::string optical_state_token; + unsigned char optical_state_sha256[32]{}; +}; + +bool parse_session(std::string_view text, SessionIdentity *identity) { + if (!identity || text.empty() || text.back() != '\n' || text.find('\0') != std::string_view::npos || + text.find('\r') != std::string_view::npos) + return false; + size_t at = 0; + size_t line_number = 0; + bool target = false, measurement = false, white_border = false, planarity = false; + bool decoder = false, optical_state = false, image = false; + while (at < text.size()) { + size_t end = text.find('\n', at); + if (end == std::string_view::npos) return false; + std::string_view line = text.substr(at, end - at); + at = end + 1; + ++line_number; + if (line_number == 1) { + if (line != "L3DCAL_SESSION_V1") return false; + continue; + } + if (line.empty()) continue; + auto w = words(line); + if (w.empty()) return false; + const auto tag = w[0]; + if (tag == "target") { + uint32_t squares_x = 0, squares_y = 0; double square = 0, marker = 0; + if (target || w.size() != 8 || !token_ok(w[1]) || w[3] != "DICT_5X5_100" || + !parse_u32_any(w[4], &squares_x) || !parse_u32_any(w[5], &squares_y) || + !parse_finite_double(w[6], &square) || !parse_finite_double(w[7], &marker) || + squares_x != 9 || squares_y != 7 || square != 30.0 || marker != 21.0) return false; + unsigned char hash[32]; if (!parse_sha256(w[2], hash)) return false; target = true; + } else if (tag == "measurement") { + if (measurement || w.size() != 13 || !token_ok(w[1])) return false; + double v = 0; if (!parse_finite_double(w[2], &v) || v <= 0 || v > .1) return false; + for (size_t i = 3; i < w.size(); ++i) if (!parse_finite_double(w[i], &v)) return false; + measurement = true; + } else if (tag == "white_border") { + double v = 0; if (white_border || w.size() != 2 || !parse_finite_double(w[1], &v) || v < 30.0) return false; + white_border = true; + } else if (tag == "planarity") { + unsigned char hash[32]; + if (planarity || w.size() != 3 || w[1] != "PASS" || !parse_sha256(w[2], hash)) return false; + planarity = true; + } else if (tag == "decoder") { + if (decoder || w.size() != 3 || !token_ok(w[1]) || !token_ok(w[2])) return false; + identity->decoder.assign(w[1]); identity->decoder_version.assign(w[2]); decoder = true; + } else if (tag == "optical_state") { + if (optical_state || w.size() != 3 || !parse_sha256(w[1], identity->optical_state_sha256) || + !token_ok(w[2]) || w[2] == "UNKNOWN") return false; + identity->optical_state_token.assign(w[2]); optical_state = true; + } else if (tag == "image") { + unsigned char hash[32]; uint32_t orientation = 0; + if (w.size() != 4 || w[1].empty() || w[1].size() > 4096 || !parse_sha256(w[2], hash) || + !parse_u32_any(w[3], &orientation) || + (orientation != 90 && orientation != 180 && orientation != 270 && w[3] != "0")) return false; + image = true; + } else if (tag == "pre_solve" || tag == "clipping") { + unsigned char hash[32]; double v = 0; + if (w.size() != 3 || !parse_sha256(w[1], hash) || !parse_finite_double(w[2], &v)) return false; + } else if (tag == "coordinate") { + unsigned char hash[32]; uint32_t orientation = 0, width = 0, height = 0, count = 0; double dx = 0, dy = 0; + if (w.size() != 10 || !parse_sha256(w[1], hash) || !token_ok(w[2]) || !token_ok(w[3]) || + !parse_u32_any(w[4], &orientation) || !parse_u32_any(w[5], &width) || !parse_u32_any(w[6], &height) || + !parse_u32_any(w[7], &count) || !parse_finite_double(w[8], &dx) || !parse_finite_double(w[9], &dy) || + width == 0 || height == 0 || count < 20 || count > 48 || + (orientation != 90 && orientation != 180 && orientation != 270 && w[4] != "0")) return false; + } else if (tag == "coordinate_point") { + unsigned char hash[32]; double a = 0, b = 0, c = 0, d = 0; + if (w.size() != 7 || !parse_sha256(w[1], hash) || !token_ok(w[2]) || + !parse_finite_double(w[3], &a) || !parse_finite_double(w[4], &b) || + !parse_finite_double(w[5], &c) || !parse_finite_double(w[6], &d)) return false; + } else if (tag == "distance") { + unsigned char hash[32]; double meters = 0; uint32_t band = 0; + if (w.size() != 4 || !parse_sha256(w[1], hash) || !parse_finite_double(w[2], &meters) || + !parse_u32_any(w[3], &band) || meters <= 0 || band > 2) return false; + } else { + return false; + } + } + return target && measurement && white_border && planarity && decoder && optical_state && image; +} + +struct CampaignState { + uint64_t execution_id = 0; + uint64_t optical_configuration_id = 0; + std::string optical_state_token; + unsigned char optical_state_sha256[32]{}; + std::vector captures; +}; + +bool parse_campaign_state(std::string_view text, CampaignState *state) { + if (!state || text.empty() || text.back() != '\n' || text.find('\0') != std::string_view::npos || + text.find('\r') != std::string_view::npos) return false; + std::vector lines; + size_t at = 0; + while (at < text.size()) { + size_t end = text.find('\n', at); if (end == std::string_view::npos) return false; + if (end > at) lines.push_back(text.substr(at, end - at)); + at = end + 1; + } + if (lines.size() < 5 || lines[0] != "L3DCAL_CAMPAIGN_STATE_V1") return false; + auto execution = words(lines[1]); + auto configuration = words(lines[2]); + auto optical = words(lines[3]); + if (execution.size() != 2 || execution[0] != "execution" || !parse_u64_positive(execution[1], &state->execution_id) || + configuration.size() != 2 || configuration[0] != "optical_configuration" || + !parse_u64_positive(configuration[1], &state->optical_configuration_id) || optical.size() != 3 || + optical[0] != "optical_state" || !parse_sha256(optical[1], state->optical_state_sha256) || + !token_ok(optical[2]) || optical[2] == "UNKNOWN") return false; + state->optical_state_token.assign(optical[2]); + for (size_t i = 4; i < lines.size(); ++i) { + auto capture = words(lines[i]); + uint64_t index = 0, capture_id = 0; + if (capture.size() != 3 || capture[0] != "capture" || !parse_u64_any(capture[1], &index) || + !parse_u64_positive(capture[2], &capture_id) || index != state->captures.size()) return false; + if (state->captures.size() >= LARDON3D_CALIBRATION_WORKFLOW_MAX_SELECTED_ITEMS) return false; + state->captures.push_back(capture_id); + } + return !state->captures.empty(); +} + +class JsonParser { + public: + explicit JsonParser(std::string_view text) : text_(text) {} + bool parse(std::map *top_scalars) { + top_scalars_ = top_scalars; + skip_ws(); + if (!parse_object(0, true)) return false; + skip_ws(); + return at_ == text_.size(); + } + private: + bool parse_object(size_t depth, bool top) { + if (depth > kJsonDepthMax || !take('{')) return false; + skip_ws(); if (take('}')) return true; + std::set keys; + while (true) { + std::string key; + if (!parse_string(&key, true) || key.size() > kJsonKeyMax || !keys.insert(key).second) return false; + skip_ws(); if (!take(':')) return false; skip_ws(); + if (!parse_value(depth + 1, top ? &key : nullptr)) return false; + skip_ws(); if (take('}')) return true; if (!take(',')) return false; skip_ws(); + } + } + bool parse_array(size_t depth) { + if (depth > kJsonDepthMax || !take('[')) return false; + skip_ws(); if (take(']')) return true; + while (true) { + if (!parse_value(depth + 1, nullptr)) return false; + skip_ws(); if (take(']')) return true; if (!take(',')) return false; skip_ws(); + } + } + bool parse_value(size_t depth, const std::string *top_key) { + if (depth > kJsonDepthMax || at_ >= text_.size()) return false; + if (text_[at_] == '{') return parse_object(depth, false); + if (text_[at_] == '[') return parse_array(depth); + if (text_[at_] == '"') { + std::string value; + if (!parse_string(top_key ? &value : nullptr, top_key != nullptr)) return false; + if (top_key) (*top_scalars_)[*top_key] = value; + return true; + } + size_t start = at_; + if (parse_literal("true") || parse_literal("false") || parse_literal("null") || parse_number()) { + if (top_key) (*top_scalars_)[*top_key] = std::string(text_.substr(start, at_ - start)); + return true; + } + return false; + } + bool parse_string(std::string *out, bool capture) { + if (!take('"')) return false; + if (capture && out) out->clear(); + while (at_ < text_.size()) { + unsigned char c = static_cast(text_[at_++]); + if (c == '"') return true; + if (c < 0x20) return false; + if (c == '\\') { + if (at_ >= text_.size()) return false; + char e = text_[at_++]; + if (e == 'u') { + if (at_ + 4 > text_.size()) return false; + for (size_t i = 0; i < 4; ++i) { unsigned v = 0; if (!hex_digit(text_[at_ + i], &v)) return false; } + at_ += 4; + } else if (std::string_view("\"\\/bfnrt").find(e) == std::string_view::npos) return false; + if (capture) return false; + } else if (capture && out) { + out->push_back(static_cast(c)); + } + } + return false; + } + bool parse_number() { + size_t p = at_; + if (p < text_.size() && text_[p] == '-') ++p; + if (p >= text_.size()) return false; + if (text_[p] == '0') ++p; + else { + if (text_[p] < '1' || text_[p] > '9') return false; + while (p < text_.size() && std::isdigit(static_cast(text_[p]))) ++p; + } + if (p < text_.size() && text_[p] == '.') { + ++p; size_t digits = p; while (p < text_.size() && std::isdigit(static_cast(text_[p]))) ++p; + if (p == digits) return false; + } + if (p < text_.size() && (text_[p] == 'e' || text_[p] == 'E')) { + ++p; if (p < text_.size() && (text_[p] == '+' || text_[p] == '-')) ++p; + size_t digits = p; while (p < text_.size() && std::isdigit(static_cast(text_[p]))) ++p; + if (p == digits) return false; + } + at_ = p; return true; + } + bool parse_literal(std::string_view literal) { + if (text_.substr(at_, literal.size()) != literal) return false; + at_ += literal.size(); return true; + } + void skip_ws() { while (at_ < text_.size() && (text_[at_] == ' ' || text_[at_] == '\n' || text_[at_] == '\r' || text_[at_] == '\t')) ++at_; } + bool take(char c) { if (at_ >= text_.size() || text_[at_] != c) return false; ++at_; return true; } + std::string_view text_; + size_t at_ = 0; + std::map *top_scalars_ = nullptr; +}; + +bool scalar(const std::map& values, const char *key, std::string *out) { + auto it = values.find(key); if (it == values.end()) return false; *out = it->second; return true; +} + +bool parse_json_top(std::string_view text, std::map *top) { + if (text.empty() || text.find('\0') != std::string_view::npos) return false; + JsonParser parser(text); return parser.parse(top); +} + +bool require_format(const std::map& top, const char *expected) { + auto it = top.find("format"); return it != top.end() && it->second == expected; +} + +} // namespace + +extern "C" Lardon3DCalibrationWorkflowResult lardon3d_calibration_workflow_validate_input_boundary( + const Lardon3DCalibrationWorkflowInputFiles *files, + Lardon3DCalibrationWorkflowInputBoundary *boundary) { + if (!files || !boundary || !files->session_path || !files->detection_path || !files->solve_path || + !files->evidence_path || !files->producer_path || !files->campaign_state_path) + return LARDON3D_CALIBRATION_WORKFLOW_INVALID_ARGUMENT; + std::memset(boundary, 0, sizeof(*boundary)); + + MappedFile session, detection, solve, evidence, producer, campaign; + MappedFile *mapped[] = {&session, &detection, &solve, &evidence, &producer, &campaign}; + const char *paths[] = {files->session_path, files->detection_path, files->solve_path, + files->evidence_path, files->producer_path, files->campaign_state_path}; + for (size_t i = 0; i < 6; ++i) { + Lardon3DCalibrationWorkflowResult r = map_regular_file(paths[i], mapped[i]); + if (r != LARDON3D_CALIBRATION_WORKFLOW_OK) return r; + } + + SessionIdentity session_identity; + if (!parse_session(std::string_view(reinterpret_cast(session.data), session.size), &session_identity)) + return LARDON3D_CALIBRATION_WORKFLOW_MALFORMED_EVIDENCE; + CampaignState campaign_state; + if (!parse_campaign_state(std::string_view(reinterpret_cast(campaign.data), campaign.size), &campaign_state)) + return LARDON3D_CALIBRATION_WORKFLOW_MALFORMED_EVIDENCE; + + std::map detection_top, solve_top, evidence_top, producer_top; + if (!parse_json_top(std::string_view(reinterpret_cast(detection.data), detection.size), &detection_top) || + !parse_json_top(std::string_view(reinterpret_cast(solve.data), solve.size), &solve_top) || + !parse_json_top(std::string_view(reinterpret_cast(evidence.data), evidence.size), &evidence_top) || + !parse_json_top(std::string_view(reinterpret_cast(producer.data), producer.size), &producer_top)) + return LARDON3D_CALIBRATION_WORKFLOW_MALFORMED_EVIDENCE; + if (!require_format(detection_top, "L3DCAL_DETECTION_V1") || !require_format(solve_top, "L3DCAL_SOLVE_V1") || + !require_format(evidence_top, "L3DCAL_EVIDENCE_BUNDLE_V1") || !require_format(producer_top, "L3DCAL_PRODUCER_V1")) + return LARDON3D_CALIBRATION_WORKFLOW_MALFORMED_EVIDENCE; + + std::string detection_decoder, detection_version, evidence_optical, evidence_state; + std::string producer_executable, producer_configuration, producer_session, producer_optical, producer_threads; + if (!scalar(detection_top, "decoder", &detection_decoder) || !scalar(detection_top, "decoder_version", &detection_version) || + !scalar(evidence_top, "optical_sha256", &evidence_optical) || !scalar(evidence_top, "optical_state", &evidence_state) || + !scalar(producer_top, "solver_executable_sha256", &producer_executable) || + !scalar(producer_top, "solver_configuration_sha256", &producer_configuration) || + !scalar(producer_top, "session_sha256", &producer_session) || !scalar(producer_top, "optical_sha256", &producer_optical) || + !scalar(producer_top, "threads", &producer_threads)) + return LARDON3D_CALIBRATION_WORKFLOW_MALFORMED_EVIDENCE; + + unsigned char evidence_optical_sha[32], producer_optical_sha[32], producer_session_sha[32]; + unsigned char executable_sha[32], configuration_sha[32]; + if (!parse_sha256(evidence_optical, evidence_optical_sha) || !parse_sha256(producer_optical, producer_optical_sha) || + !parse_sha256(producer_session, producer_session_sha) || !parse_sha256(producer_executable, executable_sha) || + !parse_sha256(producer_configuration, configuration_sha) || producer_threads != "1" || + !nonzero_sha(executable_sha) || !nonzero_sha(configuration_sha)) + return LARDON3D_CALIBRATION_WORKFLOW_MALFORMED_EVIDENCE; + + if (detection_decoder != session_identity.decoder || detection_version != session_identity.decoder_version || + std::memcmp(evidence_optical_sha, session_identity.optical_state_sha256, 32) != 0 || + evidence_state != session_identity.optical_state_token || + std::memcmp(producer_optical_sha, session_identity.optical_state_sha256, 32) != 0 || + std::memcmp(producer_session_sha, session.sha256, 32) != 0 || + std::memcmp(campaign_state.optical_state_sha256, session_identity.optical_state_sha256, 32) != 0 || + campaign_state.optical_state_token != session_identity.optical_state_token) + return LARDON3D_CALIBRATION_WORKFLOW_PROVENANCE_MISMATCH; + + std::memcpy(boundary->session_sha256, session.sha256, 32); + std::memcpy(boundary->detection_sha256, detection.sha256, 32); + std::memcpy(boundary->solve_sha256, solve.sha256, 32); + std::memcpy(boundary->evidence_sha256, evidence.sha256, 32); + std::memcpy(boundary->producer_sha256, producer.sha256, 32); + std::memcpy(boundary->campaign_state_sha256, campaign.sha256, 32); + std::memcpy(boundary->optical_state_sha256, session_identity.optical_state_sha256, 32); + std::memcpy(boundary->solver_executable_sha256, executable_sha, 32); + std::memcpy(boundary->solver_configuration_sha256, configuration_sha, 32); + boundary->selected_execution_id = campaign_state.execution_id; + boundary->optical_configuration_id = campaign_state.optical_configuration_id; + boundary->capture_count = static_cast(campaign_state.captures.size()); + for (size_t i = 0; i < campaign_state.captures.size(); ++i) boundary->capture_ids[i] = campaign_state.captures[i]; + std::memcpy(boundary->optical_state_token, session_identity.optical_state_token.data(), session_identity.optical_state_token.size()); + boundary->optical_state_token[session_identity.optical_state_token.size()] = '\0'; + return LARDON3D_CALIBRATION_WORKFLOW_OK; +} diff --git a/tests/test_calibration_workflow.cpp b/tests/test_calibration_workflow.cpp new file mode 100644 index 0000000..3271d9c --- /dev/null +++ b/tests/test_calibration_workflow.cpp @@ -0,0 +1,156 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr, "workflow failure %d: %s\n", __LINE__, #x); return false; } } while (0) + +namespace { + +std::string hex_repeat(char c) { return std::string(64, c); } + +bool write_text(const std::filesystem::path& path, const std::string& text) { + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out << text; + return static_cast(out); +} + +std::string sha256_file(const std::filesystem::path& path) { + std::ifstream in(path, std::ios::binary); + std::ostringstream bytes; bytes << in.rdbuf(); + std::string data = bytes.str(); + unsigned char digest[32]{}; unsigned int size = 0; + if (!in || EVP_Digest(data.data(), data.size(), digest, &size, EVP_sha256(), nullptr) != 1 || size != 32) return {}; + static const char digits[] = "0123456789abcdef"; + std::string hex(64, '0'); + for (size_t i = 0; i < 32; ++i) { hex[2*i] = digits[digest[i] >> 4]; hex[2*i+1] = digits[digest[i] & 15]; } + return hex; +} + +struct Fixture { + std::filesystem::path root; + std::filesystem::path session, detection, solve, evidence, producer, campaign; + Lardon3DCalibrationWorkflowInputFiles files{}; +}; + +std::string session_text(const std::string& optical) { + const std::string generator = hex_repeat('2'), planarity = hex_repeat('3'), image = hex_repeat('4'); + std::ostringstream s; + s << "L3DCAL_SESSION_V1\n" + << "target board " << generator << " DICT_5X5_100 9 7 30 21\n" + << "measurement caliper 0.1 30 30 30 30 30 30 30 30 30 30\n" + << "white_border 30\n" + << "planarity PASS " << planarity << "\n" + << "decoder qualified_decoder 1\n" + << "optical_state " << optical << " body_objective_zoom_focus_stabilization_format_pipeline\n" + << "image /nonexistent " << image << " 0\n" + << "pre_solve " << image << " 0.1\n" + << "clipping " << image << " 0.0\n" + << "coordinate " << image << " qualified_decoder 1 0 100 100 20 0 0\n"; + for (int i = 0; i < 20; ++i) + s << "coordinate_point " << image << " center " << i << " 0 " << i << " 0\n"; + s << "distance " << image << " 0.4 1\n"; + return s.str(); +} + +bool make_valid(Fixture *f) { + char temp[] = "/tmp/lardon3d-calibration-workflow-XXXXXX"; + char *root = mkdtemp(temp); if (!root) return false; + f->root = root; + f->session = f->root / "session.l3dcal"; + f->detection = f->root / "detection.json"; + f->solve = f->root / "solve.json"; + f->evidence = f->root / "evidence.json"; + f->producer = f->root / "producer.json"; + f->campaign = f->root / "campaign.l3dcal"; + const std::string optical = hex_repeat('1'); + if (!write_text(f->session, session_text(optical))) return false; + const std::string session_sha = sha256_file(f->session); if (session_sha.empty()) return false; + if (!write_text(f->detection, + "{\n\"format\":\"L3DCAL_DETECTION_V1\",\n\"decoder\":\"qualified_decoder\",\n\"decoder_version\":\"1\",\n\"views\":[]\n}\n")) return false; + if (!write_text(f->solve, "{\n\"format\":\"L3DCAL_SOLVE_V1\",\n\"runs\":[],\n\"fit_params\":[]\n}\n")) return false; + if (!write_text(f->evidence, + "{\n\"format\":\"L3DCAL_EVIDENCE_BUNDLE_V1\",\n\"optical_sha256\":\"" + optical + + "\",\n\"optical_state\":\"body_objective_zoom_focus_stabilization_format_pipeline\",\n\"validation_flags\":\"0xf\",\n\"residuals\":[]\n}\n")) return false; + if (!write_text(f->producer, + "{\n\"format\":\"L3DCAL_PRODUCER_V1\",\n\"solver_executable_sha256\":\"" + hex_repeat('a') + + "\",\n\"solver_configuration_sha256\":\"" + hex_repeat('b') + + "\",\n\"session_sha256\":\"" + session_sha + + "\",\n\"opencv_version\":\"5.0.0\",\n\"opencv_build_sha256\":\"" + hex_repeat('c') + + "\",\n\"threads\":1,\n\"rng_seed\":1278432342,\n\"optical_sha256\":\"" + optical + "\"\n}\n")) return false; + if (!write_text(f->campaign, + "L3DCAL_CAMPAIGN_STATE_V1\nexecution 42\noptical_configuration 7\noptical_state " + optical + + " body_objective_zoom_focus_stabilization_format_pipeline\ncapture 0 101\ncapture 1 102\n")) return false; + f->files = {f->session.c_str(), f->detection.c_str(), f->solve.c_str(), f->evidence.c_str(), f->producer.c_str(), f->campaign.c_str()}; + return true; +} + +bool valid_and_identity() { + Fixture f; CHECK(make_valid(&f)); + Lardon3DCalibrationWorkflowInputBoundary boundary{}; + CHECK(lardon3d_calibration_workflow_validate_input_boundary(&f.files, &boundary) == LARDON3D_CALIBRATION_WORKFLOW_OK); + CHECK(boundary.selected_execution_id == 42 && boundary.optical_configuration_id == 7 && boundary.capture_count == 2); + CHECK(boundary.capture_ids[0] == 101 && boundary.capture_ids[1] == 102); + CHECK(std::strcmp(boundary.optical_state_token, "body_objective_zoom_focus_stabilization_format_pipeline") == 0); + std::filesystem::remove_all(f.root); return true; +} + +bool malformed_and_provenance() { + Fixture f; CHECK(make_valid(&f)); + Lardon3DCalibrationWorkflowInputBoundary boundary{}; + CHECK(write_text(f.solve, "{\"format\":\"L3DCAL_SOLVE_V1\",}")); + CHECK(lardon3d_calibration_workflow_validate_input_boundary(&f.files, &boundary) == LARDON3D_CALIBRATION_WORKFLOW_MALFORMED_EVIDENCE); + CHECK(boundary.selected_execution_id == 0); + CHECK(write_text(f.solve, "{\"format\":\"L3DCAL_SOLVE_V1\",\"runs\":[]}")); + std::string producer; { std::ifstream in(f.producer); std::ostringstream x; x << in.rdbuf(); producer = x.str(); } + const std::string actual = sha256_file(f.session); CHECK(!actual.empty()); + const size_t at = producer.find(actual); CHECK(at != std::string::npos); producer.replace(at, 64, hex_repeat('0')); + CHECK(write_text(f.producer, producer)); + CHECK(lardon3d_calibration_workflow_validate_input_boundary(&f.files, &boundary) == LARDON3D_CALIBRATION_WORKFLOW_PROVENANCE_MISMATCH); + std::filesystem::remove_all(f.root); + CHECK(make_valid(&f)); + std::string campaign; { std::ifstream in(f.campaign); std::ostringstream x; x << in.rdbuf(); campaign = x.str(); } + const size_t opt = campaign.find(hex_repeat('1')); CHECK(opt != std::string::npos); campaign.replace(opt, 64, hex_repeat('9')); + CHECK(write_text(f.campaign, campaign)); + CHECK(lardon3d_calibration_workflow_validate_input_boundary(&f.files, &boundary) == LARDON3D_CALIBRATION_WORKFLOW_PROVENANCE_MISMATCH); + std::filesystem::remove_all(f.root); return true; +} + +bool file_boundary() { + Fixture f; CHECK(make_valid(&f)); + Lardon3DCalibrationWorkflowInputBoundary boundary{}; + const std::filesystem::path fifo = f.root / "fifo"; + CHECK(mkfifo(fifo.c_str(), 0600) == 0); + auto saved = f.files.producer_path; f.files.producer_path = fifo.c_str(); + CHECK(lardon3d_calibration_workflow_validate_input_boundary(&f.files, &boundary) == LARDON3D_CALIBRATION_WORKFLOW_NON_REGULAR_FILE); + f.files.producer_path = saved; + const std::filesystem::path link = f.root / "producer-link"; + CHECK(symlink(f.producer.c_str(), link.c_str()) == 0); saved = f.files.producer_path; f.files.producer_path = link.c_str(); + CHECK(lardon3d_calibration_workflow_validate_input_boundary(&f.files, &boundary) == LARDON3D_CALIBRATION_WORKFLOW_NON_REGULAR_FILE); + f.files.producer_path = saved; + const std::filesystem::path huge = f.root / "huge"; + int fd = open(huge.c_str(), O_CREAT | O_WRONLY | O_CLOEXEC, 0600); CHECK(fd >= 0); + CHECK(ftruncate(fd, static_cast(LARDON3D_CALIBRATION_WORKFLOW_MAX_FILE_BYTES) + 1) == 0); CHECK(close(fd) == 0); + saved = f.files.producer_path; f.files.producer_path = huge.c_str(); + CHECK(lardon3d_calibration_workflow_validate_input_boundary(&f.files, &boundary) == LARDON3D_CALIBRATION_WORKFLOW_CAPACITY); + f.files.producer_path = saved; + std::filesystem::remove_all(f.root); return true; +} + +} // namespace + +int main() { + if (!valid_and_identity() || !malformed_and_provenance() || !file_boundary()) return 1; + std::puts("CALIBRATION_WORKFLOW_INPUT_BOUNDARY_V1=PASS"); + return 0; +}