diff --git a/README.md b/README.md index 277b696..afab6b6 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ persistante, enrichissable et versionnable. - **ScanSet / Image Catalog v1** : acquisitions, images logiques, provenance et assets SHA-256 persistants et paginés - **Capture / Asset Provenance v1** : Captures par ScanSet, associations source/dérivé et sélection explicite d'une image logique — IMPLEMENTED / VALIDATION PENDING +- **Découverte et planification de campagne bornées** : racines explicites, + plan metadata-only et exécution par groupes via S3-E — IMPLEMENTED / + VALIDATION PENDING - **Feature Store v1/v2** : ORB U8×32, SIFT/RootSIFT F32×128 et lecture typée bornée - **Image View** : vues triées et filtrées pour la TUI - **Task** : moteur de tâches avec pause/reprise, annulation et séquences diff --git a/docs/architecture/project_database.md b/docs/architecture/project_database.md index 2cb337d..4fe9712 100644 --- a/docs/architecture/project_database.md +++ b/docs/architecture/project_database.md @@ -56,6 +56,64 @@ chaque image v18, relie son asset comme source et rend cette même image sélectionnée. Aucun nom de fichier, EXIF ou rapprochement de siblings n'est interprété ; les IDs et la sémantique v18 restent inchangés. +### Couche finale bornée de découverte et de planification de campagne + +**IMPLEMENTED / VALIDATION PENDING.** Cette couche reçoit de 1 à 64 racines +absolues, lexicalement normalisées et explicitement fournies par l'appelant. +Elle ne parcourt pas récursivement : chaque racine et chacune de ses entrées +immédiates régulières est inspectée sans suivre de lien symbolique. Seuls les +fichiers `.arw`, `.jpg` et `.jpeg`, sans sensibilité à la casse, sont des +sources supportées. La découverte, les groupes et les propositions sont chacun +bornés à 4096 éléments et les chemins sont ordonnés de façon déterministe par +`strcmp`. + +La planification appelle S3-D uniquement sur les métadonnées : elle ne lit pas +de pixels, n'écrit pas en base et ne matérialise aucun asset ou Capture. Une +association automatique est admise seulement pour une relation forte unique et +mutuelle. Un stem de nom de fichier identique est une proposition, jamais une +identité. Les contradictions, ambiguïtés, comparaisons insuffisantes et cas non +résolus restent des singletons. L'appelant peut confirmer explicitement de 1 à +64 groupes ; chacun est alors étiqueté `CALLER_EXPLICIT`. Une confirmation +réelle explicite reste nécessaire avant tout regroupement. + +Le plan et sa progression appartiennent à l'appelant. Ils retiennent les +`capture_id` et `resume_capture_id` retournés afin de reprendre par Capture ; +aucune garantie whole-request exactly-once n'existe avant cette rétention. +L'exécution applique le lot par groupe, avec exactement un appel S3-E par +groupe figé. S3-E demeure l'unique matérialisateur et les représentations +restent explicites. Project DB demeure v19 : cette couche ne détourne ni Task, +ni checkpoint, et n'ajoute aucun sous-système de persistance. + +La validation structurelle JPEG accepte un fichier ordinaire jusqu'à son EOI, +puis uniquement du remplissage nul. Si et seulement si l'image primaire porte +un segment APP2 commençant par `MPF\0`, ce remplissage peut être suivi d'une +nouvelle image JPEG structurellement validée par le même parseur ; jusqu'à huit +images au total sont admises, en mémoire constante. Chaque intervalle et la fin +physique restent limités à des octets nuls. Une image ajoutée sans preuve MPF, +un trailer non nul, une image secondaire tronquée ou sans EOI et un neuvième +élément sont corrompus. Les marqueurs ressemblants dans les payloads APP/EXIF +et les données entropy-coded ne deviennent jamais des frontières de conteneur. + +Observation autorisée sur le jeu A6000 réel, en dry run : 953 ARW, 953 JPEG, +soit 1906 sources ; métadonnées OK pour les 1906 sources, indisponibles 0 et +autres erreurs 0. Les fichiers JPEG Sony sont des conteneurs MPF valides avec +une image secondaire et un remplissage final nul. Leurs métadonnées ne portent +pas d'`ImageUniqueID` utilisable en commun avec les RAW : fortes 0, +propositions candidates par stem 953, ambiguïtés 0, contradictions 0, +comparaisons insuffisantes 1814512, non résolus 1906 et groupes automatiques +planifiés 1906. L'inversion de l'ordre des racines a réussi la vérification de +déterminisme. Ce dry run n'a effectué ni écriture DB, ni matérialisation, ni +développement RAW ; il ne prouve donc pas un compte physique de 1906 +acquisitions et n'affaiblit pas S3-D. Les 953 propositions nécessitent une +confirmation explicite `CALLER_EXPLICIT` avant tout regroupement. Le fixture +d'intégration couvre l'adaptateur, sa matérialisation et son retry ; aucune +mutation de campagne complète n'a été effectuée. + +Restent hors S3 : identité persistante de campagne ou de Task, exécution par +scheduler/Governor, traitement de campagne complet, TUI, scrub d'assets et +réconciliation des orphelins, ainsi que vidéo et reconstruction aval lorsqu'ils +sont applicables. + ### S3-D — Acquisition Pairing Evidence v1 **IMPLEMENTED / VALIDATION PENDING — non FROZEN.** S3-D extrait, depuis des diff --git a/include/lardon3d/acquisition_campaign.h b/include/lardon3d/acquisition_campaign.h new file mode 100644 index 0000000..931420d --- /dev/null +++ b/include/lardon3d/acquisition_campaign.h @@ -0,0 +1,130 @@ +#ifndef LARDON3D_ACQUISITION_CAMPAIGN_H +#define LARDON3D_ACQUISITION_CAMPAIGN_H + +#include +#include + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define LARDON3D_ACQUISITION_CAMPAIGN_MAX_ROOTS 64u +#define LARDON3D_ACQUISITION_CAMPAIGN_MAX_SOURCES 4096u +#define LARDON3D_ACQUISITION_CAMPAIGN_MAX_GROUPS 4096u +#define LARDON3D_ACQUISITION_CAMPAIGN_MAX_PROPOSALS 4096u +#define LARDON3D_ACQUISITION_CAMPAIGN_PATH_CAPACITY LARDON3D_APP_STATE_PATH_CAPACITY + +typedef enum { + LARDON3D_ACQUISITION_CAMPAIGN_OK = 0, + LARDON3D_ACQUISITION_CAMPAIGN_INVALID_ARGUMENT, + LARDON3D_ACQUISITION_CAMPAIGN_IO_ERROR, + LARDON3D_ACQUISITION_CAMPAIGN_DUPLICATE, + LARDON3D_ACQUISITION_CAMPAIGN_PATH_TOO_LONG, + LARDON3D_ACQUISITION_CAMPAIGN_LIMIT_EXCEEDED, + LARDON3D_ACQUISITION_CAMPAIGN_CONSTRAINT, + LARDON3D_ACQUISITION_CAMPAIGN_INGEST_ERROR, + LARDON3D_ACQUISITION_CAMPAIGN_INTERNAL_ERROR +} Lardon3DAcquisitionCampaignResult; + +typedef struct { + char path[LARDON3D_ACQUISITION_CAMPAIGN_PATH_CAPACITY]; +} Lardon3DAcquisitionCampaignRoot; + +typedef struct { + char path[LARDON3D_ACQUISITION_CAMPAIGN_PATH_CAPACITY]; + Lardon3DAcquisitionSourceKind source_kind; + Lardon3DAcquisitionResult metadata_result; + Lardon3DAcquisitionMetadata metadata; +} Lardon3DAcquisitionCampaignSource; + +typedef struct { + size_t discovered_entry_count; + size_t supported_source_count; + size_t unsupported_entry_count; + size_t metadata_ok_count; + size_t metadata_error_count; +} Lardon3DAcquisitionCampaignDiscoverySummary; + +typedef struct { + size_t source_count; + Lardon3DAcquisitionCampaignSource sources[LARDON3D_ACQUISITION_CAMPAIGN_MAX_SOURCES]; + Lardon3DAcquisitionCampaignDiscoverySummary summary; +} Lardon3DAcquisitionCampaignDiscovery; + +typedef struct { + size_t source_count; + size_t source_indices[LARDON3D_ACQUISITION_INGEST_MAX_SOURCES]; +} Lardon3DAcquisitionCampaignConfirmation; + +typedef enum { + LARDON3D_ACQUISITION_CAMPAIGN_PROPOSAL_CANDIDATE = 1, + LARDON3D_ACQUISITION_CAMPAIGN_PROPOSAL_AMBIGUOUS = 2 +} Lardon3DAcquisitionCampaignProposalKind; + +typedef struct { + size_t left_source_index; + size_t right_source_index; + Lardon3DAcquisitionCampaignProposalKind kind; + Lardon3DAcquisitionPairResult pair; +} Lardon3DAcquisitionCampaignProposal; + +typedef struct { + uint32_t group_id; + Lardon3DAcquisitionGroupBasis basis; + size_t source_count; + size_t source_indices[LARDON3D_ACQUISITION_INGEST_MAX_SOURCES]; +} Lardon3DAcquisitionCampaignGroup; + +typedef struct { + size_t source_count; + size_t metadata_ok_count; + size_t metadata_error_count; + size_t strong_group_count; + size_t explicit_group_count; + size_t unresolved_source_count; + size_t ambiguous_pair_count; + size_t contradictory_pair_count; + size_t insufficient_pair_count; + size_t candidate_pair_count; +} Lardon3DAcquisitionCampaignPlanSummary; + +typedef struct { + size_t group_count; + Lardon3DAcquisitionCampaignGroup groups[LARDON3D_ACQUISITION_CAMPAIGN_MAX_GROUPS]; + size_t proposal_count; + Lardon3DAcquisitionCampaignProposal proposals[LARDON3D_ACQUISITION_CAMPAIGN_MAX_PROPOSALS]; + Lardon3DAcquisitionCampaignPlanSummary summary; +} Lardon3DAcquisitionCampaignPlan; + +/* + * Roots must be absolute and lexically normalized. Only their immediate regular-file + * entries are considered. Symlinks are never followed. Extensions .arw, .jpg and .jpeg + * are supported case-insensitively; all other immediate entries are counted as unsupported. + */ +Lardon3DAcquisitionCampaignResult lardon3d_acquisition_campaign_discover( + const Lardon3DAcquisitionCampaignRoot *roots, size_t root_count, + Lardon3DAcquisitionCampaignDiscovery *discovery); + +/* Pure planning over caller-supplied normalized source/evidence records. */ +Lardon3DAcquisitionCampaignResult lardon3d_acquisition_campaign_plan( + const Lardon3DAcquisitionCampaignSource *sources, size_t source_count, + const Lardon3DAcquisitionCampaignConfirmation *confirmations, size_t confirmation_count, + Lardon3DAcquisitionCampaignPlan *plan); + +/* Materializes exactly one stable plan group. Retain capture_id before retrying with it. */ +Lardon3DAcquisitionCampaignResult lardon3d_acquisition_campaign_materialize_group( + Lardon3DAppState *state, uint64_t scanset_id, + const Lardon3DAcquisitionCampaignSource *sources, size_t source_count, + const Lardon3DAcquisitionCampaignPlan *plan, uint32_t group_id, + const Lardon3DAcquisitionIngestOptions *options, + Lardon3DAcquisitionIngestOutput *output, + Lardon3DAcquisitionIngestResult *ingest_result); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/meson.build b/meson.build index fb6fa93..66bc491 100644 --- a/meson.build +++ b/meson.build @@ -151,6 +151,7 @@ executable( 'src/raw_development.cpp', 'src/acquisition_pairing.cpp', 'src/acquisition_ingest.cpp', + 'src/acquisition_campaign.cpp', 'src/feature_extractor_opencv.cpp', 'src/feature_store.c', 'src/feature_extractor_opencv.cpp', @@ -355,6 +356,27 @@ acquisition_ingest_test = executable( test('acquisition-ingest', acquisition_ingest_test, timeout: 30) +acquisition_campaign_test = executable( + 'test-acquisition-campaign', + sources: [ + 'tests/test_acquisition_campaign.cpp', + 'src/app_state.c', + 'src/image_catalog_persistent.c', + 'src/acquisition_campaign.cpp', + 'src/acquisition_ingest.cpp', + 'src/acquisition_pairing.cpp', + 'src/raw_development.cpp', + 'src/project_db.c', 'src/project_db_sparse_sfm.c', + 'src/task.c', + 'src/resource_governor.c', + 'src/resource_snapshot.c', + ], + include_directories: include_directories('include'), + dependencies: [threads, sqlite3, openssl, opencv, libraw, libexif, libpng, libdeflate], +) + +test('acquisition-campaign', acquisition_campaign_test, timeout: 30) + feature_store_test = executable( 'test-feature-store', sources: [ diff --git a/src/acquisition_campaign.cpp b/src/acquisition_campaign.cpp new file mode 100644 index 0000000..d9f1c9e --- /dev/null +++ b/src/acquisition_campaign.cpp @@ -0,0 +1,299 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +bool bounded(const char *text, size_t capacity) { + return text != nullptr && text[0] != '\0' && std::memchr(text, '\0', capacity) != nullptr; +} + +bool normalized_absolute(const char *path) { + if (!bounded(path, LARDON3D_ACQUISITION_CAMPAIGN_PATH_CAPACITY) || path[0] != '/') return false; + const size_t length = std::strlen(path); + if (length > 1u && path[length - 1u] == '/') return false; + for (const char *part = path + 1; *part != '\0';) { + const char *slash = std::strchr(part, '/'); + const size_t size = slash == nullptr ? std::strlen(part) : static_cast(slash - part); + if (size == 0u || (size == 1u && part[0] == '.') || + (size == 2u && part[0] == '.' && part[1] == '.')) return false; + if (slash == nullptr) break; + part = slash + 1; + } + return true; +} + +Lardon3DAcquisitionSourceKind extension_kind(const char *name) { + const char *dot = std::strrchr(name, '.'); + if (dot == nullptr) return LARDON3D_ACQUISITION_SOURCE_UNSUPPORTED; + char extension[6]{}; + const size_t length = std::strlen(dot); + if (length >= sizeof(extension)) return LARDON3D_ACQUISITION_SOURCE_UNSUPPORTED; + for (size_t i = 0; i <= length; ++i) + extension[i] = static_cast(std::tolower(static_cast(dot[i]))); + if (std::strcmp(extension, ".arw") == 0) return LARDON3D_ACQUISITION_SOURCE_RAW; + if (std::strcmp(extension, ".jpg") == 0 || std::strcmp(extension, ".jpeg") == 0) + return LARDON3D_ACQUISITION_SOURCE_JPEG; + return LARDON3D_ACQUISITION_SOURCE_UNSUPPORTED; +} + +bool same_stem(const char *left, const char *right) { + const char *left_name = std::strrchr(left, '/'); left_name = left_name ? left_name + 1 : left; + const char *right_name = std::strrchr(right, '/'); right_name = right_name ? right_name + 1 : right; + const char *left_dot = std::strrchr(left_name, '.'); + const char *right_dot = std::strrchr(right_name, '.'); + const size_t left_size = left_dot ? static_cast(left_dot - left_name) : std::strlen(left_name); + const size_t right_size = right_dot ? static_cast(right_dot - right_name) : std::strlen(right_name); + return left_size == right_size && std::memcmp(left_name, right_name, left_size) == 0; +} + +bool add_proposal(Lardon3DAcquisitionCampaignPlan &plan, size_t left, size_t right, + Lardon3DAcquisitionCampaignProposalKind kind, + const Lardon3DAcquisitionPairResult &pair) { + if (plan.proposal_count >= LARDON3D_ACQUISITION_CAMPAIGN_MAX_PROPOSALS) return false; + auto &proposal = plan.proposals[plan.proposal_count++]; + proposal = {left, right, kind, pair}; + return true; +} + +} // namespace + +extern "C" Lardon3DAcquisitionCampaignResult lardon3d_acquisition_campaign_discover( + const Lardon3DAcquisitionCampaignRoot *roots, size_t root_count, + Lardon3DAcquisitionCampaignDiscovery *discovery) { + if (discovery != nullptr) std::memset(discovery, 0, sizeof(*discovery)); + if (roots == nullptr || root_count == 0u || + root_count > LARDON3D_ACQUISITION_CAMPAIGN_MAX_ROOTS || discovery == nullptr) + return LARDON3D_ACQUISITION_CAMPAIGN_INVALID_ARGUMENT; + try { + std::unique_ptr storage( + new Lardon3DAcquisitionCampaignDiscovery{}); + auto &result = *storage; + for (size_t i = 0; i < root_count; ++i) { + if (!normalized_absolute(roots[i].path)) return LARDON3D_ACQUISITION_CAMPAIGN_INVALID_ARGUMENT; + for (size_t j = 0; j < i; ++j) + if (std::strcmp(roots[i].path, roots[j].path) == 0) + return LARDON3D_ACQUISITION_CAMPAIGN_DUPLICATE; + int root_fd = open(roots[i].path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (root_fd < 0) return LARDON3D_ACQUISITION_CAMPAIGN_IO_ERROR; + DIR *directory = fdopendir(root_fd); + if (directory == nullptr) { close(root_fd); return LARDON3D_ACQUISITION_CAMPAIGN_IO_ERROR; } + for (;;) { + errno = 0; + dirent *entry = readdir(directory); + if (entry == nullptr) { + const int read_errno = errno; + closedir(directory); + if (read_errno != 0) return LARDON3D_ACQUISITION_CAMPAIGN_IO_ERROR; + break; + } + if (std::strcmp(entry->d_name, ".") == 0 || std::strcmp(entry->d_name, "..") == 0) continue; + ++result.summary.discovered_entry_count; + struct stat status{}; + if (fstatat(root_fd, entry->d_name, &status, AT_SYMLINK_NOFOLLOW) != 0) { + closedir(directory); return LARDON3D_ACQUISITION_CAMPAIGN_IO_ERROR; + } + if (!S_ISREG(status.st_mode)) { ++result.summary.unsupported_entry_count; continue; } + int leaf_fd = openat(root_fd, entry->d_name, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + if (leaf_fd < 0) { + if (errno == ELOOP) { ++result.summary.unsupported_entry_count; continue; } + closedir(directory); return LARDON3D_ACQUISITION_CAMPAIGN_IO_ERROR; + } + if (fstat(leaf_fd, &status) != 0) { close(leaf_fd); closedir(directory); return LARDON3D_ACQUISITION_CAMPAIGN_IO_ERROR; } + const auto kind = extension_kind(entry->d_name); + if (!S_ISREG(status.st_mode) || kind == LARDON3D_ACQUISITION_SOURCE_UNSUPPORTED) { + ++result.summary.unsupported_entry_count; close(leaf_fd); continue; + } + if (result.source_count >= LARDON3D_ACQUISITION_CAMPAIGN_MAX_SOURCES) { + close(leaf_fd); closedir(directory); return LARDON3D_ACQUISITION_CAMPAIGN_LIMIT_EXCEEDED; + } + auto &source = result.sources[result.source_count++]; + int written = std::snprintf(source.path, sizeof(source.path), "%s/%s", roots[i].path, + entry->d_name); + if (written <= 0 || static_cast(written) >= sizeof(source.path)) { + close(leaf_fd); closedir(directory); return LARDON3D_ACQUISITION_CAMPAIGN_PATH_TOO_LONG; + } + source.source_kind = kind; + char descriptor_path[64]{}; + written = std::snprintf(descriptor_path, sizeof(descriptor_path), "/proc/self/fd/%d", leaf_fd); + if (written <= 0 || static_cast(written) >= sizeof(descriptor_path)) { + close(leaf_fd); closedir(directory); return LARDON3D_ACQUISITION_CAMPAIGN_INTERNAL_ERROR; + } + source.metadata_result = lardon3d_acquisition_extract_metadata(descriptor_path, &source.metadata); + if (source.metadata_result == LARDON3D_ACQUISITION_OK) ++result.summary.metadata_ok_count; + else ++result.summary.metadata_error_count; + ++result.summary.supported_source_count; + close(leaf_fd); + } + } + std::sort(result.sources, result.sources + result.source_count, + [](const auto &left, const auto &right) { return std::strcmp(left.path, right.path) < 0; }); + for (size_t i = 1; i < result.source_count; ++i) + if (std::strcmp(result.sources[i - 1].path, result.sources[i].path) == 0) + return LARDON3D_ACQUISITION_CAMPAIGN_DUPLICATE; + *discovery = result; + return LARDON3D_ACQUISITION_CAMPAIGN_OK; + } catch (const std::bad_alloc &) { return LARDON3D_ACQUISITION_CAMPAIGN_INTERNAL_ERROR; } + catch (...) { return LARDON3D_ACQUISITION_CAMPAIGN_INTERNAL_ERROR; } +} + +extern "C" Lardon3DAcquisitionCampaignResult lardon3d_acquisition_campaign_plan( + const Lardon3DAcquisitionCampaignSource *sources, size_t source_count, + const Lardon3DAcquisitionCampaignConfirmation *confirmations, size_t confirmation_count, + Lardon3DAcquisitionCampaignPlan *plan) { + if (plan != nullptr) std::memset(plan, 0, sizeof(*plan)); + if (sources == nullptr || source_count == 0u || + source_count > LARDON3D_ACQUISITION_CAMPAIGN_MAX_SOURCES || plan == nullptr || + (confirmation_count != 0u && confirmations == nullptr) || confirmation_count > source_count) + return LARDON3D_ACQUISITION_CAMPAIGN_INVALID_ARGUMENT; + try { + std::unique_ptr storage(new Lardon3DAcquisitionCampaignPlan{}); + auto &result = *storage; + result.summary.source_count = source_count; + bool assigned[LARDON3D_ACQUISITION_CAMPAIGN_MAX_SOURCES]{}; + for (size_t i = 0; i < source_count; ++i) { + if (!normalized_absolute(sources[i].path)) return LARDON3D_ACQUISITION_CAMPAIGN_INVALID_ARGUMENT; + if (i != 0u && std::strcmp(sources[i - 1].path, sources[i].path) >= 0) + return LARDON3D_ACQUISITION_CAMPAIGN_DUPLICATE; + if (sources[i].metadata_result == LARDON3D_ACQUISITION_OK) ++result.summary.metadata_ok_count; + else ++result.summary.metadata_error_count; + } + for (size_t c = 0; c < confirmation_count; ++c) { + const auto &confirmation = confirmations[c]; + if (confirmation.source_count == 0u || + confirmation.source_count > LARDON3D_ACQUISITION_INGEST_MAX_SOURCES) + return LARDON3D_ACQUISITION_CAMPAIGN_CONSTRAINT; + auto &group = result.groups[result.group_count++]; + group.basis = LARDON3D_ACQUISITION_GROUP_EXPLICIT; + group.source_count = confirmation.source_count; + for (size_t m = 0; m < confirmation.source_count; ++m) { + const size_t index = confirmation.source_indices[m]; + if (index >= source_count || assigned[index]) return LARDON3D_ACQUISITION_CAMPAIGN_CONSTRAINT; + for (size_t previous = 0; previous < m; ++previous) + if (confirmation.source_indices[previous] == index) + return LARDON3D_ACQUISITION_CAMPAIGN_CONSTRAINT; + group.source_indices[m] = index; assigned[index] = true; + } + std::sort(group.source_indices, group.source_indices + group.source_count); + ++result.summary.explicit_group_count; + } + size_t strong_count[LARDON3D_ACQUISITION_CAMPAIGN_MAX_SOURCES]{}; + size_t strong_partner[LARDON3D_ACQUISITION_CAMPAIGN_MAX_SOURCES]{}; + for (size_t i = 0; i < source_count; ++i) for (size_t j = i + 1; j < source_count; ++j) { + if (sources[i].metadata_result != LARDON3D_ACQUISITION_OK || + sources[j].metadata_result != LARDON3D_ACQUISITION_OK) continue; + Lardon3DAcquisitionPairResult pair{}; + const bool stem = same_stem(sources[i].path, sources[j].path); + if (lardon3d_acquisition_compare(&sources[i].metadata, &sources[j].metadata, 0, stem, + &pair) != LARDON3D_ACQUISITION_OK) + return LARDON3D_ACQUISITION_CAMPAIGN_INTERNAL_ERROR; + if (pair.contradictions != 0u) ++result.summary.contradictory_pair_count; + if (pair.decision == LARDON3D_ACQUISITION_SAME_ACQUISITION_STRONG) { + ++strong_count[i]; ++strong_count[j]; strong_partner[i] = j; strong_partner[j] = i; + } else if (pair.decision == LARDON3D_ACQUISITION_SAME_ACQUISITION_CANDIDATE) { + if (stem) { + ++result.summary.candidate_pair_count; + if (!add_proposal(result, i, j, + LARDON3D_ACQUISITION_CAMPAIGN_PROPOSAL_CANDIDATE, pair)) + return LARDON3D_ACQUISITION_CAMPAIGN_LIMIT_EXCEEDED; + } else { + ++result.summary.insufficient_pair_count; + } + } else if (pair.decision == LARDON3D_ACQUISITION_AMBIGUOUS) { + ++result.summary.ambiguous_pair_count; + if (!add_proposal(result, i, j, LARDON3D_ACQUISITION_CAMPAIGN_PROPOSAL_AMBIGUOUS, pair)) + return LARDON3D_ACQUISITION_CAMPAIGN_LIMIT_EXCEEDED; + } else if (pair.decision == LARDON3D_ACQUISITION_INSUFFICIENT && stem) { + ++result.summary.candidate_pair_count; + if (!add_proposal(result, i, j, LARDON3D_ACQUISITION_CAMPAIGN_PROPOSAL_CANDIDATE, pair)) + return LARDON3D_ACQUISITION_CAMPAIGN_LIMIT_EXCEEDED; + } else if (pair.decision == LARDON3D_ACQUISITION_INSUFFICIENT) { + ++result.summary.insufficient_pair_count; + } + } + for (size_t i = 0; i < source_count; ++i) for (size_t j = i + 1; j < source_count; ++j) { + if (sources[i].metadata_result != LARDON3D_ACQUISITION_OK || + sources[j].metadata_result != LARDON3D_ACQUISITION_OK) continue; + Lardon3DAcquisitionPairResult pair{}; + if (lardon3d_acquisition_compare(&sources[i].metadata, &sources[j].metadata, 0, + same_stem(sources[i].path, sources[j].path), &pair) != + LARDON3D_ACQUISITION_OK) + return LARDON3D_ACQUISITION_CAMPAIGN_INTERNAL_ERROR; + if (pair.decision != LARDON3D_ACQUISITION_SAME_ACQUISITION_STRONG) continue; + if (strong_count[i] > 1u || strong_count[j] > 1u) { + ++result.summary.ambiguous_pair_count; + if (!add_proposal(result, i, j, LARDON3D_ACQUISITION_CAMPAIGN_PROPOSAL_AMBIGUOUS, pair)) + return LARDON3D_ACQUISITION_CAMPAIGN_LIMIT_EXCEEDED; + } + if (strong_count[i] == 1u && strong_count[j] == 1u && + strong_partner[i] == j && strong_partner[j] == i && (assigned[i] || assigned[j])) + return LARDON3D_ACQUISITION_CAMPAIGN_CONSTRAINT; + } + for (size_t i = 0; i < source_count; ++i) { + if (assigned[i] || strong_count[i] != 1u) continue; + const size_t partner = strong_partner[i]; + if (partner >= source_count || assigned[partner] || strong_count[partner] != 1u || + strong_partner[partner] != i) continue; + auto &group = result.groups[result.group_count++]; + group.basis = LARDON3D_ACQUISITION_GROUP_STRONG; group.source_count = 2u; + group.source_indices[0] = i; group.source_indices[1] = partner; + assigned[i] = assigned[partner] = true; ++result.summary.strong_group_count; + } + for (size_t i = 0; i < source_count; ++i) if (!assigned[i]) { + auto &group = result.groups[result.group_count++]; + group.basis = LARDON3D_ACQUISITION_GROUP_SINGLETON; group.source_count = 1u; + group.source_indices[0] = i; assigned[i] = true; ++result.summary.unresolved_source_count; + } + std::sort(result.groups, result.groups + result.group_count, [](const auto &left, const auto &right) { + return left.source_indices[0] < right.source_indices[0]; + }); + for (size_t i = 0; i < result.group_count; ++i) result.groups[i].group_id = static_cast(i + 1u); + *plan = result; + return LARDON3D_ACQUISITION_CAMPAIGN_OK; + } catch (const std::bad_alloc &) { return LARDON3D_ACQUISITION_CAMPAIGN_INTERNAL_ERROR; } + catch (...) { return LARDON3D_ACQUISITION_CAMPAIGN_INTERNAL_ERROR; } +} + +extern "C" Lardon3DAcquisitionCampaignResult lardon3d_acquisition_campaign_materialize_group( + Lardon3DAppState *state, uint64_t scanset_id, + const Lardon3DAcquisitionCampaignSource *sources, size_t source_count, + const Lardon3DAcquisitionCampaignPlan *plan, uint32_t group_id, + const Lardon3DAcquisitionIngestOptions *options, Lardon3DAcquisitionIngestOutput *output, + Lardon3DAcquisitionIngestResult *ingest_result) { + if (output != nullptr) std::memset(output, 0, sizeof(*output)); + if (ingest_result != nullptr) *ingest_result = LARDON3D_ACQUISITION_INGEST_INVALID_ARGUMENT; + if (state == nullptr || sources == nullptr || source_count == 0u || + source_count > LARDON3D_ACQUISITION_CAMPAIGN_MAX_SOURCES || plan == nullptr || + group_id == 0u || group_id > plan->group_count || options == nullptr || output == nullptr || + ingest_result == nullptr) return LARDON3D_ACQUISITION_CAMPAIGN_INVALID_ARGUMENT; + const auto &group = plan->groups[group_id - 1u]; + if (group.group_id != group_id || group.source_count == 0u || + group.source_count > LARDON3D_ACQUISITION_INGEST_MAX_SOURCES) + return LARDON3D_ACQUISITION_CAMPAIGN_CONSTRAINT; + Lardon3DAcquisitionIngestSource inputs[LARDON3D_ACQUISITION_INGEST_MAX_SOURCES]{}; + for (size_t i = 0; i < group.source_count; ++i) { + if (group.source_indices[i] >= source_count) return LARDON3D_ACQUISITION_CAMPAIGN_CONSTRAINT; + inputs[i].source_path = sources[group.source_indices[i]].path; + inputs[i].explicit_group = group.basis == LARDON3D_ACQUISITION_GROUP_EXPLICIT ? 1u : 0u; + } + Lardon3DAcquisitionIngestOptions effective = *options; + effective.grouping = group.basis == LARDON3D_ACQUISITION_GROUP_EXPLICIT + ? LARDON3D_ACQUISITION_GROUP_CALLER_EXPLICIT + : LARDON3D_ACQUISITION_GROUP_AUTOMATIC; + *ingest_result = lardon3d_acquisition_ingest(state, scanset_id, inputs, group.source_count, + &effective, output); + return *ingest_result == LARDON3D_ACQUISITION_INGEST_OK + ? LARDON3D_ACQUISITION_CAMPAIGN_OK + : LARDON3D_ACQUISITION_CAMPAIGN_INGEST_ERROR; +} diff --git a/src/acquisition_pairing.cpp b/src/acquisition_pairing.cpp index 0bbe2e7..0bdae7f 100644 --- a/src/acquisition_pairing.cpp +++ b/src/acquisition_pairing.cpp @@ -133,6 +133,184 @@ bool extract_unsigned(ExifData *data, ExifTag tag, uint32_t &value) { return false; } +enum class JpegValidationResult { + valid, + corrupt, + io_error, +}; + +constexpr size_t JPEG_MAX_CONTAINER_IMAGES = 8u; + +bool read_byte(FILE *file, unsigned char &value) { + const int byte = std::fgetc(file); + if (byte == EOF) { + return false; + } + value = static_cast(byte); + return true; +} + +bool skip_bytes(FILE *file, uint16_t count) { + unsigned char byte = 0u; + for (uint16_t index = 0u; index < count; ++index) { + if (!read_byte(file, byte)) { + return false; + } + } + return true; +} + +bool marker_has_length(unsigned char marker) { + return (marker >= 0xc0u && marker <= 0xc7u) || + (marker >= 0xc9u && marker <= 0xcfu) || + (marker >= 0xdau && marker <= 0xdfu) || + (marker >= 0xe0u && marker <= 0xfeu); +} + +bool read_marker(FILE *file, unsigned char &marker) { + unsigned char byte = 0u; + if (!read_byte(file, byte) || byte != 0xffu) { + return false; + } + do { + if (!read_byte(file, marker)) { + return false; + } + } while (marker == 0xffu); + return marker != 0x00u; +} + +bool skip_marker_payload(FILE *file, unsigned char marker, bool &has_mpf_app2) { + unsigned char high = 0u; + unsigned char low = 0u; + if (!read_byte(file, high) || !read_byte(file, low)) { + return false; + } + const uint16_t length = static_cast((static_cast(high) << 8u) | + static_cast(low)); + if (marker == 0xdau) { + unsigned char component_count = 0u; + if (length < 3u || !read_byte(file, component_count) || component_count == 0u || + component_count > 4u) { + return false; + } + const uint16_t expected_length = + static_cast(6u + 2u * static_cast(component_count)); + return length == expected_length && + skip_bytes(file, static_cast(length - 3u)); + } + if (marker == 0xdcu) { + unsigned char line_count_high = 0u; + unsigned char line_count_low = 0u; + return length == 4u && read_byte(file, line_count_high) && + read_byte(file, line_count_low) && + (line_count_high != 0u || line_count_low != 0u); + } + if (marker == 0xe2u && length >= 6u) { + unsigned char identifier[4] = {}; + for (unsigned char &byte : identifier) { + if (!read_byte(file, byte)) { + return false; + } + } + if (std::memcmp(identifier, "MPF\0", sizeof(identifier)) == 0) { + has_mpf_app2 = true; + } + return skip_bytes(file, static_cast(length - 6u)); + } + return length >= 2u && skip_bytes(file, static_cast(length - 2u)); +} + +JpegValidationResult validate_jpeg_image(FILE *file, bool soi_already_consumed, + bool &has_mpf_app2) { + has_mpf_app2 = false; + if (!soi_already_consumed) { + unsigned char first = 0u; + unsigned char second = 0u; + if (!read_byte(file, first) || !read_byte(file, second) || first != 0xffu || + second != 0xd8u) { + return std::ferror(file) != 0 ? JpegValidationResult::io_error + : JpegValidationResult::corrupt; + } + } + + bool in_entropy = false; + for (;;) { + unsigned char marker = 0u; + bool marker_from_entropy = false; + if (in_entropy) { + unsigned char byte = 0u; + do { + if (!read_byte(file, byte)) { + return std::ferror(file) != 0 ? JpegValidationResult::io_error + : JpegValidationResult::corrupt; + } + } while (byte != 0xffu); + do { + if (!read_byte(file, marker)) { + return std::ferror(file) != 0 ? JpegValidationResult::io_error + : JpegValidationResult::corrupt; + } + } while (marker == 0xffu); + if (marker == 0x00u || (marker >= 0xd0u && marker <= 0xd7u)) { + continue; + } + marker_from_entropy = true; + in_entropy = false; + } else if (!read_marker(file, marker)) { + return std::ferror(file) != 0 ? JpegValidationResult::io_error + : JpegValidationResult::corrupt; + } + + if (marker == 0xd9u) { + return JpegValidationResult::valid; + } + if (marker == 0xd8u) { + return JpegValidationResult::corrupt; + } + if (marker == 0x01u || (marker >= 0xd0u && marker <= 0xd7u)) { + continue; + } + if (!marker_has_length(marker) || + !skip_marker_payload(file, marker, has_mpf_app2)) { + return std::ferror(file) != 0 ? JpegValidationResult::io_error + : JpegValidationResult::corrupt; + } + if (marker == 0xdau || (marker == 0xdcu && marker_from_entropy)) { + in_entropy = true; + } + } +} + +JpegValidationResult validate_jpeg_structure(FILE *file) { + bool primary_has_mpf = false; + for (size_t image_index = 0u; image_index < JPEG_MAX_CONTAINER_IMAGES; ++image_index) { + bool image_has_mpf = false; + const JpegValidationResult image_result = + validate_jpeg_image(file, image_index != 0u, image_has_mpf); + if (image_result != JpegValidationResult::valid) { + return image_result; + } + if (image_index == 0u) { + primary_has_mpf = image_has_mpf; + } + + int byte = EOF; + do { + byte = std::fgetc(file); + } while (byte == 0); + if (byte == EOF) { + return std::ferror(file) != 0 ? JpegValidationResult::io_error + : JpegValidationResult::valid; + } + if (!primary_has_mpf || byte != 0xff || std::fgetc(file) != 0xd8) { + return std::ferror(file) != 0 ? JpegValidationResult::io_error + : JpegValidationResult::corrupt; + } + } + return JpegValidationResult::corrupt; +} + Lardon3DAcquisitionResult extract_jpeg(const char *path, Lardon3DAcquisitionMetadata &metadata) { FILE *file = std::fopen(path, "rb"); @@ -140,14 +318,12 @@ Lardon3DAcquisitionResult extract_jpeg(const char *path, return errno == ENOENT || errno == EACCES ? LARDON3D_ACQUISITION_IO_ERROR : LARDON3D_ACQUISITION_IO_ERROR; } - bool has_end_marker = false; - if (std::fseek(file, -2L, SEEK_END) == 0) { - unsigned char tail[2] = {}; - has_end_marker = std::fread(tail, 1u, sizeof(tail), file) == sizeof(tail) && - tail[0] == 0xffu && tail[1] == 0xd9u; + const JpegValidationResult validation = validate_jpeg_structure(file); + const bool close_failed = std::fclose(file) != 0; + if (validation == JpegValidationResult::io_error || close_failed) { + return LARDON3D_ACQUISITION_IO_ERROR; } - std::fclose(file); - if (!has_end_marker) { + if (validation != JpegValidationResult::valid) { return LARDON3D_ACQUISITION_CORRUPT_SOURCE; } diff --git a/tests/test_acquisition_campaign.cpp b/tests/test_acquisition_campaign.cpp new file mode 100644 index 0000000..e5e18d8 --- /dev/null +++ b/tests/test_acquisition_campaign.cpp @@ -0,0 +1,330 @@ +#include +#include +extern "C" { +#include +} +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +int failures; +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr, "FAIL line %d: %s\n", __LINE__, #x); ++failures; } } while (0) + +void source(Lardon3DAcquisitionCampaignSource &s, const char *path, const char *id = nullptr, + const char *serial = nullptr) { + s = {}; std::snprintf(s.path, sizeof(s.path), "%s", path); + s.source_kind = std::strstr(path, ".arw") ? LARDON3D_ACQUISITION_SOURCE_RAW : LARDON3D_ACQUISITION_SOURCE_JPEG; + s.metadata_result = LARDON3D_ACQUISITION_OK; + s.metadata.policy_version = LARDON3D_ACQUISITION_PAIRING_POLICY_VERSION; + s.metadata.source_kind = s.source_kind; + if (id) { s.metadata.present_fields |= LARDON3D_ACQUISITION_FIELD_IMAGE_UNIQUE_ID; + std::snprintf(s.metadata.image_unique_id, sizeof(s.metadata.image_unique_id), "%s", id); } + if (serial) { s.metadata.present_fields |= LARDON3D_ACQUISITION_FIELD_BODY_SERIAL; + std::snprintf(s.metadata.body_serial, sizeof(s.metadata.body_serial), "%s", serial); } +} + +bool remove_tree(const char *path) { + struct stat st{}; if (lstat(path, &st) != 0) return errno == ENOENT; + if (!S_ISDIR(st.st_mode)) return unlink(path) == 0; + DIR *dir = opendir(path); if (!dir) return false; bool ok = true; + for (dirent *e = readdir(dir); e; e = readdir(dir)) { + if (!std::strcmp(e->d_name, ".") || !std::strcmp(e->d_name, "..")) continue; + char child[4096]{}; int n = std::snprintf(child, sizeof(child), "%s/%s", path, e->d_name); + if (n <= 0 || static_cast(n) >= sizeof(child) || !remove_tree(child)) ok = false; + } + return closedir(dir) == 0 && rmdir(path) == 0 && ok; +} + +void ascii(ExifData *d, ExifIfd ifd, ExifTag tag, const char *text) { + ExifEntry *e = exif_entry_new(); e->tag = tag; e->format = EXIF_FORMAT_ASCII; + e->components = std::strlen(text) + 1; e->size = static_cast(e->components); + e->data = static_cast(std::malloc(e->size)); std::memcpy(e->data, text, e->size); + exif_content_add_entry(d->ifd[ifd], e); exif_entry_unref(e); +} + +bool jpeg(const char *path, const char *id) { + ExifData *d = exif_data_new(); if (!d) return false; exif_data_set_byte_order(d, EXIF_BYTE_ORDER_INTEL); + ascii(d, EXIF_IFD_0, EXIF_TAG_MODEL, "Campaign test camera"); + ascii(d, EXIF_IFD_EXIF, EXIF_TAG_IMAGE_UNIQUE_ID, id); + unsigned char *encoded = nullptr; unsigned encoded_size = 0; exif_data_save_data(d, &encoded, &encoded_size); + FILE *f = std::fopen(path, "wb"); unsigned segment = encoded_size + 2; + const unsigned char head[] = {0xff, 0xd8, 0xff, 0xe1, static_cast(segment >> 8), + static_cast(segment)}; + const unsigned char tail[] = {0xff, 0xd9}; + bool ok = f && encoded && segment <= UINT16_MAX && std::fwrite(head, 1, sizeof(head), f) == sizeof(head) && + std::fwrite(encoded, 1, encoded_size, f) == encoded_size && std::fwrite(tail, 1, 2, f) == 2 && std::fclose(f) == 0; + if (f && !ok) std::fclose(f); + std::free(encoded); + exif_data_unref(d); + return ok; +} + +bool empty_file(const char *path) { + FILE *file = std::fopen(path, "wb"); + return file != nullptr && std::fclose(file) == 0; +} + +struct Fixture { + char root[4096] = "/tmp/lardon3d-campaign-XXXXXX", db_path[4096]{}; + Lardon3DProjectDb *db{}; Lardon3DAppState state{}; Lardon3DProjectDbScanSet scanset{}; + bool open() { + if (!mkdtemp(root) || std::snprintf(db_path, sizeof(db_path), "%s/project.db", root) <= 0) return false; + char error[LARDON3D_PROJECT_DB_ERROR_CAPACITY]{}; + if (lardon3d_project_db_open(db_path, &db, error) != LARDON3D_PROJECT_DB_OK) return false; + state.project_loaded = true; state.project_db = db; + std::snprintf(state.project_path, sizeof(state.project_path), "%s", root); + return lardon3d_project_db_create_scanset(db, "campaign", &scanset) == LARDON3D_PROJECT_DB_OK; + } + bool path(char out[4096], const char *name) { int n = std::snprintf(out, 4096, "%s/%s", root, name); return n > 0 && n < 4096; } + ~Fixture() { if (db) lardon3d_project_db_close(db); (void)remove_tree(root); } +}; + +size_t captures(Fixture &f) { Lardon3DProjectDbCapture c[16]{}; size_t n = 0; + return lardon3d_project_db_list_captures(f.db, f.scanset.scanset_id, 0, c, 16, &n) == LARDON3D_PROJECT_DB_OK ? n : SIZE_MAX; } +Lardon3DAcquisitionIngestOptions options() { Lardon3DAcquisitionIngestOptions o{}; + o.grouping = LARDON3D_ACQUISITION_GROUP_AUTOMATIC; o.representation = LARDON3D_ACQUISITION_SELECT_JPEG_SOURCE; + o.select_representation = 1; o.imported_at = 17; o.max_source_bytes = 1024 * 1024; return o; } + +void planner_tests() { + Lardon3DAcquisitionCampaignSource v[4]{}; + source(v[0], "/a/0.arw", "same", "camera-a"); source(v[1], "/a/1.jpg", "same", "camera-a"); + source(v[2], "/a/2.arw", "same", "camera-b"); source(v[3], "/a/3.jpg", "other", "camera-a"); + Lardon3DAcquisitionCampaignPlan p{}; + CHECK(lardon3d_acquisition_campaign_plan(v, 4, nullptr, 0, &p) == LARDON3D_ACQUISITION_CAMPAIGN_OK); + CHECK(p.summary.strong_group_count == 1 && p.groups[0].source_indices[0] == 0 && p.groups[0].source_indices[1] == 1); + + Lardon3DAcquisitionCampaignSource explicit_sources[4]{}; + source(explicit_sources[0], "/e/0.jpg"); source(explicit_sources[1], "/e/1.jpg"); + source(explicit_sources[2], "/e/2.jpg"); source(explicit_sources[3], "/e/3.jpg"); + Lardon3DAcquisitionCampaignConfirmation x[2]{}; x[0].source_count = 3; + x[0].source_indices[0] = 2; x[0].source_indices[1] = 0; x[0].source_indices[2] = 1; + CHECK(lardon3d_acquisition_campaign_plan(explicit_sources, 4, x, 1, &p) == LARDON3D_ACQUISITION_CAMPAIGN_OK); + CHECK(p.groups[0].basis == LARDON3D_ACQUISITION_GROUP_EXPLICIT && p.groups[0].source_count == 3 && + p.groups[0].source_indices[0] == 0 && p.groups[0].source_indices[2] == 2); + x[0].source_count = 2; x[0].source_indices[0] = 0; x[0].source_indices[1] = 0; + CHECK(lardon3d_acquisition_campaign_plan(explicit_sources, 4, x, 1, &p) == LARDON3D_ACQUISITION_CAMPAIGN_CONSTRAINT); + x[0].source_indices[1] = 4; + CHECK(lardon3d_acquisition_campaign_plan(explicit_sources, 4, x, 1, &p) == LARDON3D_ACQUISITION_CAMPAIGN_CONSTRAINT); + x[0].source_indices[1] = 1; x[1].source_count = 2; x[1].source_indices[0] = 1; x[1].source_indices[1] = 2; + CHECK(lardon3d_acquisition_campaign_plan(explicit_sources, 4, x, 2, &p) == LARDON3D_ACQUISITION_CAMPAIGN_CONSTRAINT); + + Lardon3DAcquisitionCampaignSource a[4]{v[0], v[1], v[2], v[3]}, b[4]{v[3], v[1], v[0], v[2]}; + auto less = [](const auto &l, const auto &r) { return std::strcmp(l.path, r.path) < 0; }; + std::sort(a, a + 4, less); std::sort(b, b + 4, less); + Lardon3DAcquisitionCampaignPlan pa{}, pb{}; + CHECK(lardon3d_acquisition_campaign_plan(a, 4, nullptr, 0, &pa) == LARDON3D_ACQUISITION_CAMPAIGN_OK); + CHECK(lardon3d_acquisition_campaign_plan(b, 4, nullptr, 0, &pb) == LARDON3D_ACQUISITION_CAMPAIGN_OK); + CHECK(std::memcmp(&pa, &pb, sizeof(pa)) == 0); + + constexpr size_t weak_source_count = 100; + auto weak = std::make_unique(weak_source_count); + for (size_t i = 0; i < weak_source_count - 2u; ++i) { + char path[64]{}; + std::snprintf(path, sizeof(path), "/weak/%03zu.jpg", i); + source(weak[i], path, nullptr, "shared-camera"); + } + source(weak[weak_source_count - 2u], "/weak/match.arw", nullptr, "shared-camera"); + source(weak[weak_source_count - 1u], "/weak/match.jpg", nullptr, "shared-camera"); + std::sort(weak.get(), weak.get() + weak_source_count, less); + auto weak_plan = std::make_unique(); + CHECK(lardon3d_acquisition_campaign_plan(weak.get(), weak_source_count, nullptr, 0, + weak_plan.get()) == + LARDON3D_ACQUISITION_CAMPAIGN_OK); + CHECK(weak_plan->proposal_count == 1u); + CHECK(weak_plan->summary.candidate_pair_count == 1u); + CHECK(weak_plan->summary.insufficient_pair_count == 4949u); + CHECK(weak_plan->proposals[0].kind == LARDON3D_ACQUISITION_CAMPAIGN_PROPOSAL_CANDIDATE); + CHECK(std::strstr(weak[weak_plan->proposals[0].left_source_index].path, "/match.") != nullptr); + CHECK(std::strstr(weak[weak_plan->proposals[0].right_source_index].path, "/match.") != nullptr); +} + +void materialization_tests() { + Fixture f; CHECK(f.open()); if (!f.db) return; char paths[3][4096]{}; + CHECK(f.path(paths[0], "a.JPG") && f.path(paths[1], "b.JPG") && f.path(paths[2], "c.JPG")); + CHECK(jpeg(paths[0], "a") && jpeg(paths[1], "b") && jpeg(paths[2], "c")); + Lardon3DAcquisitionCampaignSource s[3]{}; source(s[0], paths[0]); source(s[1], paths[1]); source(s[2], paths[2]); + Lardon3DAcquisitionCampaignConfirmation c[2]{}; c[0].source_count = 2; c[0].source_indices[0] = 0; c[0].source_indices[1] = 1; + c[1].source_count = 1; c[1].source_indices[0] = 2; Lardon3DAcquisitionCampaignPlan p{}; + CHECK(lardon3d_acquisition_campaign_plan(s, 3, c, 2, &p) == LARDON3D_ACQUISITION_CAMPAIGN_OK); + auto o = options(); Lardon3DAcquisitionIngestOutput out{}; Lardon3DAcquisitionIngestResult ir{}; + CHECK(lardon3d_acquisition_campaign_materialize_group(&f.state, f.scanset.scanset_id, s, 3, &p, 1, &o, &out, &ir) == LARDON3D_ACQUISITION_CAMPAIGN_OK); + CHECK(ir == LARDON3D_ACQUISITION_INGEST_OK && out.group_count == 1 && out.groups[0].source_count == 2 && captures(f) == 1); + uint64_t capture_id = out.groups[0].capture_id, image_count = 0, selected = 0; size_t asset_count = 0; + Lardon3DProjectDbCaptureAsset assets[4]{}; + CHECK(lardon3d_project_db_list_capture_assets(f.db, capture_id, 0, assets, 4, &asset_count) == LARDON3D_PROJECT_DB_OK && asset_count == 2); + CHECK(assets[0].role == LARDON3D_DB_CAPTURE_ASSET_SOURCE && assets[1].role == LARDON3D_DB_CAPTURE_ASSET_SOURCE); + CHECK(lardon3d_project_db_count_images(f.db, f.scanset.scanset_id, &image_count) == LARDON3D_PROJECT_DB_OK && image_count == 1); + CHECK(lardon3d_project_db_get_selected_capture_image(f.db, capture_id, &selected) == LARDON3D_PROJECT_DB_OK && selected != 0); + o.resume_capture_id = capture_id; + CHECK(lardon3d_acquisition_campaign_materialize_group(&f.state, f.scanset.scanset_id, s, 3, &p, 1, &o, &out, &ir) == LARDON3D_ACQUISITION_CAMPAIGN_OK); + CHECK(out.groups[0].capture_id == capture_id && captures(f) == 1); + asset_count = 0; CHECK(lardon3d_project_db_list_capture_assets(f.db, capture_id, 0, assets, 4, &asset_count) == LARDON3D_PROJECT_DB_OK && asset_count == 2); + CHECK(lardon3d_project_db_count_images(f.db, f.scanset.scanset_id, &image_count) == LARDON3D_PROJECT_DB_OK && image_count == 1); + o.resume_capture_id = 0; + CHECK(lardon3d_acquisition_campaign_materialize_group(&f.state, f.scanset.scanset_id, s, 3, &p, 2, &o, &out, &ir) == LARDON3D_ACQUISITION_CAMPAIGN_OK); + CHECK(out.groups[0].capture_id != capture_id && captures(f) == 2); +} + +bool same_discovery(const Lardon3DAcquisitionCampaignDiscovery &a, const Lardon3DAcquisitionCampaignDiscovery &b) { + return a.source_count == b.source_count && !std::memcmp(&a.summary, &b.summary, sizeof(a.summary)) && + !std::memcmp(a.sources, b.sources, a.source_count * sizeof(a.sources[0])); +} + +void discovery_tests() { + char base[] = "/tmp/lardon3d-campaign-discovery-XXXXXX"; + CHECK(mkdtemp(base) != nullptr); + char left[4096]{}, right[4096]{}, nested[4096]{}, path[4096]{}, target[4096]{}; + CHECK(std::snprintf(left, sizeof(left), "%s/left", base) > 0); + CHECK(std::snprintf(right, sizeof(right), "%s/right", base) > 0); + CHECK(std::snprintf(nested, sizeof(nested), "%s/nested", left) > 0); + CHECK(mkdir(left, 0700) == 0 && mkdir(right, 0700) == 0 && mkdir(nested, 0700) == 0); + CHECK(std::snprintf(path, sizeof(path), "%s/good.jpeg", left) > 0 && jpeg(path, "valid-id")); + CHECK(std::snprintf(path, sizeof(path), "%s/bad.JPG", left) > 0 && empty_file(path)); + CHECK(std::snprintf(path, sizeof(path), "%s/raw.ARW", right) > 0 && empty_file(path)); + CHECK(std::snprintf(path, sizeof(path), "%s/other.jpg", right) > 0 && empty_file(path)); + CHECK(std::snprintf(path, sizeof(path), "%s/ignored.txt", left) > 0 && empty_file(path)); + CHECK(std::snprintf(path, sizeof(path), "%s/hidden.jpg", nested) > 0 && jpeg(path, "nested")); + CHECK(std::snprintf(target, sizeof(target), "%s/good.jpeg", left) > 0); + CHECK(std::snprintf(path, sizeof(path), "%s/link.jpg", left) > 0 && symlink(target, path) == 0); + CHECK(std::snprintf(path, sizeof(path), "%s/pipe.jpeg", right) > 0 && mkfifo(path, 0600) == 0); + + Lardon3DAcquisitionCampaignRoot roots[2]{}; + std::snprintf(roots[0].path, sizeof(roots[0].path), "%s", right); + std::snprintf(roots[1].path, sizeof(roots[1].path), "%s", left); + auto first = std::make_unique(); + auto second = std::make_unique(); + CHECK(lardon3d_acquisition_campaign_discover(roots, 2, first.get()) == + LARDON3D_ACQUISITION_CAMPAIGN_OK); + std::swap(roots[0], roots[1]); + CHECK(lardon3d_acquisition_campaign_discover(roots, 2, second.get()) == + LARDON3D_ACQUISITION_CAMPAIGN_OK); + CHECK(same_discovery(*first, *second)); + CHECK(first->source_count == 4 && first->summary.discovered_entry_count == 8 && + first->summary.supported_source_count == 4 && first->summary.unsupported_entry_count == 4); + for (size_t i = 1; i < first->source_count; ++i) + CHECK(std::strcmp(first->sources[i - 1].path, first->sources[i].path) < 0); + bool valid_jpeg = false, corrupt_jpeg = false; + for (size_t i = 0; i < first->source_count; ++i) { + if (std::strstr(first->sources[i].path, "/good.jpeg")) + valid_jpeg = first->sources[i].metadata_result == LARDON3D_ACQUISITION_OK; + if (std::strstr(first->sources[i].path, "/bad.JPG")) + corrupt_jpeg = first->sources[i].metadata_result != LARDON3D_ACQUISITION_OK; + CHECK(std::strstr(first->sources[i].path, "/nested/") == nullptr); + CHECK(std::strstr(first->sources[i].path, "/link.jpg") == nullptr); + CHECK(std::strstr(first->sources[i].path, "/pipe.jpeg") == nullptr); + } + CHECK(valid_jpeg && corrupt_jpeg); + + auto rejected = std::make_unique(); + auto reject = [&](const Lardon3DAcquisitionCampaignRoot *r, size_t count, + Lardon3DAcquisitionCampaignResult expected) { + std::memset(rejected.get(), 0xa5, sizeof(*rejected)); + CHECK(lardon3d_acquisition_campaign_discover(r, count, rejected.get()) == expected); + auto zero = std::make_unique(); + CHECK(std::memcmp(rejected.get(), zero.get(), sizeof(*zero)) == 0); + }; + Lardon3DAcquisitionCampaignRoot invalid{}; + std::snprintf(invalid.path, sizeof(invalid.path), "relative"); + reject(&invalid, 1, LARDON3D_ACQUISITION_CAMPAIGN_INVALID_ARGUMENT); + std::snprintf(invalid.path, sizeof(invalid.path), "%s/./left", base); + reject(&invalid, 1, LARDON3D_ACQUISITION_CAMPAIGN_INVALID_ARGUMENT); + std::snprintf(invalid.path, sizeof(invalid.path), "%s/../discovery", base); + reject(&invalid, 1, LARDON3D_ACQUISITION_CAMPAIGN_INVALID_ARGUMENT); + Lardon3DAcquisitionCampaignRoot duplicate[2]{}; + std::snprintf(duplicate[0].path, sizeof(duplicate[0].path), "%s", left); + duplicate[1] = duplicate[0]; + reject(duplicate, 2, LARDON3D_ACQUISITION_CAMPAIGN_DUPLICATE); + Lardon3DAcquisitionCampaignRoot too_many[LARDON3D_ACQUISITION_CAMPAIGN_MAX_ROOTS + 1]{}; + reject(too_many, LARDON3D_ACQUISITION_CAMPAIGN_MAX_ROOTS + 1, + LARDON3D_ACQUISITION_CAMPAIGN_INVALID_ARGUMENT); + + char limit_root[4096]{}; + CHECK(std::snprintf(limit_root, sizeof(limit_root), "%s/limit", base) > 0 && + mkdir(limit_root, 0700) == 0); + bool files_ok = true; + for (size_t i = 0; i <= LARDON3D_ACQUISITION_CAMPAIGN_MAX_SOURCES; ++i) { + int written = std::snprintf(path, sizeof(path), "%s/%04zu.jpg", limit_root, i); + if (written <= 0 || static_cast(written) >= sizeof(path) || !empty_file(path)) { + files_ok = false; + break; + } + } + CHECK(files_ok); + Lardon3DAcquisitionCampaignRoot limit{}; + std::snprintf(limit.path, sizeof(limit.path), "%s", limit_root); + reject(&limit, 1, LARDON3D_ACQUISITION_CAMPAIGN_LIMIT_EXCEEDED); + CHECK(remove_tree(base)); +} + +int dry_run(const char *a, const char *b) { + Lardon3DAcquisitionCampaignRoot roots[2]{}; std::snprintf(roots[0].path, sizeof(roots[0].path), "%s", a); + std::snprintf(roots[1].path, sizeof(roots[1].path), "%s", b); + auto d1 = std::make_unique(); auto d2 = std::make_unique(); + auto p1 = std::make_unique(); auto p2 = std::make_unique(); + auto result = lardon3d_acquisition_campaign_discover(roots, 2, d1.get()); + if (result != LARDON3D_ACQUISITION_CAMPAIGN_OK) { + std::fprintf(stderr, "CAMPAIGN_DRY_RUN_STAGE=DISCOVER_1\nCAMPAIGN_RESULT=%d\n", result); + return 2; + } + std::swap(roots[0], roots[1]); + result = lardon3d_acquisition_campaign_discover(roots, 2, d2.get()); + if (result != LARDON3D_ACQUISITION_CAMPAIGN_OK) { + std::fprintf(stderr, "CAMPAIGN_DRY_RUN_STAGE=DISCOVER_2\nCAMPAIGN_RESULT=%d\n", result); + return 3; + } + const auto plan_result_1 = lardon3d_acquisition_campaign_plan( + d1->sources, d1->source_count, nullptr, 0, p1.get()); + const auto plan_result_2 = lardon3d_acquisition_campaign_plan( + d2->sources, d2->source_count, nullptr, 0, p2.get()); + if (plan_result_1 != LARDON3D_ACQUISITION_CAMPAIGN_OK || + plan_result_2 != LARDON3D_ACQUISITION_CAMPAIGN_OK) { + std::fprintf(stderr, + "CAMPAIGN_DRY_RUN_STAGE=PLAN\nCAMPAIGN_PLAN_RESULT_1=%d\n" + "CAMPAIGN_PLAN_RESULT_2=%d\n", + plan_result_1, plan_result_2); + return 4; + } + size_t raw = 0, jpeg_count = 0, unavailable = 0; + size_t raw_ok = 0, raw_unavailable = 0, raw_error = 0; + size_t jpeg_ok = 0, jpeg_unavailable = 0, jpeg_error = 0; + for (size_t i = 0; i < d1->source_count; ++i) { + const bool is_raw = d1->sources[i].source_kind == LARDON3D_ACQUISITION_SOURCE_RAW; + raw += is_raw; + jpeg_count += !is_raw; + const bool is_ok = d1->sources[i].metadata_result == LARDON3D_ACQUISITION_OK; + const bool is_unavailable = + d1->sources[i].metadata_result == LARDON3D_ACQUISITION_METADATA_UNAVAILABLE; + unavailable += is_unavailable; + (is_raw ? raw_ok : jpeg_ok) += is_ok; + (is_raw ? raw_unavailable : jpeg_unavailable) += is_unavailable; + (is_raw ? raw_error : jpeg_error) += !is_ok && !is_unavailable; + } + bool deterministic = same_discovery(*d1, *d2) && !std::memcmp(p1.get(), p2.get(), sizeof(*p1)); + std::printf("CAMPAIGN_RAW_COUNT=%zu\nCAMPAIGN_JPEG_COUNT=%zu\nCAMPAIGN_TOTAL_SOURCE_COUNT=%zu\n", raw, jpeg_count, d1->source_count); + std::printf("METADATA_OK=%zu\nMETADATA_UNAVAILABLE=%zu\nMETADATA_ERROR=%zu\n", d1->summary.metadata_ok_count, unavailable, d1->summary.metadata_error_count - unavailable); + std::printf("RAW_METADATA_OK=%zu\nRAW_METADATA_UNAVAILABLE=%zu\nRAW_METADATA_ERROR=%zu\n", + raw_ok, raw_unavailable, raw_error); + std::printf("JPEG_METADATA_OK=%zu\nJPEG_METADATA_UNAVAILABLE=%zu\nJPEG_METADATA_ERROR=%zu\n", + jpeg_ok, jpeg_unavailable, jpeg_error); + std::printf("STRONG_GROUPS=%zu\nCANDIDATE_PAIRS=%zu\nAMBIGUOUS_PAIRS=%zu\nCONTRADICTORY_PAIRS=%zu\nINSUFFICIENT_PAIRS=%zu\nUNPAIRED_SOURCES=%zu\n", + p1->summary.strong_group_count, p1->summary.candidate_pair_count, p1->summary.ambiguous_pair_count, + p1->summary.contradictory_pair_count, p1->summary.insufficient_pair_count, p1->summary.unresolved_source_count); + /* With no confirmations this is the automatic plan's strong groups plus singletons. */ + std::printf("PROPOSED_PHYSICAL_CAPTURE_COUNT=%zu\nCAMPAIGN_PLAN_DETERMINISTIC=%s\n", p1->group_count, deterministic ? "PASS" : "FAIL"); + if (!deterministic) std::fprintf(stderr, "CAMPAIGN_DRY_RUN_STAGE=DETERMINISM\nCAMPAIGN_RESULT=FAIL\n"); + return deterministic ? 0 : 5; +} +} // namespace + +int main(int argc, char **argv) { + if (argc == 4 && !std::strcmp(argv[1], "--dry-run")) return dry_run(argv[2], argv[3]); + if (argc != 1) { std::fprintf(stderr, "usage: %s [--dry-run ROOT1 ROOT2]\n", argv[0]); return 64; } + discovery_tests(); planner_tests(); materialization_tests(); return failures ? 1 : 0; +} diff --git a/tests/test_acquisition_pairing.cpp b/tests/test_acquisition_pairing.cpp index a7343fa..2c1263a 100644 --- a/tests/test_acquisition_pairing.cpp +++ b/tests/test_acquisition_pairing.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include namespace { @@ -213,6 +214,40 @@ void write_exif_jpeg(const std::string &path) { exif_data_unref(data); } +void append_bytes(const std::string &path, const unsigned char *bytes, size_t size) { + FILE *file = std::fopen(path.c_str(), "ab"); + assert(file != nullptr); + assert(std::fwrite(bytes, 1u, size, file) == size); + assert(std::fclose(file) == 0); +} + +void write_bytes(const std::string &path, const unsigned char *bytes, size_t size) { + FILE *file = std::fopen(path.c_str(), "wb"); + assert(file != nullptr); + assert(std::fwrite(bytes, 1u, size, file) == size); + assert(std::fclose(file) == 0); +} + +void assert_jpeg_corrupt(const unsigned char *bytes, size_t size) { + const std::string path = temporary_path(".jpg"); + write_bytes(path, bytes, size); + Lardon3DAcquisitionMetadata value = {}; + assert(lardon3d_acquisition_extract_metadata(path.c_str(), &value) == + LARDON3D_ACQUISITION_CORRUPT_SOURCE); + assert(unlink(path.c_str()) == 0); +} + +void assert_jpeg_structurally_accepted(const unsigned char *bytes, size_t size) { + const std::string path = temporary_path(".jpg"); + write_bytes(path, bytes, size); + Lardon3DAcquisitionMetadata value = {}; + const Lardon3DAcquisitionResult result = + lardon3d_acquisition_extract_metadata(path.c_str(), &value); + assert(result == LARDON3D_ACQUISITION_OK || + result == LARDON3D_ACQUISITION_METADATA_UNAVAILABLE); + assert(unlink(path.c_str()) == 0); +} + void test_extraction() { const std::string jpeg = temporary_path(".jpg"); write_exif_jpeg(jpeg); @@ -229,6 +264,159 @@ void test_extraction() { assert(std::strcmp(value.image_unique_id, "IMAGE-99") == 0); assert(unlink(jpeg.c_str()) == 0); + const std::string padded = temporary_path(".jpg"); + write_exif_jpeg(padded); + const unsigned char zero_padding[37] = {}; + append_bytes(padded, zero_padding, sizeof(zero_padding)); + assert(lardon3d_acquisition_extract_metadata(padded.c_str(), &value) == + LARDON3D_ACQUISITION_OK); + assert(value.source_kind == LARDON3D_ACQUISITION_SOURCE_JPEG); + assert(std::strcmp(value.make, "Sony") == 0); + assert(std::strcmp(value.model, "ILCE-7M4") == 0); + assert(std::strcmp(value.datetime_original, "2026:08:27 12:34:56") == 0); + assert(std::strcmp(value.subsec_original, "123") == 0); + assert(std::strcmp(value.offset_original, "+02:00") == 0); + assert(std::strcmp(value.body_serial, "BODY-42") == 0); + assert(std::strcmp(value.image_unique_id, "IMAGE-99") == 0); + assert(unlink(padded.c_str()) == 0); + + const unsigned char missing_eoi[] = {0xffu, 0xd8u, 0xffu, 0xe0u, + 0x00u, 0x02u}; + assert_jpeg_corrupt(missing_eoi, sizeof(missing_eoi)); + + const unsigned char truncated_segment[] = {0xffu, 0xd8u, 0xffu, 0xe1u, + 0x00u, 0x08u, 0x11u}; + assert_jpeg_corrupt(truncated_segment, sizeof(truncated_segment)); + + const unsigned char payload_eoi[] = {0xffu, 0xd8u, 0xffu, 0xe2u, 0x00u, + 0x06u, 0xffu, 0xd9u, 0x11u, 0x22u}; + assert_jpeg_corrupt(payload_eoi, sizeof(payload_eoi)); + + const std::string trailing_garbage = temporary_path(".jpg"); + write_exif_jpeg(trailing_garbage); + const unsigned char nonzero_padding[] = {0x00u, 0x01u}; + append_bytes(trailing_garbage, nonzero_padding, sizeof(nonzero_padding)); + assert(lardon3d_acquisition_extract_metadata(trailing_garbage.c_str(), &value) == + LARDON3D_ACQUISITION_CORRUPT_SOURCE); + assert(unlink(trailing_garbage.c_str()) == 0); + + const unsigned char entropy_markers[] = { + 0xffu, 0xd8u, 0xffu, 0xdau, 0x00u, 0x08u, 0x01u, 0x01u, 0x00u, 0x00u, + 0x3fu, 0x00u, 0x12u, 0xffu, 0x00u, 0x34u, 0xffu, 0xd0u, 0x56u, 0xffu, + 0xffu, 0xd9u}; + const std::string entropy = temporary_path(".jpg"); + write_bytes(entropy, entropy_markers, sizeof(entropy_markers)); + const Lardon3DAcquisitionResult entropy_result = + lardon3d_acquisition_extract_metadata(entropy.c_str(), &value); + assert(entropy_result == LARDON3D_ACQUISITION_OK || + entropy_result == LARDON3D_ACQUISITION_METADATA_UNAVAILABLE); + assert(unlink(entropy.c_str()) == 0); + + const unsigned char mismatched_sos_length[] = { + 0xffu, 0xd8u, 0xffu, 0xdau, 0x00u, 0x06u, 0x01u, 0x01u, + 0x00u, 0x00u, 0xffu, 0xd9u}; + assert_jpeg_corrupt(mismatched_sos_length, sizeof(mismatched_sos_length)); + + const unsigned char zero_sos_components[] = { + 0xffu, 0xd8u, 0xffu, 0xdau, 0x00u, 0x06u, 0x00u, 0x00u, + 0x00u, 0x00u, 0xffu, 0xd9u}; + assert_jpeg_corrupt(zero_sos_components, sizeof(zero_sos_components)); + + const unsigned char too_many_sos_components[] = { + 0xffu, 0xd8u, 0xffu, 0xdau, 0x00u, 0x10u, 0x05u, 0x01u, 0x00u, + 0x02u, 0x00u, 0x03u, 0x00u, 0x04u, 0x00u, 0x05u, 0x00u, 0x00u, + 0x3fu, 0x00u, 0xffu, 0xd9u}; + assert_jpeg_corrupt(too_many_sos_components, sizeof(too_many_sos_components)); + + const unsigned char entropy_dnl[] = { + 0xffu, 0xd8u, 0xffu, 0xdau, 0x00u, 0x08u, 0x01u, 0x01u, 0x00u, + 0x00u, 0x3fu, 0x00u, 0x12u, 0xffu, 0xdcu, 0x00u, 0x04u, 0x00u, + 0x2au, 0x34u, 0xffu, 0x00u, 0x56u, 0xffu, 0xd1u, 0x78u, 0xffu, + 0xd9u}; + const std::string entropy_dnl_path = temporary_path(".jpg"); + write_bytes(entropy_dnl_path, entropy_dnl, sizeof(entropy_dnl)); + const Lardon3DAcquisitionResult entropy_dnl_result = + lardon3d_acquisition_extract_metadata(entropy_dnl_path.c_str(), &value); + assert(entropy_dnl_result == LARDON3D_ACQUISITION_OK || + entropy_dnl_result == LARDON3D_ACQUISITION_METADATA_UNAVAILABLE); + assert(unlink(entropy_dnl_path.c_str()) == 0); + + const unsigned char malformed_dnl_length[] = { + 0xffu, 0xd8u, 0xffu, 0xdcu, 0x00u, 0x02u, 0xffu, 0xd9u}; + assert_jpeg_corrupt(malformed_dnl_length, sizeof(malformed_dnl_length)); + + const unsigned char zero_dnl_line_count[] = { + 0xffu, 0xd8u, 0xffu, 0xdcu, 0x00u, 0x04u, 0x00u, 0x00u, 0xffu, 0xd9u}; + assert_jpeg_corrupt(zero_dnl_line_count, sizeof(zero_dnl_line_count)); + + const unsigned char marker_level_dnl_does_not_start_entropy[] = { + 0xffu, 0xd8u, 0xffu, 0xdcu, 0x00u, 0x04u, 0x00u, 0x2au, + 0x12u, 0xffu, 0xd9u}; + assert_jpeg_corrupt(marker_level_dnl_does_not_start_entropy, + sizeof(marker_level_dnl_does_not_start_entropy)); + + const unsigned char mpf_primary[] = { + 0xffu, 0xd8u, 0xffu, 0xe2u, 0x00u, 0x06u, 'M', 'P', 'F', 0x00u, + 0xffu, 0xd9u}; + const unsigned char ordinary_image[] = {0xffu, 0xd8u, 0xffu, 0xd9u}; + std::vector mpf_two_images(std::begin(mpf_primary), std::end(mpf_primary)); + mpf_two_images.insert(mpf_two_images.end(), 9u, 0u); + mpf_two_images.insert(mpf_two_images.end(), std::begin(ordinary_image), + std::end(ordinary_image)); + mpf_two_images.insert(mpf_two_images.end(), 11u, 0u); + assert_jpeg_structurally_accepted(mpf_two_images.data(), mpf_two_images.size()); + + std::vector no_mpf_second(std::begin(ordinary_image), + std::end(ordinary_image)); + no_mpf_second.insert(no_mpf_second.end(), 3u, 0u); + no_mpf_second.insert(no_mpf_second.end(), std::begin(ordinary_image), + std::end(ordinary_image)); + assert_jpeg_corrupt(no_mpf_second.data(), no_mpf_second.size()); + + std::vector mpf_garbage(std::begin(mpf_primary), std::end(mpf_primary)); + mpf_garbage.insert(mpf_garbage.end(), 5u, 0u); + mpf_garbage.push_back(0x42u); + assert_jpeg_corrupt(mpf_garbage.data(), mpf_garbage.size()); + + std::vector mpf_truncated(std::begin(mpf_primary), std::end(mpf_primary)); + const unsigned char truncated_second[] = {0xffu, 0xd8u, 0xffu, 0xe0u, 0x00u}; + mpf_truncated.insert(mpf_truncated.end(), std::begin(truncated_second), + std::end(truncated_second)); + assert_jpeg_corrupt(mpf_truncated.data(), mpf_truncated.size()); + + std::vector mpf_missing_second_eoi(std::begin(mpf_primary), + std::end(mpf_primary)); + const unsigned char second_without_eoi[] = {0xffu, 0xd8u, 0xffu, 0xe0u, 0x00u, 0x02u}; + mpf_missing_second_eoi.insert(mpf_missing_second_eoi.end(), + std::begin(second_without_eoi), + std::end(second_without_eoi)); + assert_jpeg_corrupt(mpf_missing_second_eoi.data(), mpf_missing_second_eoi.size()); + + std::vector too_many_images(std::begin(mpf_primary), + std::end(mpf_primary)); + for (size_t index = 1u; index < 9u; ++index) { + too_many_images.insert(too_many_images.end(), std::begin(ordinary_image), + std::end(ordinary_image)); + } + assert_jpeg_corrupt(too_many_images.data(), too_many_images.size()); + + const unsigned char embedded_boundaries_in_payload[] = { + 0xffu, 0xd8u, 0xffu, 0xe1u, 0x00u, 0x06u, 0xffu, 0xd8u, 0xffu, 0xd9u, + 0xffu, 0xd9u}; + assert_jpeg_structurally_accepted(embedded_boundaries_in_payload, + sizeof(embedded_boundaries_in_payload)); + + const unsigned char stuffed_boundaries_in_entropy[] = { + 0xffu, 0xd8u, 0xffu, 0xdau, 0x00u, 0x08u, 0x01u, 0x01u, 0x00u, + 0x00u, 0x3fu, 0x00u, 0xffu, 0x00u, 0xd8u, 0xffu, 0x00u, 0xd9u, + 0xffu, 0xd9u}; + assert_jpeg_structurally_accepted(stuffed_boundaries_in_entropy, + sizeof(stuffed_boundaries_in_entropy)); + + std::vector mpf_nonzero_final = mpf_two_images; + mpf_nonzero_final.push_back(0x01u); + assert_jpeg_corrupt(mpf_nonzero_final.data(), mpf_nonzero_final.size()); + const std::string malformed = temporary_path(".jpg"); FILE *file = std::fopen(malformed.c_str(), "wb"); assert(file != nullptr); @@ -279,7 +467,27 @@ void test_non_terminated_raw_fixed_text_mapping() { } // namespace -int main() { +int main(int argc, char **argv) { + if (argc == 3 && std::strcmp(argv[1], "--extract") == 0) { + Lardon3DAcquisitionMetadata value = {}; + const Lardon3DAcquisitionResult result = + lardon3d_acquisition_extract_metadata(argv[2], &value); + std::printf("METADATA_RESULT=%d\n", static_cast(result)); + if (result == LARDON3D_ACQUISITION_OK) { + std::printf("MAKE=%s\nMODEL=%s\nDATETIME_ORIGINAL=%s\nSUBSEC_ORIGINAL=%s\n" + "BODY_SERIAL=%s\nIMAGE_UNIQUE_ID=%s\nWIDTH=%u\nHEIGHT=%u\n" + "ORIENTATION=%u\nPRESENT_FIELDS=%u\n", + value.make, value.model, value.datetime_original, + value.subsec_original, value.body_serial, value.image_unique_id, + value.width, value.height, static_cast(value.orientation), + value.present_fields); + } + return result == LARDON3D_ACQUISITION_OK ? 0 : 1; + } + if (argc != 1) { + std::fprintf(stderr, "usage: %s [--extract PATH]\n", argv[0]); + return 64; + } test_pairing_policy(); test_selector(); test_non_terminated_public_text_is_unavailable();