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"[![coverage](https://img.shields.io/badge/{coverage_prefix}" + replacement = f"[![coverage](https://img.shields.io/badge/{coverage_prefix}-{coverage}%25-{coverage_color})](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 @@ ------- -[![Linting and Testing](https://github.com/TourmalineCore/c2pie/actions/workflows/lint-and-test.yml/badge.svg?branch=develop)](https://github.com/TourmalineCore/c2pie/actions/workflows/lint-and-test.yml) +[![Linting](https://github.com/TourmalineCore/c2pie/actions/workflows/lint-on-pull-request.yml/badge.svg?branch=develop)](https://github.com/TourmalineCore/c2pie/actions/workflows/lint-on-pull-request.yml) [![c2pa](https://img.shields.io/badge/c2pa-v1.4-seagreen.svg)](https://c2pa.org/) -[![coverage](https://img.shields.io/badge/coverage-87%25-olivedrab?logo=codecov&logoColor=ff9d1c)](https://github.com/TourmalineCore/c2pie/actions/workflows/lint-and-test.yml) +[![coverage](https://img.shields.io/badge/e2e_coverage-82.33%25-olivedrab)](https://github.com/TourmalineCore/c2pie/actions/workflows/calculate-tests-coverage-on-pull-request.yml) +[![coverage](https://img.shields.io/badge/units_coverage-86.53%25-olivedrab)](https://github.com/TourmalineCore/c2pie/actions/workflows/calculate-tests-coverage-on-pull-request.yml) +[![coverage](https://img.shields.io/badge/full_coverage-92.17%25-forestgreen)](https://github.com/TourmalineCore/c2pie/actions/workflows/calculate-tests-coverage-on-pull-request.yml) [![latest](https://img.shields.io/pypi/v/c2pie?label=latest&colorB=fc8021)](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/