| // 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 "assertion/actions_assertion_validator.h" |
| |
| #include <string> |
| |
| #include "absl/container/flat_hash_map.h" |
| #include "absl/container/flat_hash_set.h" |
| #include "absl/strings/str_cat.h" |
| #include "absl/strings/str_format.h" |
| #include "absl/strings/string_view.h" |
| #include "constants/ingredient_relationships.h" |
| #include "constants/labels.h" |
| #include "constants/status_codes.h" |
| #include "proto/actions_assertion.pb.h" |
| #include "proto/assertion.pb.h" |
| #include "proto/hashed_uri.pb.h" |
| #include "proto/ingredient_assertion.pb.h" |
| #include "proto/manifest.pb.h" |
| #include "validator/tracker.h" |
| |
| namespace credentio { |
| |
| namespace { |
| |
| absl::string_view Basename(absl::string_view path) { |
| return path.substr(path.find_last_of('/') + 1); |
| } |
| |
| bool RequiresParentOfRelationship(absl::string_view action) { |
| return action == "c2pa.opened" || action == "c2pa.repackaged" || |
| action == "c2pa.transcoded"; |
| } |
| |
| bool RequiresComponentOfRelationship(absl::string_view action) { |
| return action == "c2pa.placed" || action == "c2pa.removed"; |
| } |
| |
| bool RequiresIngredientValidation(absl::string_view action) { |
| return RequiresParentOfRelationship(action) || |
| RequiresComponentOfRelationship(action); |
| } |
| |
| template <class IngredientAssertionType> |
| bool ValidateIngredientRelationship(absl::string_view action, |
| const IngredientAssertionType& ingredient, |
| absl::string_view assertion_url, |
| ValidationTracker& validation_tracker) { |
| // 3.c.ii.A. Check that the ingredient relationship is parentOf. |
| if (RequiresParentOfRelationship(action)) { |
| if (ingredient.relationship() != kIngredientRelationshipParentOf) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kAssertionActionIngredientMismatch, |
| {.url = assertion_url, |
| .explanation = absl::StrFormat( |
| "action %s has wrong ingredient relationship: %s; want %s", |
| action, ingredient.relationship(), |
| kIngredientRelationshipParentOf)}); |
| return false; |
| } |
| } |
| // 3.c.ii.B. Check that the ingredient relationship is componentOf. |
| if (RequiresComponentOfRelationship(action)) { |
| if (ingredient.relationship() != kIngredientRelationshipComponentOf) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kAssertionActionIngredientMismatch, |
| {.url = assertion_url, |
| .explanation = absl::StrFormat( |
| "action %s has wrong ingredient relationship: %s; " |
| "want %s", |
| action, ingredient.relationship(), |
| kIngredientRelationshipComponentOf)}); |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| // See |
| // https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#_c2pa_actions_validation. |
| template <class ActionsAssertionType> |
| bool ValidateActions( |
| const ActionsAssertionType& assertion, |
| absl::flat_hash_map<std::string, const Assertion*> assertion_store, |
| ValidationTracker& validation_tracker) { |
| // 1. Ensure that it has an actions field. |
| if (assertion.actions().empty()) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kAssertionActionMalformed, {}); |
| return false; |
| } |
| for (auto i = 0; i < assertion.actions().size(); ++i) { |
| const auto& action = assertion.actions(i); |
| // 3.a. Ensure that it has an action field. |
| if (action.action().empty()) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kAssertionActionMalformed, |
| {.explanation = "empty action"}); |
| return false; |
| } |
| // 3.b. If the action field is c2pa.created or c2pa.opened, it must be the |
| // first action. |
| if (action.action() == "c2pa.created" || action.action() == "c2pa.opened") { |
| if (i != 0) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kAssertionActionMalformed, |
| {.explanation = "created/opened not first action"}); |
| return false; |
| } |
| } |
| // 3.c. Check ingredient for certain actions. |
| if (RequiresIngredientValidation(action.action())) { |
| // 3.c.i. Check ingredients are present. |
| if (!action.has_parameters()) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kAssertionActionIngredientMismatch, |
| {.explanation = absl::StrFormat("action %s missing parameters", |
| action.action())}); |
| return false; |
| } |
| if (action.parameters().ingredients().size() == 0) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kAssertionActionIngredientMismatch, |
| {.explanation = absl::StrFormat("action %s missing ingredients", |
| action.action())}); |
| return false; |
| } |
| // 3.c.ii. For each ingredient, check that it has a url and it is valid. |
| for (const auto& ingredient : action.parameters().ingredients()) { |
| if (!ingredient.has_url() || ingredient.url().empty()) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kAssertionActionIngredientMismatch, |
| {.explanation = absl::StrFormat( |
| "action %s missing ingredient url", action.action())}); |
| return false; |
| } |
| auto label = Basename(ingredient.url()); |
| if (!assertion_store.contains(label)) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kAssertionActionIngredientMismatch, |
| {.explanation = absl::StrFormat( |
| "action %s missing ingredient assertion", action.action())}); |
| return false; |
| } |
| auto* assertion = assertion_store[label]; |
| std::string assertion_url = absl::StrCat( |
| kAssertionStoreLabel, kManifestLabelDelimiter, assertion->label()); |
| if (assertion->has_ingredient_v3()) { |
| if (!ValidateIngredientRelationship( |
| action.action(), assertion->ingredient_v3(), assertion_url, |
| validation_tracker)) { |
| return false; |
| } |
| } else { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kAssertionActionIngredientMismatch, |
| {.url = assertion_url, |
| .explanation = absl::StrFormat( |
| "action %s missing ingredient assertion", action.action())}); |
| return false; |
| } |
| } |
| } |
| // 3.d. If the action is c2pa.redacted, check that the redacted field that |
| // is a member of the parameters object for the presence of a JUMBF URI and |
| // that the URI resolves to an assertion. |
| if (action.action() == "c2pa.redacted") { |
| if (action.parameters().redacted_uri().empty()) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kAssertionActionRedactionMismatch, |
| {.explanation = absl::StrFormat( |
| "action %s missing redacted in parameters", action.action())}); |
| return false; |
| } |
| auto redacted_label = Basename(action.parameters().redacted_uri()); |
| if (!assertion_store.contains(redacted_label)) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kAssertionActionRedactionMismatch, |
| {.explanation = absl::StrFormat( |
| "action %s missing redacted assertion", action.action())}); |
| return false; |
| } |
| } |
| } |
| return true; |
| } |
| |
| // 2. Check the first action in each actions assertion. |
| template <class ActionsAssertionType> |
| bool ValidateFirstAction(absl::string_view label, |
| const ActionsAssertionType& assertion, |
| bool is_first_action_assertion, |
| ValidationTracker& validation_tracker) { |
| bool is_created_or_opened = |
| assertion.actions_size() > 0 && |
| (assertion.actions(0).action() == "c2pa.created" || |
| assertion.actions(0).action() == "c2pa.opened"); |
| if (is_first_action_assertion && !is_created_or_opened) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kAssertionActionMalformed, |
| {.explanation = "the first action is not c2pa.created or c2pa.opened"}); |
| return false; |
| } |
| if (is_created_or_opened && !is_first_action_assertion) { |
| validation_tracker.RecordFailure( |
| FailureStatusCode::kAssertionActionMalformed, |
| {.explanation = "c2pa.created or c2pa.opened is not the first action"}); |
| return false; |
| } |
| return true; |
| } |
| |
| } // namespace |
| |
| bool ActionsAssertionValidator::Validate( |
| const Manifest& manifest, ValidationTracker& validation_tracker) const { |
| absl::flat_hash_map<std::string, const Assertion*> assertion_store; |
| for (const auto& assertion : manifest.assertions()) { |
| assertion_store[assertion.label()] = &assertion; |
| } |
| absl::flat_hash_set<std::string> created_assertions; |
| created_assertions.reserve(manifest.claim().created_assertions_size()); |
| for (const auto& assertion : manifest.claim().created_assertions()) { |
| created_assertions.insert(std::string(Basename(assertion.url()))); |
| } |
| bool is_first_action_assertion = true; |
| auto validate_actions_assertion = [&](absl::string_view label, |
| const auto& actions_assertion) -> bool { |
| if (!ValidateActions(actions_assertion, assertion_store, |
| validation_tracker)) { |
| return false; |
| } |
| if (created_assertions.contains(label) && |
| !ValidateFirstAction(label, actions_assertion, |
| is_first_action_assertion, validation_tracker)) { |
| return false; |
| } |
| is_first_action_assertion = false; |
| return true; |
| }; |
| |
| for (const auto& assertion : manifest.assertions()) { |
| if (assertion.has_actions_v1() && |
| !validate_actions_assertion(assertion.label(), |
| assertion.actions_v1())) { |
| return false; |
| } |
| if (assertion.has_actions() && |
| !validate_actions_assertion(assertion.label(), assertion.actions())) { |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| } // namespace credentio |