| // Copyright 2026 Google LLC | |
| // | |
| // Licensed under the Apache License, Version 2.0 (the "License"); | |
| // you may not use this file except in compliance with the License. | |
| // You may obtain a copy of the License at | |
| // | |
| // https://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, software | |
| // distributed under the License is distributed on an "AS IS" BASIS, | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| // See the License for the specific language governing permissions and | |
| // limitations under the License. | |
| // | |
| #include "formats/jpeg/hasher.h" | |
| #include <algorithm> | |
| #include <cstdint> | |
| #include <memory> | |
| #include <optional> | |
| #include <string> | |
| #include <utility> | |
| #include <vector> | |
| #include "absl/container/flat_hash_set.h" | |
| #include "absl/status/status.h" | |
| #include "absl/status/status_macros.h" | |
| #include "absl/status/statusor.h" | |
| #include "absl/strings/match.h" | |
| #include "absl/strings/str_cat.h" | |
| #include "absl/strings/string_view.h" | |
| #include "bindings/input_hasher.h" | |
| #include "crypto/algorithms.h" | |
| #include "formats/byte_range.h" | |
| #include "formats/jpeg/assessor.h" | |
| #include "formats/jpeg/c2pa_segment_handler.h" | |
| #include "formats/jpeg/reader.h" | |
| #include "proto/data_hash_assertion.pb.h" | |
| #include "proto/generator.pb.h" | |
| #include "proto/multi_asset_hash_assertion.pb.h" | |
| #include "riegeli/bytes/reader.h" | |
| namespace credentio { | |
| namespace { | |
| constexpr uint64_t kMaxChunkSize = 1024 * 1024; // 1 MiB | |
| // Updates the given hashers with the indicated content, leaving the input | |
| // position at the end of the content. | |
| absl::Status UpdateTwoHashers(riegeli::Reader& input, int64_t offset, | |
| int64_t length, InputHasher& hasher1, | |
| InputHasher& hasher2) { | |
| if (!input.Seek(offset)) { | |
| return absl::InvalidArgumentError( | |
| absl::StrCat("Failed to seek to offset ", offset)); | |
| } | |
| int64_t chunk_size = kMaxChunkSize; | |
| for (int64_t i = 0; i < length; i += chunk_size) { | |
| int64_t l = std::min(length - i, chunk_size); | |
| std::string data; | |
| if (!input.Read(l, data)) { | |
| return absl::InvalidArgumentError("Failed to read data"); | |
| } | |
| hasher1.Update(data); | |
| hasher2.Update(data); | |
| } | |
| return absl::OkStatus(); | |
| } | |
| // Returns true if the current box hash range should end based on the next | |
| // segment header and whether a C2PA segment needs to be injected. | |
| bool InsertC2paBeforeSegment(absl::string_view next_segment_label) { | |
| if (next_segment_label == "SOI") { | |
| return false; | |
| } | |
| if (!absl::StartsWith(next_segment_label, "APP")) { | |
| // Inject before any non-APP segments. | |
| return true; | |
| } | |
| if (next_segment_label == "APP2") { | |
| // Inject before the APP2 segment. | |
| return true; | |
| } | |
| if (next_segment_label == "APP11") { | |
| // Inject before any existing APP11 segments. | |
| return true; | |
| } | |
| return false; | |
| } | |
| absl::StatusOr<uint16_t> GetUnusedSegmentId( | |
| const absl::flat_hash_set<uint16_t>& used_segment_ids) { | |
| if (used_segment_ids.size() == UINT16_MAX) { | |
| return absl::InvalidArgumentError("No Available Segment ID"); | |
| } | |
| uint16_t next_segment_id = 1; | |
| while (used_segment_ids.contains(next_segment_id)) { | |
| ++next_segment_id; | |
| } | |
| return next_segment_id; | |
| } | |
| // Computes data hash(es) for a JPEG asset (which may be a part of a multi-part | |
| // asset). If the starting input position is 0, then | |
| // * a placeholder exclusion will be added for the C2PA segment | |
| // * the overall file hash will be computed (result.hard_binding.data_hash) | |
| // In all cases, a part hash will be computed, covering the asset starting at | |
| // the current input position and ending at the next EOI marker. | |
| // | |
| // If `exclusion_processor` is provided, it is invoked for each segment to | |
| // determine byte ranges to exclude from the hashes. | |
| absl::Status HashJpeg(riegeli::Reader& input, HashAlgorithm alg, | |
| std::optional<JpegExclusionProcessor> exclusion_processor, | |
| InputHasher& asset_hasher, DigestJpegResult& result) { | |
| int64_t asset_start_offset = input.pos(); | |
| bool is_first_asset = asset_start_offset == 0; | |
| C2paSegmentHandler c2pa_segment_handler; | |
| absl::flat_hash_set<uint16_t> used_segment_ids; | |
| std::optional<JpegInsertionPoint> insertion_point; | |
| ABSL_ASSIGN_OR_RETURN(auto part_hasher, InputHasher::Create(alg)); | |
| PartBinding* part_binding = result.hard_binding.add_part_bindings(); | |
| part_binding->mutable_location()->set_byte_offset(asset_start_offset); | |
| part_binding->set_is_optional(!is_first_asset); | |
| part_binding->mutable_data_hash()->set_alg(absl::StrCat(alg)); | |
| std::optional<JpegSegment> data_past_asset = std::nullopt; | |
| ABSL_RETURN_IF_ERROR(IterateOverJpegSegments( | |
| input, [&](const JpegSegment& segment) -> absl::StatusOr<bool> { | |
| if (segment.marker_label == "c2pa.after") { | |
| data_past_asset = std::move(segment); | |
| return false; // stop iteration | |
| } | |
| if (segment.app11_info.has_value()) { | |
| used_segment_ids.insert(segment.app11_info->segment_id); | |
| } | |
| if (is_first_asset && !insertion_point.has_value() && | |
| InsertC2paBeforeSegment(segment.marker_label)) { | |
| // Add an exclusion placeholder for the C2PA segment. Length will be | |
| // filled in during encoding. | |
| auto* asset_exclusion = | |
| result.hard_binding.mutable_data_hash()->add_exclusions(); | |
| asset_exclusion->set_start(segment.offset); | |
| asset_exclusion->set_length(0); | |
| auto* part_exclusion = | |
| part_binding->mutable_data_hash()->add_exclusions(); | |
| part_exclusion->set_start(segment.offset - asset_start_offset); | |
| part_exclusion->set_length(0); | |
| insertion_point = JpegInsertionPoint{ | |
| .segment_id = 1, // to be updated with the next available ID | |
| .offset = segment.offset, | |
| }; | |
| } | |
| std::vector<ByteRange> exclusions; | |
| if (exclusion_processor.has_value()) { | |
| ABSL_ASSIGN_OR_RETURN(exclusions, | |
| (*exclusion_processor)(input, segment)); | |
| } | |
| int64_t offset = segment.offset; | |
| for (ByteRange exclusion : exclusions) { | |
| uint64_t segment_end = segment.offset + segment.length; | |
| if (exclusion.offset < offset || exclusion.offset > segment_end || | |
| exclusion.length > segment_end - exclusion.offset) { | |
| return absl::InvalidArgumentError(absl::StrCat( | |
| "Invalid or overlapping exclusion range [", exclusion.offset, | |
| ", ", exclusion.offset + exclusion.length, ") for segment at ", | |
| segment.offset)); | |
| } | |
| auto* asset_exclusion = | |
| result.hard_binding.mutable_data_hash()->add_exclusions(); | |
| asset_exclusion->set_start(exclusion.offset); | |
| asset_exclusion->set_length(exclusion.length); | |
| auto* part_exclusion = | |
| part_binding->mutable_data_hash()->add_exclusions(); | |
| part_exclusion->set_start(exclusion.offset - asset_start_offset); | |
| part_exclusion->set_length(exclusion.length); | |
| ABSL_RETURN_IF_ERROR(UpdateTwoHashers(input, offset, | |
| exclusion.offset - offset, | |
| asset_hasher, *part_hasher)); | |
| offset = exclusion.offset + exclusion.length; | |
| } | |
| if (offset < (segment.offset + segment.length)) { | |
| ABSL_RETURN_IF_ERROR(UpdateTwoHashers( | |
| input, offset, segment.offset + segment.length - offset, | |
| asset_hasher, *part_hasher)); | |
| } | |
| c2pa_segment_handler.RecordSegment(segment); | |
| return true; | |
| })); | |
| if (is_first_asset) { | |
| absl::Status c2pa_segment_status = | |
| c2pa_segment_handler.GetC2paSegment(input).status(); | |
| if (c2pa_segment_status.ok() || | |
| absl::StrContains(c2pa_segment_status.message(), "multiple")) { | |
| return absl::AlreadyExistsError("C2PA chunk already exists"); | |
| } | |
| if (!insertion_point.has_value()) { | |
| return absl::InvalidArgumentError( | |
| "No valid C2PA insertion point found in JPEG"); | |
| } | |
| ABSL_ASSIGN_OR_RETURN(uint16_t next_segment_id, | |
| GetUnusedSegmentId(used_segment_ids)); | |
| insertion_point->segment_id = next_segment_id; | |
| result.insertion_point = std::move(*insertion_point); | |
| } | |
| if (data_past_asset.has_value()) { | |
| if (!input.Seek(data_past_asset->offset)) { | |
| return absl::InvalidArgumentError("Failed to seek to data past asset"); | |
| } | |
| } | |
| part_binding->mutable_location()->set_length(input.pos() - | |
| asset_start_offset); | |
| part_binding->mutable_data_hash()->set_hash(part_hasher->Digest()); | |
| return absl::OkStatus(); | |
| } | |
| // Adds a part hash covering the remaining bytes of the input, marked as | |
| // optional. | |
| absl::Status HashRemainingBytes(riegeli::Reader& input, HashAlgorithm alg, | |
| InputHasher& asset_hasher, | |
| DigestJpegResult& result) { | |
| if (!input.SupportsSize() || !input.Size().has_value()) { | |
| return absl::InvalidArgumentError("Failed to get size of input"); | |
| } | |
| int64_t asset_start_offset = input.pos(); | |
| int64_t asset_length = *input.Size() - asset_start_offset; | |
| ABSL_ASSIGN_OR_RETURN(auto part_hasher, InputHasher::Create(alg)); | |
| ABSL_RETURN_IF_ERROR(UpdateTwoHashers(input, asset_start_offset, asset_length, | |
| asset_hasher, *part_hasher)); | |
| auto* part_binding = result.hard_binding.add_part_bindings(); | |
| part_binding->mutable_location()->set_byte_offset(asset_start_offset); | |
| part_binding->mutable_location()->set_length(asset_length); | |
| part_binding->set_is_optional(true); | |
| part_binding->mutable_data_hash()->set_alg(absl::StrCat(alg)); | |
| part_binding->mutable_data_hash()->set_hash(part_hasher->Digest()); | |
| return absl::OkStatus(); | |
| } | |
| } // namespace | |
| absl::StatusOr<DigestJpegResult> DigestJpeg( | |
| riegeli::Reader& input, HashAlgorithm alg, bool generate_multi_asset_hash, | |
| std::optional<JpegExclusionProcessor> exclusion_processor) { | |
| ABSL_ASSIGN_OR_RETURN(auto correct_format, JpegAssessor().IsSupported(input)); | |
| if (!correct_format) { | |
| return absl::InvalidArgumentError("File does not start with a valid JPEG"); | |
| } | |
| if (!input.Seek(0)) { | |
| return absl::InvalidArgumentError("Failed to seek to start of input"); | |
| } | |
| if (!input.SupportsSize() || !input.Size().has_value()) { | |
| return absl::InvalidArgumentError("Failed to get size of input"); | |
| } | |
| DigestJpegResult result = {}; | |
| result.hard_binding.mutable_data_hash()->set_alg(absl::StrCat(alg)); | |
| uint64_t input_size = *input.Size(); | |
| ABSL_ASSIGN_OR_RETURN(auto asset_hasher, InputHasher::Create(alg)); | |
| int64_t pos; | |
| do { | |
| pos = input.pos(); | |
| ABSL_ASSIGN_OR_RETURN(bool is_jpeg, JpegAssessor().IsSupported(input)); | |
| if (is_jpeg) { | |
| ABSL_RETURN_IF_ERROR( | |
| HashJpeg(input, alg, exclusion_processor, *asset_hasher, result)); | |
| } else { | |
| ABSL_RETURN_IF_ERROR( | |
| HashRemainingBytes(input, alg, *asset_hasher, result)); | |
| } | |
| if (!generate_multi_asset_hash) { | |
| break; | |
| } | |
| int64_t newpos = input.pos(); | |
| if (pos == newpos && pos < input_size) { | |
| // Avoid infinite loop in case of bugs. | |
| return absl::InternalError("Hashing made no progress"); | |
| } | |
| pos = newpos; | |
| } while (pos < input_size); | |
| if (!generate_multi_asset_hash && input.pos() < input_size) { | |
| ABSL_RETURN_IF_ERROR( | |
| asset_hasher->Update(input, input.pos(), input_size - input.pos())); | |
| } | |
| result.hard_binding.mutable_data_hash()->set_hash(asset_hasher->Digest()); | |
| if (!generate_multi_asset_hash || | |
| result.hard_binding.part_bindings_size() == 1) { | |
| // Only a single asset was found, no need for multi-asset hash assertion. | |
| result.hard_binding.clear_part_bindings(); | |
| } | |
| return result; | |
| } | |
| } // namespace credentio |