diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 4eb9e100..3ee8aaac 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -16,9 +16,12 @@ ENV RUSTUP_HOME=/home/vscode/.rustup \
RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable && \
cargo install c2patool --locked
-COPY . /c2pie/
-
-WORKDIR c2pie
+# If the configuration files are not copied, it will not
+# be possible to install dependencies using poetry install
+COPY . /workspaces/c2pie/
+WORKDIR /workspaces/c2pie/
+# --no-interaction - installing in non-interactive mode
+# --no-ansi - disable ANSI codes
RUN poetry config virtualenvs.create false \
&& poetry lock && poetry install --no-interaction --no-ansi
\ No newline at end of file
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 891b00da..54c226c8 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -1,10 +1,10 @@
// For format details, see https://aka.ms/devcontainer.json
{
- "name": "Existing Dockerfile",
- "build": {
- "context": "..",
- "dockerfile": "Dockerfile"
- },
+ "name": "Existing Dockerfile",
+ "build": {
+ "context": "..",
+ "dockerfile": "Dockerfile"
+ },
"customizations": {
"vscode": {
"extensions": [
@@ -25,9 +25,17 @@
"source.fixAll.ruff": "explicit",
"source.organizeImports.ruff": "explicit"
},
+ "python.analysis.exclude": [
+ "**/__pycache__",
+ "**/.*"
+ ]
+ },
+ "files.associations": {
+ ".env-example": "dotenv" // For syntax highlighting
},
+ "editor.tabSize": 4
}
- }
+ }
},
"runArgs": [
"--env-file", "${localWorkspaceFolder}/.env",
diff --git a/.env-example b/.env-example
index 7b2c8067..fbe7f50b 100644
--- a/.env-example
+++ b/.env-example
@@ -1,4 +1,9 @@
C2PIE_PRIVATE_KEY_FILE=tests/credentials/private-key.pem
C2PIE_CERTIFICATE_CHAIN_FILE=tests/credentials/certificate-chain.pub
+C2PIE_TSA_URL=http://timestamp.digicert.com
+C2PIE_TSA_REQUIRED=true
+C2PIE_TSA_LOG_DIR=/var/log/c2pie/tsa
+
+# Paths to test files
TEST_PDF_PATH=tests/test_files/test_doc.pdf
TEST_IMAGE_PATH=tests/test_files/test_image.jpg
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
index 8218efdb..94f480de 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1 @@
-*.sh eol=lf
\ No newline at end of file
+* text=auto eol=lf
\ No newline at end of file
diff --git a/.github/workflows/calculate-tests-coverage-on-pull-request.yml b/.github/workflows/calculate-tests-coverage-on-pull-request.yml
new file mode 100644
index 00000000..7dfd6b72
--- /dev/null
+++ b/.github/workflows/calculate-tests-coverage-on-pull-request.yml
@@ -0,0 +1,257 @@
+name: Calculate and Update Tests Coverage
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ calculate-coverage:
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Check out the repo
+ uses: actions/checkout@v5
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ # this is needed to address this issue according to the comment https://github.com/devcontainers/ci/issues/271#issuecomment-2301764487
+ # otherwise our TourmalineCore org name cannot be used in docker image names, only tourmalinecore
+ - name: Add DEV_CONTAINER_IMAGE Env Var with Lowercase Organization, Repo Name and devcontainer postfix
+ run: |
+ echo "DEV_CONTAINER_IMAGE=${GITHUB_REPOSITORY,,}-devcontainer" >>${GITHUB_ENV}
+
+ - name: Create .env from .env-example
+ run: |
+ cp .env-example .env
+
+ - name: Calculate and Save Unit Tests Coverage
+ uses: devcontainers/ci@v0.3
+ with:
+ imageName: ghcr.io/${{ env.DEV_CONTAINER_IMAGE }}
+ cacheFrom: ghcr.io/${{ env.DEV_CONTAINER_IMAGE }}
+ runCmd: |
+ # COVERAGE_FILE is necessary to later combine separate .coverage files for e2e and unit tests
+ # We have "e2e" marker configured, so using -m "not e2e" allows running all tests without "e2e" marker
+ # Here we could use any random non-existent marker in place of "not e2e", this one was chosen for clarity
+ # With --cov-report xml, we save coverage results in both default pytest's coverage format (sqlite) and in xml
+ # We need both cause sqlite is expected in `coverage combine`, and xml is expected later when parsing it to embed percentage into README
+ COVERAGE_FILE=.coverage.unit pytest -m "not e2e" --maxfail=1 -v --cov=c2pie --cov-report xml:units-coverage.xml
+
+ # in the first step that runs inside Dev Container we push the image we built to re-use it in the next steps
+ # we never push in the next steps, only use the cache if available
+ push: always
+
+ - name: Calculate and Save E2E Tests Coverage
+ uses: devcontainers/ci@v0.3
+ with:
+ cacheFrom: ghcr.io/${{ env.DEV_CONTAINER_IMAGE }}
+ runCmd: |
+ # For detailed explanation refer to previous step
+ COVERAGE_FILE=.coverage.e2e pytest tests/c2pa/e2e_test.py --cov=c2pie -v --cov-report xml:e2e-coverage.xml
+ push: never
+
+ - name: Merge Units And E2E Into Full Coverage
+ uses: devcontainers/ci@v0.3
+ with:
+ cacheFrom: ghcr.io/${{ env.DEV_CONTAINER_IMAGE }}
+ runCmd: |
+ coverage combine --keep
+ # Need this additional command to save merged results into .xml as coverage combine doesn't allow it in itself
+ # And further down the line we work with .xml's
+ coverage xml --data-file .coverage -o full-coverage.xml
+ push: never
+
+ - name: Generate Full Tests Coverage Summary as Markdown
+ uses: devcontainers/ci@v0.3
+ with:
+ cacheFrom: ghcr.io/${{ env.DEV_CONTAINER_IMAGE }}
+ runCmd: |
+ coverage report --format=markdown > Summary.md
+ push: never
+
+ # https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#adding-a-job-summary
+ - name: Add Full Tests Coverage Summary to Job Summary
+ run: |
+ # this is a separate step because for some reason if you do it from devcontainers/ci it doesn't work and Job's summary is empty
+ cat Summary.md >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload Unit Tests Coverage
+ uses: actions/upload-artifact@v7
+ with:
+ name: units-coverage
+ path: units-coverage.xml
+
+ - name: Upload E2E Tests Coverage
+ uses: actions/upload-artifact@v7
+ with:
+ name: e2e-coverage
+ path: e2e-coverage.xml
+
+ - name: Upload Full Tests Coverage
+ uses: actions/upload-artifact@v7
+ with:
+ name: full-coverage
+ path: full-coverage.xml
+
+ update-coverage:
+ runs-on: ubuntu-24.04
+ needs: [calculate-coverage]
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v4
+ with:
+ # from AI: Without this ref in PRs, you're on a merge commit, and pushing back is ambiguous.
+ ref: ${{ github.head_ref }}
+
+ - name: Download Units Coverage
+ uses: actions/download-artifact@v8
+ with:
+ name: units-coverage
+ path: coverage
+
+ - name: Download E2E Coverage
+ uses: actions/download-artifact@v8
+ with:
+ name: e2e-coverage
+ path: coverage
+
+ - name: Download Full Coverage
+ uses: actions/download-artifact@v8
+ with:
+ name: full-coverage
+ path: coverage
+
+ - name: Install xq to Read Coverage from XML
+ run: |
+ sudo apt install xq
+
+ # Find existing score and color by line matching in README and get calculated coverage from json file
+ - name: Write Coverages to ENV Vars
+ # ToDo avoid code duplication with reusable bash or python function
+ run: |
+ units_line_coverage_float=$(cat coverage/units-coverage.xml | xq -x /coverage/@line-rate)
+
+ units_line_coverage_percent=$(awk -v num="$units_line_coverage_float" 'BEGIN { print num * 100 }')
+
+ # round to 2 decimal digits
+ echo "UNITS_COVERAGE=$(printf "%.2f\n" "$units_line_coverage_percent")" >> "$GITHUB_ENV"
+
+ e2e_line_coverage_float=$(cat coverage/e2e-coverage.xml | xq -x /coverage/@line-rate)
+
+ e2e_line_coverage_percent=$(awk -v num="$e2e_line_coverage_float" 'BEGIN { print num * 100 }')
+
+ # round to 2 decimal digits
+ echo "E2E_COVERAGE=$(printf "%.2f\n" "$e2e_line_coverage_percent")" >> "$GITHUB_ENV"
+
+ full_line_coverage_float=$(cat coverage/full-coverage.xml | xq -x /coverage/@line-rate)
+
+ full_line_coverage_percent=$(awk -v num="$full_line_coverage_float" 'BEGIN { print num * 100 }')
+
+ # round to 2 decimal digits
+ echo "FULL_COVERAGE=$(printf "%.2f\n" "$full_line_coverage_percent")" >> "$GITHUB_ENV"
+
+ # 0-60: red; 60-70: orange; 70-80: yellow; 80-90: yellowish green; 90-100: green
+ # set new color and then add its variable to workflow's environment
+ - name: Calculate Coverages Badge Colors
+ run: |
+ import os
+
+ def get_badge_color_by_coverage_value(
+ new_coverage: str,
+ ) -> str:
+ if new_coverage < 60:
+ new_badge_color = "crimson"
+ elif 60 <= new_coverage < 70:
+ new_badge_color = "orange"
+ elif 70 <= new_coverage < 80:
+ new_badge_color = "yellow"
+ elif 80 <= new_coverage < 90:
+ new_badge_color = "olivedrab"
+ elif new_coverage >= 90:
+ new_badge_color = "forestgreen"
+ return new_badge_color
+
+ def set_by_coverage_badge_color(
+ env_value_name_with_coverage: str,
+ env_value_name_with_bage_color: str,
+ ) -> None:
+ new_coverage = float(os.getenv(env_value_name_with_coverage))
+ badge_color = get_badge_color_by_coverage_value(new_coverage)
+ env_file = os.getenv("GITHUB_ENV")
+
+ with open(env_file, "a") as f:
+ f.write(f"{env_value_name_with_bage_color}={badge_color}\n")
+
+ set_by_coverage_badge_color(
+ env_value_name_with_coverage="UNITS_COVERAGE",
+ env_value_name_with_bage_color="UNITS_BADGE_COLOR"
+ )
+ set_by_coverage_badge_color(
+ env_value_name_with_coverage="E2E_COVERAGE",
+ env_value_name_with_bage_color="E2E_BADGE_COLOR"
+ )
+ set_by_coverage_badge_color(
+ env_value_name_with_coverage="FULL_COVERAGE",
+ env_value_name_with_bage_color="FULL_BADGE_COLOR"
+ )
+ shell: python
+
+ - name: Replace Badge URL in README
+ run: |
+ import io, sys, os, fileinput
+
+ def replace_coverage_badge_in_readme(
+ coverage_prefix: str,
+ coverage: str,
+ coverage_color: str,
+ ) -> None:
+ target_prefix = f"[](https://github.com/${{ github.repository }}/actions/workflows/calculate-tests-coverage-on-pull-request.yml)\n"
+
+ with fileinput.FileInput("README.md", inplace=True) as file:
+ for line in file:
+ if line.startswith(target_prefix):
+ sys.stdout.write(replacement)
+ else:
+ sys.stdout.write(line)
+
+ replace_coverage_badge_in_readme(
+ coverage_prefix="units_coverage",
+ coverage=os.getenv("UNITS_COVERAGE"),
+ coverage_color=os.getenv("UNITS_BADGE_COLOR")
+ )
+ replace_coverage_badge_in_readme(
+ coverage_prefix="e2e_coverage",
+ coverage=os.getenv("E2E_COVERAGE"),
+ coverage_color=os.getenv("E2E_BADGE_COLOR")
+ )
+ replace_coverage_badge_in_readme(
+ coverage_prefix="full_coverage",
+ coverage=os.getenv("FULL_COVERAGE"),
+ coverage_color=os.getenv("FULL_BADGE_COLOR")
+ )
+ shell: python
+
+ - name: Commit README changes
+ run: |
+ git config user.email "contact@tourmalinecore.com"
+ git config user.name "Workflow Action"
+
+ if ! git commit README.md -m "docs(readme): bring test coverage score up to date"; then
+ echo "No changes to commit."
+ exit 0
+ fi
+
+ # these fetch and rebase were needed to address an issue when there are 2 or more parallel jobs which modify README.md
+ # these jobs are doing it concurrently and there were a race condition, only the first modifier could push its changes
+ git fetch origin ${{ github.head_ref }}
+ git rebase origin/${{ github.head_ref }}
+ git push origin HEAD:${{ github.head_ref }}
\ No newline at end of file
diff --git a/.github/workflows/check-dependencies.yml b/.github/workflows/check-dependencies.yml
new file mode 100644
index 00000000..692bd584
--- /dev/null
+++ b/.github/workflows/check-dependencies.yml
@@ -0,0 +1,51 @@
+name: Check Dependencies with Deptry
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+jobs:
+ deptry_check:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
+
+ # poetry shell doesn't work properly without this setting
+ defaults:
+ run:
+ shell: bash
+
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout the repo
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install Poetry
+ uses: snok/install-poetry@v1
+ with:
+ version: '2.4.1'
+ virtualenvs-create: true
+ virtualenvs-in-project: true
+
+ - name: Load cached venv
+ id: cached-poetry-dependencies
+ uses: actions/cache@v4
+ with:
+ path: .venv
+ key: venv-tests-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
+
+ # --no-interaction - installing in non-interactive mode
+ # --no-ansi - disable ANSI codes
+ - name: Install The Project Dependencies
+ run: poetry install --with dev --no-interaction --no-ansi
+
+ - name: Check dependencies
+ run: poetry run deptry .
diff --git a/.github/workflows/e2e-tests-on-pull-request.yml b/.github/workflows/e2e-tests-on-pull-request.yml
new file mode 100644
index 00000000..b11eed3a
--- /dev/null
+++ b/.github/workflows/e2e-tests-on-pull-request.yml
@@ -0,0 +1,67 @@
+name: E2E Testing on PR
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ run-e2e-tests:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
+
+ # poetry shell doesn't work properly without this setting
+ defaults:
+ run:
+ shell: bash
+
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install Poetry
+ uses: snok/install-poetry@v1
+ with:
+ version: '2.4.1'
+ virtualenvs-create: true
+ virtualenvs-in-project: true
+
+ - name: Load cached venv
+ id: cached-poetry-dependencies
+ uses: actions/cache@v4
+ with:
+ path: .venv
+ key: venv-tests-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
+
+ - name: Install dependencies
+ run: poetry install --no-interaction
+
+ - name: Install Rust toolchain and c2patool
+ uses: baptiste0928/cargo-install@v3
+ with:
+ crate: c2patool
+
+ - name: Verify if c2patool is installed
+ run: |
+ c2patool -V
+
+ - name: Run e2e tests with coverage
+ run: |
+ poetry run pytest tests/c2pa/e2e_test.py -v
+ env:
+ C2PIE_PRIVATE_KEY_FILE: ${{ vars.C2PIE_PRIVATE_KEY_FILE }}
+ C2PIE_CERTIFICATE_CHAIN_FILE: ${{ vars.C2PIE_CERTIFICATE_CHAIN_FILE }}
diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml
deleted file mode 100644
index d8130082..00000000
--- a/.github/workflows/lint-and-test.yml
+++ /dev/null
@@ -1,354 +0,0 @@
-name: Linting and Testing
-
-on:
- push:
- branches-ignore:
- - master
- - release/**
- paths-ignore:
- - '**.md'
- - 'docs/**'
- tags-ignore:
- - v**
- workflow_call:
-
-permissions:
- contents: read
- packages: read
-
-concurrency:
- group: ci-${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- ruff:
- name: Linting and Formatting
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v5
-
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: '3.12'
-
- - name: Install Poetry
- uses: snok/install-poetry@v1
- with:
- version: 'latest'
- virtualenvs-create: true
- virtualenvs-in-project: true
-
- - name: Load cached venv
- id: cached-poetry-dependencies
- uses: actions/cache@v4
- with:
- path: .venv
- key: venv-ruff-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
-
- - name: Install dependencies
- if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
- run: poetry install --only dev
-
- - name: Ruff version
- run: poetry run ruff --version
-
- - name: Check formatting
- run: poetry run ruff format --check .
-
- - name: Lint (GitHub Annotations)
- run: poetry run ruff check --output-format=github .
-
- run-unit-tests:
- name: Run unit tests
- needs: ruff
- strategy:
- fail-fast: false
- matrix:
- os: [ubuntu-latest, macos-latest, windows-latest]
- python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
-
- defaults:
- run:
- shell: bash
-
- runs-on: ${{ matrix.os }}
- timeout-minutes: 30
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v5
-
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: ${{ matrix.python-version }}
-
- - name: Install Poetry
- uses: snok/install-poetry@v1
- with:
- version: 'latest'
- virtualenvs-create: true
- virtualenvs-in-project: true
-
- - name: Load cached venv
- id: cached-poetry-dependencies
- uses: actions/cache@v4
- with:
- path: .venv
- key: venv-tests-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
-
- - name: Install dependencies
- run: poetry install --no-interaction
-
- - name: Run unit tests with coverage
- run: |
- poetry run pytest \
- --cov=c2pie \
- -m "not e2e" \
- --maxfail=1 \
- -v
-
- - name: Upload coverage artifact
- if: (matrix.python-version == '3.12') && (matrix.os == 'ubuntu-latest')
- uses: actions/upload-artifact@v4
- with:
- include-hidden-files: true
- name: coverage-unit
- path: .coverage
-
- run-e2e-tests:
- name: Run e2e tests
- needs: ruff
-
- strategy:
- fail-fast: false
- matrix:
- os: [ubuntu-latest, macos-latest, windows-latest]
- python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
-
- defaults:
- run:
- shell: bash
-
- runs-on: ${{ matrix.os }}
- timeout-minutes: 30
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v5
-
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: ${{ matrix.python-version }}
-
- - name: Install Poetry
- uses: snok/install-poetry@v1
- with:
- version: 'latest'
- virtualenvs-create: true
- virtualenvs-in-project: true
-
- - name: Load cached venv
- id: cached-poetry-dependencies
- uses: actions/cache@v4
- with:
- path: .venv
- key: venv-tests-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
-
- - name: Install dependencies
- run: poetry install --no-interaction
-
- - name: Install Rust toolchain and c2patool
- uses: baptiste0928/cargo-install@v3
- with:
- crate: c2patool
-
- - name: Verify if c2patool is installed
- run: |
- c2patool -V
-
- - name: Run e2e tests with coverage
- run: |
- poetry run pytest tests/c2pa/e2e_test.py \
- --cov=c2pie \
- -v
- env:
- C2PIE_PRIVATE_KEY_FILE: ${{ vars.C2PIE_PRIVATE_KEY_FILE }}
- C2PIE_CERTIFICATE_CHAIN_FILE: ${{ vars.C2PIE_CERTIFICATE_CHAIN_FILE }}
-
- - name: Upload coverage artifact
- if: (matrix.python-version == '3.12') && (matrix.os == 'ubuntu-latest')
- uses: actions/upload-artifact@v4
- with:
- include-hidden-files: true
- name: coverage-e2e
- path: .coverage
-
- combine-coverage:
- name: Combine and upload coverage
- needs: [run-unit-tests, run-e2e-tests]
- if: always()
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v5
-
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: "3.12"
-
- - name: Install coverage
- run: python -m pip install coverage[toml]
-
- - name: Download unit coverage
- uses: actions/download-artifact@v5
- with:
- name: coverage-unit
- path: coverage-unit
- continue-on-error: true
-
- - name: Download e2e coverage
- uses: actions/download-artifact@v5
- with:
- name: coverage-e2e
- path: coverage-e2e
- continue-on-error: true
-
- - name: Check if artifacts exist
- id: check
- run: |
- if [ -f ./coverage-e2e/.coverage ] && [ -f ./coverage-unit/.coverage ]; then
- echo "ready=true" >> $GITHUB_OUTPUT
- else
- echo "ready=false" >> $GITHUB_OUTPUT
- fi
-
- - name: Fail if coverage missing
- if: steps.check.outputs.ready != 'true'
- run: |
- echo "Coverage artifacts not found โ skipping coverage report"
- exit 1
-
- - name: Combine coverage reports
- run: |
- coverage combine ./coverage-e2e/.coverage ./coverage-unit/.coverage
- coverage xml -o coverage-combined.xml
- coverage report --format=markdown >> $GITHUB_STEP_SUMMARY
- coverage json -o coverage-combined.json
-
- - name: Upload combined coverage artifact
- uses: actions/upload-artifact@v4
- with:
- name: coverage-combined
- path: coverage-combined.json
-
- update-coverage:
- name: Update Coverage
- runs-on: ubuntu-latest
- needs: [combine-coverage]
- permissions:
- contents: write
- steps:
- - name: Checkout repository
- uses: actions/checkout@v5
-
- - name: Download jq
- run: |
- sudo apt install jq
-
- - name: Download combined coverage
- uses: actions/download-artifact@v5
- with:
- name: coverage-combined
- path: coverage-combined
-
- # Find existing score and color by line matching in README and get calculated coverage from json file
- - name: Find existing and calculated test coverage scores and existing color
- run: |
- echo "EXISTING_COVERAGE=$(grep -oP '(?<=coverage-)\d+' README.md | head -n1)" >> "$GITHUB_ENV"
- echo "EXISTING_BADGE_COLOR=$(grep -oP '(?<=%25-)[^?]+' README.md | head -n1)" >> "$GITHUB_ENV"
- echo "CALCULATED_COVERAGE=$(jq -r .totals.percent_covered_display coverage-combined/coverage-combined.json)" >> "$GITHUB_ENV"
-
- - name: Check found coverage scores and color
- run: |
- echo $EXISTING_COVERAGE
- echo $CALCULATED_COVERAGE
- echo $EXISTING_BADGE_COLOR
-
- # 0-60: red; 60-70: orange; 70-80: yellow; 80-90: yellowish green; 90-100: green
- # set new color and then add its variable to workflow's environment
- - name: Define new badge color
- if: ${{ env.EXISTING_COVERAGE != env.CALCULATED_COVERAGE }}
- run: |
- python3 - <<'PY'
- import os
-
- new_coverage = int(os.getenv("CALCULATED_COVERAGE"))
-
- if new_coverage < 60:
- new_badge_color = "crimson"
- elif 60 <= new_coverage < 70:
- new_badge_color = "orange"
- elif 70 <= new_coverage < 80:
- new_badge_color = "yellow"
- elif 80 <= new_coverage < 90:
- new_badge_color = "olivedrab"
- elif new_coverage >= 90:
- new_badge_color = "forestgreen"
-
- env_file = os.getenv('GITHUB_ENV')
-
- with open(env_file, "a") as f:
- f.write(f"NEW_BADGE_COLOR={new_badge_color}")
- PY
-
- # Find existing badge url in README assuming that it looks like we expect (must be perfect match)
- # Replace it with calculated url, where score and color are new
- - name: Replace badge URL in README
- if: ${{ env.EXISTING_COVERAGE != env.CALCULATED_COVERAGE }}
- run: |
-
- export EXISTING_COVERAGE_BADGE_URL="https://img.shields.io/badge/coverage-$EXISTING_COVERAGE%25-$EXISTING_BADGE_COLOR?logo=codecov&logoColor=ff9d1c"
- export CALCULATED_COVERAGE_BADGE_URL="https://img.shields.io/badge/coverage-$CALCULATED_COVERAGE%25-$NEW_BADGE_COLOR?logo=codecov&logoColor=ff9d1c"
-
- echo $EXISTING_COVERAGE_BADGE_URL
- echo $CALCULATED_COVERAGE_BADGE_URL
-
- python3 - <<'PY'
- import io, sys, os
-
- old_badge_url = os.getenv("EXISTING_COVERAGE_BADGE_URL")
- new_badge_url = os.getenv("CALCULATED_COVERAGE_BADGE_URL")
- path = "README.md"
-
- if not old_badge_url or not new_badge_url:
- sys.exit("Either existing or calculated badge URL doesn't exist")
-
- with io.open(path, "r", encoding="utf-8") as f:
- content = f.read()
-
- if old_badge_url in content:
- content = content.replace(old_badge_url, new_badge_url)
- with io.open(path, "w", encoding="utf-8") as f:
- f.write(content)
- print("Replaced coverage badge URL in README.md")
- else:
- print("Old URL not found in README.md, no replacement made")
- PY
-
- - name: Commit README changes
- if: ${{ env.EXISTING_COVERAGE != env.CALCULATED_COVERAGE }}
- run: |
- git config --global user.name github-actions[bot]
- git config --global user.email 41898282+github-actions[bot]@users.noreply.github.com
-
- git add README.md
-
- git commit -m "docs(readme): bring test coverage score up to date"
-
- git push origin ${{ github.ref }}
diff --git a/.github/workflows/lint-on-pull-request.yml b/.github/workflows/lint-on-pull-request.yml
new file mode 100644
index 00000000..d8f04303
--- /dev/null
+++ b/.github/workflows/lint-on-pull-request.yml
@@ -0,0 +1,51 @@
+name: Linting
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ run-linting:
+ name: Linting and Formatting
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ # using the version we're working with in development
+ with:
+ python-version: 3.13
+
+ - name: Install Poetry
+ uses: snok/install-poetry@v1
+ with:
+ version: '2.4.1'
+ virtualenvs-create: true
+ virtualenvs-in-project: true
+
+ - name: Load cached venv
+ id: cached-poetry-dependencies
+ uses: actions/cache@v4
+ with:
+ path: .venv
+ key: venv-ruff-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
+
+ - name: Install dependencies
+ if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
+ run: poetry install --only dev
+
+ - name: Ruff version
+ run: poetry run ruff --version
+
+ - name: Check formatting
+ run: poetry run ruff format --check .
+
+ - name: Lint (GitHub Annotations)
+ run: poetry run ruff check --output-format=github .
\ No newline at end of file
diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml
index 1ffcbc3d..d2ab07c5 100644
--- a/.github/workflows/publish-package.yml
+++ b/.github/workflows/publish-package.yml
@@ -46,7 +46,7 @@ jobs:
poetry build
- name: Upload dists
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: dist
path: dist
@@ -65,7 +65,7 @@ jobs:
steps:
- name: Retrieve release distributions
- uses: actions/download-artifact@v5
+ uses: actions/download-artifact@v8
with:
name: dist
path: dist
diff --git a/.github/workflows/test-readme-steps.yml b/.github/workflows/test-readme-steps.yml
index b49ce035..16006936 100644
--- a/.github/workflows/test-readme-steps.yml
+++ b/.github/workflows/test-readme-steps.yml
@@ -1,14 +1,16 @@
name: Execute Readme Steps
on:
+ pull_request:
+ types: [opened, synchronize, reopened]
push:
branches:
- - feature/*
+ - master
jobs:
sign-steps:
runs-on: ubuntu-24.04
- container: python:3.9.2-buster
+ container: python:3.10-bookworm
steps:
- name: Generate Certificates, Install c2pie, and Sign Image and PDF
run: |
@@ -46,13 +48,13 @@ jobs:
c2pie sign --input_file ./test_doc.pdf
- name: Upload Signed Image
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: signed_test_image
path: signed_test_image.jpg
- name: Upload Signed PDF
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: signed_test_doc
path: signed_test_doc.pdf
@@ -62,12 +64,12 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Download Signed Image
- uses: actions/download-artifact@v5
+ uses: actions/download-artifact@v8
with:
name: signed_test_image
- name: Download Signed PDF
- uses: actions/download-artifact@v5
+ uses: actions/download-artifact@v8
with:
name: signed_test_doc
diff --git a/.github/workflows/unit-tests-on-pull-request.yml b/.github/workflows/unit-tests-on-pull-request.yml
new file mode 100644
index 00000000..a436facc
--- /dev/null
+++ b/.github/workflows/unit-tests-on-pull-request.yml
@@ -0,0 +1,56 @@
+name: Unit Testing
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ run-unit-tests:
+ name: Run unit tests
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
+
+ # poetry shell doesn't work properly without this setting
+ defaults:
+ run:
+ shell: bash
+
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 30
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install Poetry
+ uses: snok/install-poetry@v1
+ with:
+ version: '2.4.1'
+ virtualenvs-create: true
+ virtualenvs-in-project: true
+
+ - name: Load cached venv
+ id: cached-poetry-dependencies
+ uses: actions/cache@v4
+ with:
+ path: .venv
+ key: venv-tests-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
+
+ - name: Install dependencies
+ run: poetry install --no-interaction
+
+ - name: Run unit tests
+ run: |
+ poetry run pytest -m "not e2e" --maxfail=1 -v
diff --git a/.gitignore b/.gitignore
index f74909d6..c65e4d9f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,8 @@ __pycache__
build
dist
**/signed_*
+# This file is needed for unit tests
+!tests/test_files/signed_signed_test_doc.pdf
.vscode/launch.json
.env
**/.ipynb_checkpoints
diff --git a/README.md b/README.md
index 39ea1c3e..a65ab4dc 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,11 @@
-------
-[](https://github.com/TourmalineCore/c2pie/actions/workflows/lint-and-test.yml)
+[](https://github.com/TourmalineCore/c2pie/actions/workflows/lint-on-pull-request.yml)
[](https://c2pa.org/)
-[](https://github.com/TourmalineCore/c2pie/actions/workflows/lint-and-test.yml)
+[](https://github.com/TourmalineCore/c2pie/actions/workflows/calculate-tests-coverage-on-pull-request.yml)
+[](https://github.com/TourmalineCore/c2pie/actions/workflows/calculate-tests-coverage-on-pull-request.yml)
+[](https://github.com/TourmalineCore/c2pie/actions/workflows/calculate-tests-coverage-on-pull-request.yml)
[](https://pypi.org/project/c2pie/)
@@ -21,9 +23,9 @@ The package supports building claims, assertions, and COSE signatures and embedd
๐ธ **Supported file extensions**: `JPG`, `JPEG`, `PDF`
-๐ธ **Supported Python versions**: `3.9.2 - 3.14.0`
+๐ธ **Supported Python versions**: `3.10.0 - 3.14.5`
-๐ธ **Supported C2PA Spec Versions**: `1.4`.
+๐ธ **Supported C2PA Spec Versions**: `2.4`.
Support for C2PA 2.2 is planned for future releases.
@@ -122,7 +124,7 @@ After being copied to host machine, signed files can then be validated using eit
### Prerequisites
-1) Python environment. Currently supported Python versions: 3.9.2 - 3.14.0. Make sure to [create and activate virtual environment](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/) to avoid installing packages globally and any errors caused by that.
+1) Python environment. Currently supported Python versions: 3.10.0 - 3.14.5. Make sure to [create and activate virtual environment](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/) to avoid installing packages globally and any errors caused by that.
2) Private key and certificate chain pair. The repo contains pre-generated mock credentials in `tests/credentials`. You can either download and use them for a quick start or go to [Certificates](#-certificates) for instructions on how to generate a similar key-certificate pair.
@@ -200,9 +202,9 @@ Follow the steps:
1. Clone the c2pie repository.
2. Go to `example_app` directory:
- ```bash
- cd example_app
- ```
+```bash
+cd example_app
+```
>[!NOTE]
>By default, example apps use the latest available stable c2pie version. If you'd like to test some particular version, you can change the value of `C2PIE_PACKAGE_VERSION` in `example_app/.example-app-env`.
@@ -224,18 +226,22 @@ Follow the steps:
The result was saved to test_files/signed_test_image.jpg.
c2patool_validation_results:
{
- "active_manifest": "urn:uuid:f0ce8560b76342d1bb3085cfbe6cc5e9",
+ "active_manifest": "urn:c2pa:f0ce8560b76342d1bb3085cfbe6cc5e9",
"manifests": {
- "urn:uuid:f0ce8560b76342d1bb3085cfbe6cc5e9": {
- "claim_generator": "c2pie",
- ................
+ "urn:c2pa:f0ce8560b76342d1bb3085cfbe6cc5e9": {
+ "claim_generator_info": {
+ "name": "c2pie",
+ ...
+ },
+ ...
+ }
},
"validation_results": {
"activeManifest": {
"success": [
{
"code": "claimSignature.insideValidity",
- "url": "self#jumbf=/c2pa/urn:uuid:f0ce8560b76342d1bb3085cfbe6cc5e9/c2pa.signature",
+ "url": "self#jumbf=/c2pa/urn:c2pa:f0ce8560b76342d1bb3085cfbe6cc5e9/c2pa.signature",
"explanation": "claim signature valid"
},
................
@@ -290,20 +296,23 @@ c2patool path/to/your_output.pdf
If the file has been correctly signed and validation is successful, the results you'll see in the terminal will look similar to this:
```bash
-c2patool_validation_results:
{
- "active_manifest": "urn:uuid:f0ce8560b76342d1bb3085cfbe6cc5e9",
+ "active_manifest": "urn:c2pa:f0ce8560b76342d1bb3085cfbe6cc5e9",
"manifests": {
- "urn:uuid:f0ce8560b76342d1bb3085cfbe6cc5e9": {
- "claim_generator": "c2pie",
- ................
+ "urn:c2pa:f0ce8560b76342d1bb3085cfbe6cc5e9": {
+ "claim_generator_info": {
+ "name": "c2pie",
+ ...
+ },
+ ...
+ }
},
"validation_results": {
"activeManifest": {
"success": [
{
"code": "claimSignature.insideValidity",
- "url": "self#jumbf=/c2pa/urn:uuid:f0ce8560b76342d1bb3085cfbe6cc5e9/c2pa.signature",
+ "url": "self#jumbf=/c2pa/urn:c2pa:f0ce8560b76342d1bb3085cfbe6cc5e9/c2pa.signature",
"explanation": "claim signature valid"
},
................
@@ -340,18 +349,22 @@ cargo install c2patool
```bash
c2patool_validation_results:
{
- "active_manifest": "urn:uuid:f0ce8560b76342d1bb3085cfbe6cc5e9",
+ "active_manifest": "urn:c2pa:f0ce8560b76342d1bb3085cfbe6cc5e9",
"manifests": {
- "urn:uuid:f0ce8560b76342d1bb3085cfbe6cc5e9": {
- "claim_generator": "c2pie",
- ................
+ "urn:c2pa:f0ce8560b76342d1bb3085cfbe6cc5e9": {
+ "claim_generator_info": {
+ "name": "c2pie",
+ ...
+ },
+ ...
+ }
},
"validation_results": {
"activeManifest": {
"success": [
{
"code": "claimSignature.insideValidity",
- "url": "self#jumbf=/c2pa/urn:uuid:f0ce8560b76342d1bb3085cfbe6cc5e9/c2pa.signature",
+ "url": "self#jumbf=/c2pa/urn:c2pa:f0ce8560b76342d1bb3085cfbe6cc5e9/c2pa.signature",
"explanation": "claim signature valid"
},
................
@@ -442,7 +455,7 @@ For detailed information on signing and certificates please explore the [corresp
1) Load a sample asset (`tests/test_files/..`);
-2) Build a manifest with `c2pie_GenerateAssertion`, `c2pie_GenerateHashDataAssertion`, `c2pie_GenerateManifest`;
+2) Build a manifest with `c2pie_GenerateAssertion`, `c2pie_GenerateHashDataAssertion`, `c2pie_GenerateManifestStore`;
3) Embed the manifest (`c2pie_EmplaceManifest`);
diff --git a/c2pie/c2pa/assertion.py b/c2pie/c2pa/assertion.py
index f48c7082..78d09a1d 100644
--- a/c2pie/c2pa/assertion.py
+++ b/c2pie/c2pa/assertion.py
@@ -1,7 +1,11 @@
-from __future__ import annotations
-
+import hashlib
+import io
from typing import Any
+import c2pa
+
+from c2pie.c2pa_parsing.jumbf_parsing import find_in_box
+from c2pie.jumbf_boxes.box import Box
from c2pie.jumbf_boxes.content_box import ContentBox
from c2pie.jumbf_boxes.super_box import SuperBox
from c2pie.utils.assertion_schemas import (
@@ -13,6 +17,9 @@
json_to_bytes,
)
from c2pie.utils.content_types import jumbf_content_types
+from c2pie.utils.generate_hashed_uri_map import generate_hashed_uri_map
+
+_ALLOWED_ACTIONS = ["c2pa.created", "c2pa.opened"]
class Assertion(SuperBox):
@@ -22,28 +29,35 @@ def __init__(
self,
assertion_type: C2PA_AssertionTypes,
schema: dict[str, Any],
+ content_boxes: list[ContentBox] | None = None,
):
self.type = assertion_type
self.schema = schema
- payload = self.get_payload_from_schema()
- box_type_hex = get_assertion_content_box_type(self.type)
- content_box = ContentBox(box_type=box_type_hex, payload=payload)
+ if not content_boxes:
+ payload = self.get_payload_from_schema()
+ box_type_hex = get_assertion_content_box_type(self.type)
+ content_boxes = [
+ ContentBox(
+ box_type=box_type_hex,
+ payload=payload,
+ )
+ ]
super().__init__(
content_type=get_assertion_content_type(self.type),
label=get_assertion_label(self.type),
- content_boxes=[content_box],
+ content_boxes=content_boxes,
)
def get_payload_from_schema(self) -> bytes:
ctype = get_assertion_content_type(self.type)
+
if ctype == jumbf_content_types["json"]:
return json_to_bytes(self.schema)
- if ctype == jumbf_content_types["cbor"]:
+ elif ctype == jumbf_content_types["cbor"]:
return cbor_to_bytes(self.schema)
- if ctype == jumbf_content_types["codestream"]:
- return self.schema.get("payload", b"")
+
return b""
def get_data_for_signing(self) -> bytes:
@@ -55,42 +69,255 @@ class HashDataAssertion(Assertion):
def __init__(
self,
- cai_offset: int,
hashed_data: bytes,
- additional_exclusions: list[dict[str, int]] | None = None,
):
- exclusions: list[dict[str, int]] = [{"start": cai_offset, "length": 65535}]
- if additional_exclusions:
- exclusions.extend(additional_exclusions)
+ exclusions: list[dict[str, int]] = []
schema: dict[str, Any] = {
- "name": "jumbf manifest",
"exclusions": exclusions,
"alg": "sha256",
"hash": hashed_data,
- "pad": [],
+ # The specification recommends setting the pad to at least 16 bytes. We use 64 bytes
+ # to allow for some extra space before the 23-byte limit is exceeded, since otherwise
+ # the CBOR header of the pad field would be reduced by 1 byte.
+ "pad": b"\x00" * 64,
}
- super().__init__(C2PA_AssertionTypes.data_hash, schema)
- def set_hash_data_length(self, length: int) -> None:
- if self.schema.get("name") != "jumbf manifest":
- raise ValueError("c2pa.hash.data: jumbf manifest is missing")
- exclusions = self.schema.get("exclusions", [])
- if not exclusions:
- raise ValueError("c2pa.hash.data: exclusions are missing")
- exclusions[0]["length"] = int(length)
+ super().__init__(
+ C2PA_AssertionTypes.data_hash,
+ schema,
+ )
+
+ def add_full_c2pa_structure_exclusion(
+ self,
+ offset: int,
+ length: int,
+ ) -> None:
+ exclusions = self.schema["exclusions"]
+ previous_exclusion_length = len(cbor_to_bytes(exclusions))
+
+ self.schema["exclusions"].extend(
+ [
+ {
+ "start": offset,
+ "length": length,
+ },
+ ]
+ )
+
+ # NOTE: If the number of exclusions exceeds 23, an additional length byte
+ # will be added to the CBOR header of serialized exclusions array. This byte
+ # is included in the recalculation of the serialized exclusions.
+ current_exclusion_length = len(cbor_to_bytes(exclusions))
+
+ difference = previous_exclusion_length - current_exclusion_length
+
+ if -difference > len(self.schema["pad"]):
+ raise ValueError("Difference in length exceeds the predefined pad")
+
+ # If the pad is less than 24 bytes the size of the cbor header
+ # will change during conversion to cbor and will occupy less than 2 bytes.
+ updated_pad_length = len(self.schema["pad"]) + difference
+
+ # If a CBOR overflow is not handled, the extra length byte that
+ # would be added in this case will not be taken into account.
+ if updated_pad_length < 24:
+ updated_pad_length -= 1
+
+ self.schema["pad"] = b"\x00" * updated_pad_length
payload = self.get_payload_from_schema()
- if self.content_boxes:
- self.content_boxes[0] = ContentBox(
+
+ self.content_boxes = [
+ ContentBox(
box_type=get_assertion_content_box_type(self.type),
payload=payload,
)
- else:
- self.content_boxes = [
+ ]
+
+ self.sync_payload()
+
+
+class ActionsAssertion(Assertion):
+ """c2pa.actions.v2 assertion of actions on an asset."""
+
+ def __init__(
+ self,
+ action: str,
+ parameters: dict[str, list[dict[str, Any]]] | None = None,
+ ):
+ if action not in _ALLOWED_ACTIONS:
+ raise ValueError(f"Invalid action {action!r}. Must be one of: {_ALLOWED_ACTIONS}")
+
+ schema: dict[str, Any] = {
+ "actions": [
+ {"action": action},
+ ],
+ }
+
+ if parameters:
+ schema["actions"][0]["parameters"] = parameters
+
+ super().__init__(C2PA_AssertionTypes.actions, schema)
+
+
+class EmbeddedDataAssertion(Assertion):
+ """
+ Embedded Data assertion, contains embedded data within the JUMBF Box.
+
+ Can be used for the following assertions:
+ - c2pa.thumbnail.claim,
+ - c2pa.ingredient.thumbnail
+ - c2pa.embedded-data
+
+ Structure:
+ JUMBF Super Box (jumb)
+ -> JUMBF Description Box (jumd)
+ -> Embedded File Description Box (bfdb)
+ -> Binary Data Box (bidb)
+ """
+
+ def __init__(
+ self,
+ media_type: str,
+ image_data: bytes,
+ assertion_type: C2PA_AssertionTypes = C2PA_AssertionTypes.embedded_data,
+ ):
+ # 0000 000x - Filename present? (0 - false, 1 - true)
+ # 0000 00x0 - What's inside bidb? (0 - binary data, 1 - URI)
+ # xxxx xx00- reserved
+ toggles_bytes = b"\x00"
+
+ # IANA media type + null-terminate
+ media_type_bytes = media_type.encode("utf-8") + b"\x00"
+
+ payload = toggles_bytes + media_type_bytes
+
+ super().__init__(
+ assertion_type=assertion_type,
+ schema={},
+ content_boxes=[
ContentBox(
- box_type=get_assertion_content_box_type(self.type),
+ box_type=b"bfdb".hex(), # UUID Type of Embedded File Description Box
payload=payload,
+ ),
+ ContentBox(
+ box_type=b"bidb".hex(), # UUID Type of Binary Data Box
+ payload=image_data,
+ ),
+ ],
+ )
+
+
+class ThumbnailAssertion(EmbeddedDataAssertion):
+ """An assertion (c2pa.thumbnail.claim) containing an asset thumbnail"""
+
+ def __init__(
+ self,
+ media_type: str,
+ image_data: bytes,
+ ):
+ super().__init__(
+ media_type=media_type,
+ image_data=image_data,
+ assertion_type=C2PA_AssertionTypes.thumbnail,
+ )
+
+
+class IngredientThumbnailAssertion(EmbeddedDataAssertion):
+ """An assertion (c2pa.thumbnail.ingredient) containing an ingredient thumbnail"""
+
+ def __init__(
+ self,
+ media_type: str,
+ image_data: bytes,
+ ):
+ super().__init__(
+ media_type=media_type,
+ image_data=image_data,
+ assertion_type=C2PA_AssertionTypes.ingredient_thumbnail,
+ )
+
+
+class IngredientAssertion(Assertion):
+ """c2pa.ingredient.v3 asset-binding assertion."""
+
+ def __init__(
+ self,
+ title: str,
+ dc_format: str,
+ ingredient_bytes: bytes,
+ active_manifest_urn: str | None,
+ active_manifest: Box | None,
+ ingredient_thumbnail_assertion: IngredientThumbnailAssertion | None = None,
+ ):
+ schema: dict[str, Any] = {
+ "dc:title": title,
+ "dc:format": dc_format,
+ "relationship": "parentOf",
+ }
+
+ if ingredient_thumbnail_assertion:
+ ingredient_thumbnail_hash = hashlib.sha256(ingredient_thumbnail_assertion.payload).digest()
+
+ ingredient_thumbnail: dict[str, str | bytes] = generate_hashed_uri_map(
+ url=f"self#jumbf=c2pa.assertions/{ingredient_thumbnail_assertion.get_label()}",
+ hash_value=ingredient_thumbnail_hash,
+ hash_algorithm="sha256",
+ )
+ schema["thumbnail"] = ingredient_thumbnail
+
+ if active_manifest_urn and active_manifest:
+ validation_results = self.validate_ingredient(
+ ingredient_bytes,
+ dc_format,
+ )
+
+ # We should not include information about the active manifest if validation was unsuccessful
+ if not validation_results:
+ super().__init__(
+ C2PA_AssertionTypes.ingredient,
+ schema,
)
- ]
- self.sync_payload()
+ return
+
+ active_manifest_hash = hashlib.sha256(active_manifest.payload).digest()
+
+ active_manifest_map: dict[str, str | bytes] = generate_hashed_uri_map(
+ url=f"self#jumbf=/c2pa/{active_manifest_urn}",
+ hash_value=active_manifest_hash,
+ hash_algorithm="sha256",
+ )
+
+ claim_signature_box = find_in_box(active_manifest, "c2pa.signature")
+
+ claim_signature_hash = hashlib.sha256(claim_signature_box.payload).digest()
+
+ claim_signature: dict[str, str | bytes] = generate_hashed_uri_map(
+ url=f"self#jumbf=/c2pa/{active_manifest_urn}/c2pa.signature",
+ hash_value=claim_signature_hash,
+ hash_algorithm="sha256",
+ )
+
+ schema["activeManifest"] = active_manifest_map
+ schema["validationResults"] = validation_results
+ schema["claimSignature"] = claim_signature
+
+ super().__init__(
+ C2PA_AssertionTypes.ingredient,
+ schema,
+ )
+
+ def validate_ingredient(
+ self,
+ ingredient_bytes: bytes,
+ mime_type: str,
+ ) -> dict | None:
+ stream = io.BytesIO(ingredient_bytes)
+ reader = c2pa.Reader.try_create(mime_type, stream)
+
+ if not reader:
+ return None
+
+ with reader:
+ return reader.get_validation_results()
diff --git a/c2pie/c2pa/assertion_store.py b/c2pie/c2pa/assertion_store.py
index ee4e114d..d5b31894 100644
--- a/c2pie/c2pa/assertion_store.py
+++ b/c2pie/c2pa/assertion_store.py
@@ -1,5 +1,4 @@
-from __future__ import annotations
-
+from c2pie.c2pa.assertion import Assertion
from c2pie.jumbf_boxes.super_box import SuperBox
from c2pie.utils.assertion_schemas import C2PA_AssertionTypes
from c2pie.utils.content_types import c2pa_content_types
@@ -8,9 +7,10 @@
class AssertionStore(SuperBox):
def __init__(
self,
- assertions: list,
+ assertions: list[Assertion],
):
self.assertions = assertions
+
super().__init__(
content_type=c2pa_content_types["assertions"],
label="c2pa.assertions",
@@ -20,11 +20,16 @@ def __init__(
def get_assertions(self) -> list:
return self.assertions
- def set_hash_data_length(
+ def add_full_c2pa_structure_exclusion(
self,
+ offset: int,
length: int,
) -> None:
for assertion in self.assertions:
if assertion.type == C2PA_AssertionTypes.data_hash:
- assertion.set_hash_data_length(length)
+ assertion.add_full_c2pa_structure_exclusion(
+ offset,
+ length,
+ )
+
self.sync_payload()
diff --git a/c2pie/c2pa/claim.py b/c2pie/c2pa/claim.py
index cf9ff01a..753804c0 100644
--- a/c2pie/c2pa/claim.py
+++ b/c2pie/c2pa/claim.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
import hashlib
import uuid
from typing import Any
@@ -11,29 +9,28 @@
from c2pie.jumbf_boxes.super_box import SuperBox
from c2pie.utils.content_types import c2pa_content_types
+_GATHERED_ASSERTIONS = {"c2pa.ingredient.v3", "c2pa.actions.v2"}
+
def _sha256(b: bytes) -> bytes:
return hashlib.sha256(b).digest()
class Claim(SuperBox):
- """Claim (c2pa.claim) as a JUMBF superbox with one CBOR content box."""
+ """Claim (c2pa.claim.v2) as a JUMBF superbox with one CBOR content box."""
def __init__(
self,
assertion_store: AssertionStore,
- claim_generator: str = "c2pie",
- manifest_label: str = f"urn:uuid:{uuid.uuid4().hex}",
- dc_format: str = None,
+ manifest_label: str,
+ dc_title: str,
):
- self.claim_generator = claim_generator
self.manifest_label = manifest_label
self.assertion_store = assertion_store
- self.dc_format = dc_format
+ self.dc_title = dc_title
self.claim_signature_label = f"self#jumbf=c2pa/{self.manifest_label}/c2pa.signature"
-
- self._instance_id = f"xmp:iid:{uuid.uuid4()}"
+ self.instance_id = f"xmp:iid:{uuid.uuid4()}"
cbor_payload = self._build_cbor_payload()
@@ -41,9 +38,10 @@ def __init__(
box_type=b"cbor".hex(),
payload=cbor_payload,
)
+
super().__init__(
content_type=c2pa_content_types["claim"],
- label="c2pa.claim",
+ label="c2pa.claim.v2",
content_boxes=[content_box],
)
@@ -62,26 +60,25 @@ def set_assertion_store(self, assertion_store) -> None:
self.assertion_store = assertion_store
self._rebuild_payload()
- def set_format(self, dc_format: str | None) -> None:
- self.dc_format = dc_format
- self._rebuild_payload()
-
def _build_assertions_array(self) -> list[dict[str, Any]]:
"""
- Build the claim[โassertionsโ] array from the current AssertionStore:
+ Build the claim["created_assertions"] and claim["gathered_assertions"] arrays from the current AssertionStore:
- url: self#jumbf=/c2pa//c2pa.assertions/