diff --git a/.github/workflows/build-wheel.yml b/.github/workflows/build-wheel.yml new file mode 100644 index 00000000..819b4d0b --- /dev/null +++ b/.github/workflows/build-wheel.yml @@ -0,0 +1,150 @@ +name: Build Wheel + +on: + workflow_call: + inputs: + python-version: + required: true + type: string + architecture: + required: false + type: string + artifact-name: + required: true + type: string + runs-on: + required: true + type: string + default: 'ubuntu-latest' + c2pa-version: + required: true + type: string + secrets: + github-token: + required: true + +permissions: + contents: read + packages: read + actions: read + +env: + GITHUB_TOKEN: ${{ secrets.github-token }} + C2PA_VERSION: ${{ inputs.c2pa-version }} + +jobs: + build: + runs-on: ${{ inputs.runs-on }} + steps: + - uses: actions/checkout@v4 + + - name: Build Linux wheels + if: runner.os == 'Linux' + run: | + # Create necessary directories + mkdir -p artifacts + mkdir -p src/c2pa/libs + rm -rf dist build + + # Set Docker image and platform tag based on architecture + if [ "${{ inputs.architecture }}" = "aarch64" ]; then + DOCKER_IMAGE="quay.io/pypa/manylinux_2_28_aarch64" + PLATFORM_TAG="manylinux_2_28_aarch64" + else + DOCKER_IMAGE="quay.io/pypa/manylinux_2_28_x86_64" + PLATFORM_TAG="manylinux_2_28_x86_64" + fi + + # Build wheel in Docker container + docker run --rm -v $PWD:/io $DOCKER_IMAGE bash -c " + yum install -y gcc gcc-c++ make && + mkdir -p /io/artifacts /io/src/c2pa/libs && + rm -rf /io/dist /io/build && + cd /io && + /opt/python/cp310-cp310/bin/pip install -r requirements.txt -r requirements-dev.txt && + /opt/python/cp310-cp310/bin/pip install toml && + C2PA_LIBS_PLATFORM=\"${{ inputs.architecture == 'aarch64' && 'aarch64-unknown-linux-gnu' || 'x86_64-unknown-linux-gnu' }}\" /opt/python/cp310-cp310/bin/python scripts/download_artifacts.py $C2PA_VERSION && + for PYBIN in /opt/python/cp3{10,11}-*/bin; do + \${PYBIN}/pip install --upgrade pip wheel && + \${PYBIN}/pip install toml && + CFLAGS=\"-I/opt/python/cp310-cp310/include/python3.10\" LDFLAGS=\"-L/opt/python/cp310-cp310/lib\" \${PYBIN}/python setup.py bdist_wheel --plat-name $PLATFORM_TAG + done && + rm -f /io/dist/*-linux_*.whl + " + + # Verify the wheel was built + echo "Contents of dist directory:" + ls -la dist/ + echo "Number of wheels found:" + find dist -name "*.whl" | wc -l + echo "Wheel filenames:" + find dist -name "*.whl" -exec basename {} \; + + - name: Build Windows wheel (x64) + if: runner.os == 'Windows' + shell: pwsh + run: | + # Create necessary directories + New-Item -ItemType Directory -Force -Path artifacts + New-Item -ItemType Directory -Force -Path src/c2pa/libs + if (Test-Path dist) { Remove-Item -Recurse -Force dist } + if (Test-Path build) { Remove-Item -Recurse -Force build } + + # Install dependencies + pip install -r requirements.txt + pip install -r requirements-dev.txt + pip install wheel + + # Download native artifacts + Write-Host "Starting artifact download process..." + Write-Host "C2PA_VERSION: $env:C2PA_VERSION" + + python scripts/download_artifacts.py "$env:C2PA_VERSION" + + Write-Host "Artifacts directory contents:" + Get-ChildItem -Recurse -Path artifacts + Write-Host "src/c2pa/libs directory contents:" + Get-ChildItem -Recurse -Path src/c2pa/libs + + # Build wheel + python setup.py bdist_wheel --plat-name win_amd64 + + - name: Build macOS wheel (Apple Silicon) + if: runner.os == 'macOS' && runner.arch == 'arm64' + run: | + # Create necessary directories + mkdir -p artifacts + mkdir -p src/c2pa/libs + rm -rf dist build + + # Install dependencies + pip install -r requirements.txt + pip install -r requirements-dev.txt + pip install wheel + + # Download native artifacts + python scripts/download_artifacts.py $C2PA_VERSION + + # Build wheel + python setup.py bdist_wheel --plat-name macosx_11_0_arm64 + + # Rename wheel to ensure unique filename + cd dist + for wheel in *.whl; do + mv "$wheel" "${wheel/macosx_11_0_arm64/macosx_11_0_arm64}" + done + cd .. + + - name: Log wheel filename + if: runner.os == 'Linux' || runner.os == 'macOS' + shell: bash + run: | + echo "Built wheel:" + ls -l dist/*.whl + + - name: Upload wheels + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: dist + if-no-files-found: error \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7876c18e..8740a0f1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,10 +19,26 @@ on: required: true default: 'false' +permissions: + contents: read + packages: read + actions: read + jobs: - tests: - name: Unit tests + read-version: + name: Read C2PA version + runs-on: ubuntu-latest + outputs: + c2pa-native-version: ${{ steps.read-version.outputs.version }} + steps: + - uses: actions/checkout@v4 + - name: Read version from file + id: read-version + run: echo "version=$(cat c2pa-native-version.txt | tr -d '\r\n')" >> $GITHUB_OUTPUT + tests-unix: + name: Unit tests for developer setup (Unix) + needs: read-version if: | github.event_name != 'pull_request' || github.event.pull_request.author_association == 'COLLABORATOR' || @@ -35,25 +51,53 @@ jobs: strategy: fail-fast: false matrix: - os: [ windows-latest, macos-latest, ubuntu-latest ] - rust_version: [ stable, 1.76.0 ] + os: [ macos-latest, ubuntu-latest ] steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@master + - name: Set up Python + uses: actions/setup-python@v5 with: - toolchain: ${{ matrix.rust_version }} - components: llvm-tools-preview + python-version: "3.10" + cache: "pip" - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 + - name: Install project dependencies + run: python -m pip install -r requirements.txt - clippy_check: - name: Clippy + - name: Install project development dependencies + run: python -m pip install -r requirements-dev.txt + - name: Prepare build directories + run: | + mkdir -p artifacts + mkdir -p src/c2pa/libs + rm -rf dist/* build/* + + - name: Download native artifacts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Ensure the token is being used + echo "Using GitHub token for authentication" + python3 scripts/download_artifacts.py ${{ needs.read-version.outputs.c2pa-native-version }} + + - name: Install package in development mode + run: | + pip uninstall -y c2pa + pip install -e . + + - name: Verify installation + run: | + python3 -c "from c2pa import C2paError; print('C2paError imported successfully')" + + - name: Run tests + run: python3 ./tests/test_unit_tests.py + + tests-windows: + name: Unit tests for developer setup (Windows) + needs: read-version if: | github.event_name != 'pull_request' || github.event.pull_request.author_association == 'COLLABORATOR' || @@ -61,26 +105,79 @@ jobs: github.event.pull_request.user.login == 'dependabot[bot]' || contains(github.event.pull_request.labels.*.name, 'safe to test') - runs-on: ubuntu-latest + runs-on: windows-latest steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + - name: Set up Python + uses: actions/setup-python@v5 with: - components: clippy + python-version: "3.10" + cache: "pip" - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 + - name: Install project dependencies + run: python -m pip install -r requirements.txt - - name: Run Clippy - run: cargo clippy --all-features --all-targets -- -Dwarnings + - name: Install project development dependencies + run: python -m pip install -r requirements-dev.txt - cargo_fmt: - name: Enforce Rust code format + - name: Prepare build directories + run: | + New-Item -ItemType Directory -Force -Path artifacts + New-Item -ItemType Directory -Force -Path src\c2pa\libs + if (Test-Path dist) { Remove-Item -Recurse -Force dist } + if (Test-Path build) { Remove-Item -Recurse -Force build } + - name: Check GitHub API rate limit + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "Checking GitHub API rate limit..." + curl -s -H "Authorization: token $env:GITHUB_TOKEN" https://api.github.com/rate_limit + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to check rate limit" + exit 1 + } + + - name: Download native artifacts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Ensure the token is being used + echo "Using GitHub token for authentication" + python scripts\download_artifacts.py ${{ needs.read-version.outputs.c2pa-native-version }} + + - name: Install package in development mode + run: | + pip uninstall -y c2pa + pip install -e . + + - name: Verify installation + run: | + python -c "from c2pa import C2paError; print('C2paError imported successfully')" + + - name: Run tests + run: python .\tests\test_unit_tests.py + + build-linux-wheel: + name: Build Linux wheel + uses: ./.github/workflows/build-wheel.yml + needs: [tests-unix, read-version] + with: + python-version: "3.10" + architecture: ${{ matrix.target }} + artifact-name: wheels-linux-${{ matrix.target }} + runs-on: ${{ matrix.runs-on }} + c2pa-version: ${{ needs.read-version.outputs.c2pa-native-version }} + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} + strategy: + matrix: + include: + - target: x86_64 + runs-on: ubuntu-24.04 if: | github.event_name != 'pull_request' || github.event.pull_request.author_association == 'COLLABORATOR' || @@ -88,23 +185,15 @@ jobs: github.event.pull_request.user.login == 'dependabot[bot]' || contains(github.event.pull_request.labels.*.name, 'safe to test') - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install nightly toolchain - uses: dtolnay/rust-toolchain@nightly - with: - components: rustfmt - - - name: Check format - run: cargo +nightly fmt --all -- --check - - docs_rs: - name: Preflight docs.rs build - + test-built-linux-wheel: + name: Test Linux built wheel + needs: build-linux-wheel + runs-on: ${{ matrix.runs-on }} + strategy: + matrix: + include: + - target: x86_64 + runs-on: ubuntu-24.04 if: | github.event_name != 'pull_request' || github.event.pull_request.author_association == 'COLLABORATOR' || @@ -112,30 +201,62 @@ jobs: github.event.pull_request.user.login == 'dependabot[bot]' || contains(github.event.pull_request.labels.*.name, 'safe to test') - runs-on: ubuntu-latest - steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Install nightly Rust toolchain - # Nightly is used here because the docs.rs build - # uses nightly and we use doc_cfg features that are - # not in stable Rust as of this writing (Rust 1.76). - uses: dtolnay/rust-toolchain@nightly - - - name: Run cargo docs - # This is intended to mimic the docs.rs build - # environment. The goal is to fail PR validation - # if the subsequent release would result in a failed - # documentation build on docs.rs. - run: cargo +nightly doc --workspace --all-features --no-deps - env: - RUSTDOCFLAGS: --cfg docsrs - DOCS_RS: 1 - cargo-deny: - name: License / vulnerability audit + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: "pip" + + - name: Download wheel artifacts + uses: actions/download-artifact@v4 + with: + name: wheels-linux-${{ matrix.target }} + path: dist + - name: Create and activate virtual environment + run: | + python -m venv venv + source venv/bin/activate + + - name: Install wheel for testing + run: | + source venv/bin/activate + pip install dist/c2pa_python-*.whl + + - name: Run unittest tests on installed wheel + run: | + source venv/bin/activate + python ./tests/test_unit_tests.py + + - name: Install pytest (in venv) + run: | + source venv/bin/activate + pip install pytest + + - name: Run tests with pytest (venv) + run: | + source venv/bin/activate + venv/bin/pytest tests/test_unit_tests.py -v + + build-windows-wheel: + name: Build Windows wheel + uses: ./.github/workflows/build-wheel.yml + needs: [tests-windows, read-version] + with: + python-version: "3.10" + architecture: ${{ matrix.target }} + artifact-name: wheels-windows-${{ matrix.target }} + runs-on: windows-latest + c2pa-version: ${{ needs.read-version.outputs.c2pa-native-version }} + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} + strategy: + matrix: + target: [x64] if: | github.event_name != 'pull_request' || github.event.pull_request.author_association == 'COLLABORATOR' || @@ -143,30 +264,13 @@ jobs: github.event.pull_request.user.login == 'dependabot[bot]' || contains(github.event.pull_request.labels.*.name, 'safe to test') - runs-on: ubuntu-latest - + test-built-windows-wheel: + name: Test Windows built wheel + needs: build-windows-wheel + runs-on: windows-latest strategy: - fail-fast: false matrix: - checks: - - advisories - - bans licenses sources - - # Prevent sudden announcement of a new advisory from failing CI: - continue-on-error: ${{ matrix.checks == 'advisories' }} - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Audit crate dependencies - uses: EmbarkStudios/cargo-deny-action@v2 - with: - command: check ${{ matrix.checks }} - - unused_deps: - name: Check for unused dependencies - + target: [x64] if: | github.event_name != 'pull_request' || github.event.pull_request.author_association == 'COLLABORATOR' || @@ -174,78 +278,63 @@ jobs: github.event.pull_request.user.login == 'dependabot[bot]' || contains(github.event.pull_request.labels.*.name, 'safe to test') - runs-on: ubuntu-latest - steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Install nightly Rust toolchain - uses: dtolnay/rust-toolchain@nightly - - - name: Run cargo-udeps - uses: aig787/cargo-udeps-action@v1 - with: - version: latest - args: --all-targets --all-features - - linux: - runs-on: ubuntu-latest - - if: | - github.event_name != 'pull_request' || - github.event.pull_request.author_association == 'COLLABORATOR' || - github.event.pull_request.author_association == 'MEMBER' || - github.event.pull_request.user.login == 'dependabot[bot]' || - contains(github.event.pull_request.labels.*.name, 'safe to test') - - strategy: - matrix: - target: [x86_64, aarch64] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - name: Set up Python + uses: actions/setup-python@v5 with: python-version: "3.10" cache: "pip" - - run: pip install -r requirements.txt - - name: Setup QEMU - uses: docker/setup-qemu-action@v1 - if: ${{ matrix.target == 'aarch64' }} - - name: Build wheels - uses: PyO3/maturin-action@v1 - with: - target: ${{ matrix.target }} - maturin-version: "1.2.0" - args: --release --out dist --find-interpreter - sccache: "true" - manylinux: ${{ matrix.target == 'aarch64' && 'manylinux_2_28' || 'auto' }} - before-script-linux: | - pip install uniffi-bindgen==0.24.1 - - # ISSUE: https://github.com/sfackler/rust-openssl/issues/2036#issuecomment-1724324145 - # If we're running on rhel centos, install needed packages. - if command -v yum &> /dev/null; then - yum update -y && yum install -y perl-core openssl openssl-devel pkgconfig libatomic - - # If we're running on i686 we need to symlink libatomic - # in order to build openssl with -latomic flag. - if [[ ! -d "/usr/lib64" ]]; then - ln -s /usr/lib/libatomic.so.1 /usr/lib/libatomic.so - fi - else - # If we're running on debian-based system. - apt update -y && apt-get install -y libssl-dev openssl pkg-config - fi - - name: Upload wheels - uses: actions/upload-artifact@v4 + + - name: Download wheel artifacts + uses: actions/download-artifact@v4 with: - name: wheels-${{ matrix.target }} + name: wheels-windows-${{ matrix.target }} path: dist - windows: - runs-on: windows-latest - + - name: Create and activate virtual environment + run: | + python -m venv venv + .\venv\Scripts\activate + + - name: Install wheel for testing + run: | + .\venv\Scripts\activate + $wheel = Get-ChildItem -Path dist -Filter "c2pa_python-*.whl" | Select-Object -First 1 + if (-not $wheel) { Write-Error "No wheel file found in dist directory"; exit 1 } + pip install $wheel.FullName + + - name: Run unittest tests on installed wheel + run: | + .\venv\Scripts\activate + python .\tests\test_unit_tests.py + + - name: Install pytest (in venv) + run: | + .\venv\Scripts\activate + pip install pytest + + - name: Run tests with pytest (venv) + run: | + .\venv\Scripts\activate + .\venv\Scripts\pytest .\tests\test_unit_tests.py -v + + build-macos-wheel: + name: Build macOS wheel + uses: ./.github/workflows/build-wheel.yml + needs: [tests-unix, read-version] + with: + python-version: "3.10" + artifact-name: wheels-macos-${{ matrix.target }} + runs-on: macos-latest + c2pa-version: ${{ needs.read-version.outputs.c2pa-native-version }} + secrets: + github-token: ${{ secrets.GITHUB_TOKEN }} + strategy: + matrix: + target: [aarch64] if: | github.event_name != 'pull_request' || github.event.pull_request.author_association == 'COLLABORATOR' || @@ -253,32 +342,13 @@ jobs: github.event.pull_request.user.login == 'dependabot[bot]' || contains(github.event.pull_request.labels.*.name, 'safe to test') + test-built-macos-wheel: + name: Test macOS built wheel + needs: build-macos-wheel + runs-on: macos-latest strategy: matrix: - target: [x64, x86] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - architecture: ${{ matrix.target }} - cache: "pip" - - run: pip install -r requirements.txt - - name: Build wheels - uses: PyO3/maturin-action@v1 - with: - target: ${{ matrix.target }} - args: --release --out dist --find-interpreter - sccache: 'true' - - name: Upload wheels - uses: actions/upload-artifact@v4 - with: - name: wheels-${{ matrix.target }} - path: dist - - macos_x86: - runs-on: macos-latest - + target: [aarch64] if: | github.event_name != 'pull_request' || github.event.pull_request.author_association == 'COLLABORATOR' || @@ -287,56 +357,49 @@ jobs: contains(github.event.pull_request.labels.*.name, 'safe to test') steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: "3.10" cache: "pip" - - run: pip install -r requirements.txt - - name: Build wheels - uses: PyO3/maturin-action@v1 - with: - target: x86_64 - args: --release --out dist --find-interpreter - sccache: 'true' - - name: Upload wheels - uses: actions/upload-artifact@v4 + + - name: Download wheel artifacts + uses: actions/download-artifact@v4 with: - name: wheels-mac-x86_64 + name: wheels-macos-${{ matrix.target }} path: dist - macos_aarch64: - runs-on: macos-latest-large + - name: Create and activate virtual environment + run: | + python -m venv venv + source venv/bin/activate - if: | - github.event_name != 'pull_request' || - github.event.pull_request.author_association == 'COLLABORATOR' || - github.event.pull_request.author_association == 'MEMBER' || - github.event.pull_request.user.login == 'dependabot[bot]' || - contains(github.event.pull_request.labels.*.name, 'safe to test') + - name: Install wheel for testing + run: | + source venv/bin/activate + pip install dist/c2pa_python-*.whl - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - cache: "pip" - - run: pip install -r requirements.txt - - name: Build wheels - uses: PyO3/maturin-action@v1 - with: - target: aarch64 - args: --release --out dist --find-interpreter - sccache: 'true' - - name: Upload wheels - uses: actions/upload-artifact@v4 - with: - name: wheels-mac-aarch64 - path: dist + - name: Run unittest tests on installed wheel + run: | + source venv/bin/activate + python ./tests/test_unit_tests.py + + - name: Install pytest (in venv) + run: | + source venv/bin/activate + pip install pytest + + - name: Run tests with pytest (venv) + run: | + source venv/bin/activate + venv/bin/pytest tests/test_unit_tests.py -v sdist: runs-on: ubuntu-latest - + needs: [tests-unix, tests-windows] if: | github.event_name != 'pull_request' || github.event.pull_request.author_association == 'COLLABORATOR' || @@ -346,15 +409,20 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Build sdist - uses: PyO3/maturin-action@v1 + - uses: actions/setup-python@v5 with: - command: sdist - args: --out dist + python-version: "3.10" + cache: "pip" + - name: Install dependencies + run: pip install -r requirements.txt + - name: Install dev dependencies for build + run: pip install -r requirements-dev.txt + - name: Build sdist + run: python -m build --sdist - name: Upload sdist uses: actions/upload-artifact@v4 with: - name: wheels + name: wheels-sdist path: dist release: @@ -362,25 +430,27 @@ jobs: if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true' runs-on: ubuntu-latest environment: pypipublish - needs: [linux, windows, macos_x86, macos_aarch64, sdist] + needs: [test-built-linux-wheel, test-built-macos-wheel, test-built-windows-wheel, sdist] permissions: id-token: write contents: read steps: - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - name: Create dist directory + run: mkdir -p dist + - name: Download all wheels + uses: actions/download-artifact@v4 with: pattern: wheels-* path: dist merge-multiple: true - - name: List contents of dist directory - run: ls -la dist/ - - name: Publish to PyPI + - name: Publish to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: dist - # verbose: true - # print-hash: true + # Uncomment to use TestPyPI + # repository-url: https://test.pypi.org/legacy/ + verbose: true # Uncomment below for test runs, otherwise fails on existing packages being reuploaded skip-existing: true diff --git a/.gitignore b/.gitignore index c96c5aa5..30d941cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,116 @@ -# Generated by Cargo -# will have compiled files and executables -debug/ -target/ -.idea +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +/artifacts +scripts/artifacts +tests/fixtures/temp_data + +# For the examples +/output + +# C extensions +*.so +*.dll +*.dylib + +# Virtual environment +env/ venv/ +ENV/ .venv/ +.ENV/ -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg -# These are backup files generated by rustfmt -**/*.rs.bk +# PyInstaller +# Usually these files are written by a Python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb +# Installer logs +pip-log.txt +pip-delete-this-directory.txt -# Python caches -__pycache__/ +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +*.cover +*.py,cover +.hypothesis/ .pytest_cache/ -dist +nosetests.xml +coverage.xml +*.coveragerc + -c2pa/c2pa/ +# pyenv +.python-version -# Mac OS X files + +# Environments +.env +.env.* +*.env + +# IDEs and editors +.idea/ +.vscode/ +*.swp +*.swo +*.sublime-project +*.sublime-workspace + +# macOS .DS_Store +.AppleDouble +.LSOverride + +# Linux +*~ + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +# Temporary files +*.tmp +*.temp +*.log +*.bak +*.old +*.orig +*.rej +*.sublime-workspace + +# C2PA-specific +target/ +*.pem +*.key +*.dylib +*.dll +*.so +src/c2pa/libs/ diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 4c9aee32..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "rust-analyzer.linkedProjects": [ - "./Cargo.toml" - ], - "rust-analyzer.showUnlinkedFileNotification": false -} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index daa56852..00000000 --- a/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "c2pa-python" -version = "0.9.0" -edition = "2021" -authors = ["Gavin Peacock +## Project Structure -Additional documentation: -- [Using the Python library](docs/usage.md) -- [Release notes](docs/release-notes.md) -- [Contributing to the project](docs/project-contributions.md) +```bash +. +├── .github/ # GitHub configuration files +├── artifacts/ # Platform-specific libraries for building (per subfolder) +│ └── your_target_platform/ # Platform-specific artifacts +├── docs/ # Project documentation +├── examples/ # Example scripts demonstrating usage +├── scripts/ # Utility scripts (eg. artifacts download) +├── src/ # Source code +│ └── c2pa/ # Main package directory +│ └── libs/ # Platform-specific libraries +├── tests/ # Unit tests and benchmarks +├── .gitignore # Git ignore rules +├── Makefile # Build and development commands +├── pyproject.toml # Python project configuration +├── requirements.txt # Python dependencies +├── requirements-dev.txt # Development dependencies +└── setup.py # Package setup script +``` + +## Package installation - +The c2pa-python package is published to PyPI. You can install it from there by running: + +```bash +pip install c2pa-python +``` + +To use the module in your Python code, import like this: + +```python +import c2pa +``` -## Installation +## Examples -Install from PyPI by entering this command: +### Adding a "Do Not Train" Assertion + +The `examples/training.py` script demonstrates how to add a "Do Not Train" assertion to an asset and verify it. + +### Signing and Verifying Assets + +The `examples/sign.py` script shows how to sign an asset with a C2PA manifest and verify it. + +## Development Setup + +1. Create and activate a virtual environment with native dependencies: ```bash -pip install -U c2pa-python +# Create virtual environment +python -m venv .venv + +# Activate virtual environment +# On Windows: +.venv\Scripts\activate +# On macOS/Linux: +source .venv/bin/activate + +# load project dependencies +pip install -r requirements.txt +pip install -r requirements-dev.txt + +# download library artifacts for the current version you want, eg v0.55.0 +python scripts/download_artifacts.py c2pa-v0.55.0 ``` -This is a platform wheel built with Rust that works on Windows, macOS, and most Linux distributions (using [manylinux](https://github.com/pypa/manylinux)). If you need to run on another platform, see [Project contributions - Development](docs/project-contributions.md#development) for information on how to build from source. +2. Install the package in development mode: -### Updating +```bash +pip install -e . +``` + +This will: + +- Copy the appropriate libraries for your platform from `artifacts/` to `src/c2pa/libs/` +- Install the package in development mode, allowing you to make changes to the Python code without reinstalling + +## Building Wheels + +To build wheels for all platforms that have libraries in the `artifacts/` directory: + +```bash +python setup.py bdist_wheel +``` -Determine what version you've got by entering this command: +You can use `twine` to verify the wheels have correct metadata: ```bash -pip list | grep c2pa-python +twine check dist/* ``` -If the version shown is lower than the most recent version, then update by [reinstalling](#installation). +This will create platform-specific wheels in the `dist/` directory. -### Reinstalling +## Running Tests -If you tried unsuccessfully to install this package before the [0.40 release](https://github.com/contentauth/c2pa-python/releases/tag/v0.4), then use this command to reinstall: +Run the tests: ```bash -pip install --upgrade --force-reinstall c2pa-python +make test ``` -## Supported formats +Alternatively, install pytest (if not already installed): -The Python library [supports the same media file formats](https://github.com/contentauth/c2pa-rs/blob/main/docs/supported-formats.md) as the Rust library. +```bash +pip install pytest +``` -## License +And run: -This package is distributed under the terms of both the [MIT license](https://github.com/contentauth/c2pa-python/blob/main/LICENSE-MIT) and the [Apache License (Version 2.0)](https://github.com/contentauth/c2pa-python/blob/main/LICENSE-APACHE). +```bash +pytest +``` -Note that some components and dependent crates are licensed under different terms; please check the license terms for each crate and component for details. +## Contributing -### Contributions and feedback +Contributions are welcome! Please fork the repository and submit a pull request. + +## License -We welcome contributions to this project. For information on contributing, providing feedback, and about ongoing work, see [Contributing](https://github.com/contentauth/c2pa-python/blob/main/CONTRIBUTING.md). +This project is licensed under the Apache License 2.0 or the MIT License. See the LICENSE-MIT and LICENSE-APACHE files for details. diff --git a/build.rs b/build.rs deleted file mode 100644 index b6899e9c..00000000 --- a/build.rs +++ /dev/null @@ -1,4 +0,0 @@ -// Purpose: Generate the scaffolding for the uniffi library. -fn main() { - let _result = uniffi::generate_scaffolding("./src/c2pa.udl"); -} diff --git a/c2pa-native-version.txt b/c2pa-native-version.txt new file mode 100644 index 00000000..ec71097a --- /dev/null +++ b/c2pa-native-version.txt @@ -0,0 +1 @@ +c2pa-v0.55.0 \ No newline at end of file diff --git a/c2pa/__init__.py b/c2pa/__init__.py deleted file mode 100644 index 013d42a0..00000000 --- a/c2pa/__init__.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright 2024 Adobe. All rights reserved. -# This file is licensed to you under the Apache License, -# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) -# or the MIT license (http://opensource.org/licenses/MIT), -# at your option. - -# Unless required by applicable law or agreed to in writing, -# this software is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or -# implied. See the LICENSE-MIT and LICENSE-APACHE files for the -# specific language governing permissions and limitations under -# each license. - -from .c2pa_api import ( - Reader, - Builder, - create_signer, - create_remote_signer, - sign_ps256, - load_settings_file, -) -from .c2pa import Error, SigningAlg, CallbackSigner, sdk_version, version, load_settings -from .c2pa.c2pa import ( - _UniffiConverterTypeSigningAlg, - _UniffiConverterTypeReader, - _UniffiRustBuffer, -) - -__all__ = [ - "Reader", - "Builder", - "CallbackSigner", - "create_signer", - "sign_ps256", - "Error", - "SigningAlg", - "sdk_version", - "version", - "load_settings", - "load_settings_file", - "create_remote_signer", - "_UniffiConverterTypeSigningAlg", - "_UniffiRustBuffer", - "_UniffiConverterTypeReader", -] diff --git a/c2pa/c2pa_api/__init__.py b/c2pa/c2pa_api/__init__.py deleted file mode 100644 index 99aab45b..00000000 --- a/c2pa/c2pa_api/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .c2pa_api import * \ No newline at end of file diff --git a/c2pa/c2pa_api/c2pa_api.py b/c2pa/c2pa_api/c2pa_api.py deleted file mode 100644 index a9d1f5b9..00000000 --- a/c2pa/c2pa_api/c2pa_api.py +++ /dev/null @@ -1,265 +0,0 @@ -# Copyright 2024 Adobe. All rights reserved. -# This file is licensed to you under the Apache License, -# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) -# or the MIT license (http://opensource.org/licenses/MIT), -# at your option. - -# Unless required by applicable law or agreed to in writing, -# this software is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or -# implied. See the LICENSE-MIT and LICENSE-APACHE files for the -# specific language governing permissions and limitations under -# each license. - -import json -import os -import sys -import tempfile - -PROJECT_PATH = os.getcwd() -SOURCE_PATH = os.path.join( - PROJECT_PATH,"target","python" -) -sys.path.append(SOURCE_PATH) - -import c2pa.c2pa as api - -# This module provides a simple Python API for the C2PA library. - -# Reader is used to read a manifest store from a stream or file. -# It performs full validation on the manifest store. -# It also supports writing resources to a stream or file. -# -# Example: -# reader = Reader("image/jpeg", open("test.jpg", "rb")) -# json = reader.json() -class Reader(api.Reader): - def __init__(self, format, stream, manifest_data=None): - super().__init__() - if manifest_data is not None: - self.from_manifest_data_and_stream(manifest_data, format, C2paStream(stream)) - else: - self.from_stream(format, C2paStream(stream)) - - @classmethod - def from_file(cls, path: str, format=None): - with open(path, "rb") as file: - if format is None: - # determine the format from the file extension - format = os.path.splitext(path)[1][1:] - return cls(format, file) - - def get_manifest(self, label): - manifest_store = json.loads(self.json()) - return manifest_store["manifests"].get(label) - - def get_active_manifest(self): - manifest_store = json.loads(self.json()) - active_label = manifest_store.get("active_manifest") - if active_label: - return manifest_store["manifests"].get(active_label) - return None - - def resource_to_stream(self, uri, stream) -> None: - return super().resource_to_stream(uri, C2paStream(stream)) - - def resource_to_file(self, uri, path) -> None: - with open(path, "wb") as file: - return self.resource_to_stream(uri, file) - -# The Builder is used to construct a new Manifest and add it to a stream or file. -# The initial manifest is defined by a Manifest Definition dictionary. -# It supports adding resources from a stream or file. -# It supports adding ingredients from a stream or file. -# It supports signing the asset with a signer to a stream or file. -# -# Example: -# manifest = { -# "claim_generator_info": [{ -# "name": "python_test", -# "version": "0.1" -# }], -# "title": "My Title", -# "thumbnail": { -# "format": "image/jpeg", -# "identifier": "thumbnail" -# } -# } -# builder = Builder(manifest) -# builder.add_resource_file("thumbnail", "thumbnail.jpg") -# builder.add_ingredient_file({"parentOf": true}, "B.jpg") -# builder.sign_file(signer, "test.jpg", "signed.jpg") -class Builder(api.Builder): - def __init__(self, manifest): - super().__init__() - self.set_manifest(manifest) - - def set_manifest(self, manifest): - if not isinstance(manifest, str): - manifest = json.dumps(manifest) - super().with_json(manifest) - return self - - def add_resource(self, uri, stream): - return super().add_resource(uri, C2paStream(stream)) - - def add_resource_file(self, uri, path): - with open(path, "rb") as file: - return self.add_resource(uri, file) - - def add_ingredient(self, ingredient, format, stream): - if not isinstance(ingredient, str): - ingredient = json.dumps(ingredient) - return super().add_ingredient(ingredient, format, C2paStream(stream)) - - def add_ingredient_file(self, ingredient, path): - format = os.path.splitext(path)[1][1:] - if "title" not in ingredient: - if isinstance(ingredient, str): - ingredient = json.loads(ingredient) - ingredient["title"] = os.path.basename(path) - with open(path, "rb") as file: - return self.add_ingredient(ingredient, format, file) - - def to_archive(self, stream): - return super().to_archive(C2paStream(stream)) - - @classmethod - def from_archive(cls, stream): - self = cls({}) - super().from_archive(self, C2paStream(stream)) - return self - - def sign(self, signer, format, input, output = None): - return super().sign(signer, format, C2paStream(input), C2paStream(output)) - - def sign_file(self, signer, sourcePath, outputPath): - return super().sign_file(signer, sourcePath, outputPath) - - -# Implements a C2paStream given a stream handle -# This is used to pass a file handle to the c2pa library -# It is used by the Reader and Builder classes internally -class C2paStream(api.Stream): - def __init__(self, stream): - self.stream = stream - - def read_stream(self, length: int) -> bytes: - #print("Reading " + str(length) + " bytes") - return self.stream.read(length) - - def seek_stream(self, pos: int, mode: api.SeekMode) -> int: - whence = 0 - if mode is api.SeekMode.CURRENT: - whence = 1 - elif mode is api.SeekMode.END: - whence = 2 - #print("Seeking to " + str(pos) + " with whence " + str(whence)) - return self.stream.seek(pos, whence) - - def write_stream(self, data: str) -> int: - #print("Writing " + str(len(data)) + " bytes") - return self.stream.write(data) - - def flush_stream(self) -> None: - self.stream.flush() - - # A shortcut method to open a C2paStream from a path/mode - def open_file(path: str, mode: str) -> api.Stream: - return C2paStream(open(path, mode)) - - -# Internal class to implement signer callbacks -# We need this because the callback expects a class with a sign method -class SignerCallback(api.SignerCallback): - def __init__(self, callback): - self.sign = callback - super().__init__() - - -# Convenience class so we can just pass in a callback function -#class CallbackSigner(c2pa.CallbackSigner): -# def __init__(self, callback, alg, certs, timestamp_url=None): -# cb = SignerCallback(callback) -# super().__init__(cb, alg, certs, timestamp_url) - -# Creates a Signer given a callback and configuration values -# It is used by the Builder class to sign the asset -# -# Example: -# def sign_ps256(data: bytes) -> bytes: -# return c2pa_api.sign_ps256_shell(data, "tests/fixtures/ps256.pem") -# -# certs = open("tests/fixtures/ps256.pub", "rb").read() -# signer = c2pa_api.create_signer(sign_ps256, "ps256", certs, "http://timestamp.digicert.com") -# -def create_signer(callback, alg, certs, timestamp_url=None): - return api.CallbackSigner(SignerCallback(callback), alg, certs, timestamp_url) - -# Because we "share" SigningAlg enum in-between bindings, -# seems we need to manually coerce the enum types, -# like unffi itself does too -def convert_to_alg(alg): - match str(alg): - case "SigningAlg.ES256": - return api.SigningAlg.ES256 - case "SigningAlg.ES384": - return api.SigningAlg.ES384 - case "SigningAlg.ES512": - return api.SigningAlg.ES512 - case "SigningAlg.PS256": - return api.SigningAlg.PS256 - case "SigningAlg.PS384": - return api.SigningAlg.PS384 - case "SigningAlg.PS512": - return api.SigningAlg.PS512 - case "SigningAlg.ED25519": - return api.SigningAlg.ED25519 - case _: - raise ValueError("Unsupported signing algorithm: " + str(alg)) - -# Creates a special case signer that uses direct COSE handling -# The callback signer should also define the signing algorithm to use -# And a way to find out the needed reserve size -def create_remote_signer(callback): - return api.CallbackSigner.new_from_signer( - callback, - convert_to_alg(callback.alg()), - callback.reserve_size() - ) - -# Example of using openssl in an os shell to sign data using Ps256 -# Note: the openssl command line tool must be installed for this to work -def sign_ps256_shell(data: bytes, key_path: str) -> bytes: - with tempfile.NamedTemporaryFile() as bytes: - bytes.write(data) - signature = tempfile.NamedTemporaryFile() - os.system("openssl dgst -sha256 -sign {} -out {} {}".format(key_path, signature.name, bytes.name)) - return signature.read() - -# Example of using python crypto to sign data using openssl with Ps256 -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import padding - -def sign_ps256(data: bytes, key: bytes) -> bytes: - private_key = serialization.load_pem_private_key( - key, - password=None, - ) - signature = private_key.sign( - data, - padding.PSS( - mgf=padding.MGF1(hashes.SHA256()), - salt_length=padding.PSS.MAX_LENGTH - ), - hashes.SHA256() - ) - return signature - -def load_settings_file(path: str, format=None): - with open(path, "r") as file: - if format is None: - # determine the format from the file extension - format = os.path.splitext(path)[1][1:] - settings = file.read() - api.load_settings(settings, format) \ No newline at end of file diff --git a/examples/sign.py b/examples/sign.py new file mode 100644 index 00000000..82070b42 --- /dev/null +++ b/examples/sign.py @@ -0,0 +1,89 @@ +# Copyright 2025 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +# This example shows how to sign an image with a C2PA manifest +# and read the metadata added to the image. + +import os +import c2pa + +fixtures_dir = os.path.join(os.path.dirname(__file__), "../tests/fixtures/") +output_dir = os.path.join(os.path.dirname(__file__), "../output/") + +# ensure the output directory exists +if not os.path.exists(output_dir): + os.makedirs(output_dir) + +print("c2pa version:") +version = c2pa.sdk_version() +print(version) + +# Read existing C2PA metadata from the file +print("\nReading existing C2PA metadata:") +with open(fixtures_dir + "C.jpg", "rb") as file: + reader = c2pa.Reader("image/jpeg", file) + print(reader.json()) + +# Create a signer from certificate and key files +certs = open(fixtures_dir + "es256_certs.pem", "rb").read() +key = open(fixtures_dir + "es256_private.key", "rb").read() + +signer_info = c2pa.C2paSignerInfo( + alg=b"es256", # Use bytes instead of encoded string + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" # Use bytes and add timestamp URL +) + +signer = c2pa.Signer.from_info(signer_info) + +# Create a manifest definition as a dictionary +manifest_definition = { + "claim_generator": "python_example", + "claim_generator_info": [{ + "name": "python_example", + "version": "0.0.1", + }], + "format": "image/jpeg", + "title": "Python Example Image", + "ingredients": [], + "assertions": [ + { + 'label': 'stds.schema-org.CreativeWork', + 'data': { + '@context': 'http://schema.org/', + '@type': 'CreativeWork', + 'author': [ + {'@type': 'Person', 'name': 'Example User'} + ] + }, + 'kind': 'Json' + } + ] +} + +builder = c2pa.Builder(manifest_definition) + +# Sign the image +print("\nSigning the image...") +with open(fixtures_dir + "C.jpg", "rb") as source: + with open(output_dir + "C_signed.jpg", "wb") as dest: + result = builder.sign(signer, "image/jpeg", source, dest) + +# Read the signed image to verify +print("\nReading signed image metadata:") +with open(output_dir + "C_signed.jpg", "rb") as file: + reader = c2pa.Reader("image/jpeg", file) + print(reader.json()) + +print("\nExample completed successfully!") + diff --git a/examples/training.py b/examples/training.py new file mode 100644 index 00000000..fdacfba2 --- /dev/null +++ b/examples/training.py @@ -0,0 +1,164 @@ +# Copyright 2023 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license. + +# This example shows how to add a do not train assertion to an asset and then verify it +# We use python crypto to sign the data using openssl with Ps256 here + +import json +import os +import sys + +# Example of using python crypto to sign data using openssl with Ps256 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding + +import c2pa + +fixtures_dir = os.path.join(os.path.dirname(__file__), "../tests/fixtures/") +output_dir = os.path.join(os.path.dirname(__file__), "../output/") + +# Ensure the output directory exists +if not os.path.exists(output_dir): + os.makedirs(output_dir) + +# Set up paths to the files we we are using +testFile = os.path.join(fixtures_dir, "A.jpg") +pemFile = os.path.join(fixtures_dir, "ps256.pub") +keyFile = os.path.join(fixtures_dir, "ps256.pem") +testOutputFile = os.path.join(output_dir, "dnt.jpg") + +# A little helper function to get a value from a nested dictionary +from functools import reduce +import operator +def getitem(d, key): + return reduce(operator.getitem, key, d) + + +# This function signs data with PS256 using a private key +def sign_ps256(data: bytes, key: bytes) -> bytes: + private_key = serialization.load_pem_private_key( + key, + password=None, + ) + signature = private_key.sign( + data, + padding.PSS( + mgf=padding.MGF1(hashes.SHA256()), + salt_length=padding.PSS.MAX_LENGTH + ), + hashes.SHA256() + ) + return signature + +# First create an asset with a do not train assertion + +# Define a manifest with the do not train assertion +manifest_json = { + "claim_generator_info": [{ + "name": "python_test", + "version": "0.1" + }], + "title": "Do Not Train Example", + "thumbnail": { + "format": "image/jpeg", + "identifier": "thumbnail" + }, + "assertions": [ + { + "label": "c2pa.training-mining", + "data": { + "entries": { + "c2pa.ai_generative_training": { "use": "notAllowed" }, + "c2pa.ai_inference": { "use": "notAllowed" }, + "c2pa.ai_training": { "use": "notAllowed" }, + "c2pa.data_mining": { "use": "notAllowed" } + } + } + } + ] +} + +ingredient_json = { + "title": "A.jpg", + "relationship": "parentOf", + "thumbnail": { + "identifier": "thumbnail", + "format": "image/jpeg" + } +} + +# V2 signing API example +try: + # Read the private key and certificate files + key = open(keyFile,"rb").read() + certs = open(pemFile,"rb").read() + + # Create a signer using the new API + signer_info = c2pa.C2paSignerInfo( + alg=b"ps256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + signer = c2pa.Signer.from_info(signer_info) + + # Create the builder + builder = c2pa.Builder(manifest_json) + + # Add the thumbnail resource using a stream + with open(fixtures_dir + "A_thumbnail.jpg", "rb") as thumbnail_file: + builder.add_resource("thumbnail", thumbnail_file) + + # Add the ingredient using the correct method + with open(fixtures_dir + "A_thumbnail.jpg", "rb") as ingredient_file: + builder.add_ingredient(json.dumps(ingredient_json), "image/jpeg", ingredient_file) + + if os.path.exists(testOutputFile): + os.remove(testOutputFile) + + # Sign the file using the stream-based sign method + with open(testFile, "rb") as source_file: + with open(testOutputFile, "wb") as dest_file: + result = builder.sign(signer, "image/jpeg", source_file, dest_file) + +except Exception as err: + sys.exit(err) + +print("V2: successfully added do not train manifest to file " + testOutputFile) + + +# now verify the asset and check the manifest for a do not train assertion... + +allowed = True # opt out model, assume training is ok if the assertion doesn't exist +try: + # Create reader using the current API + reader = c2pa.Reader(testOutputFile) + manifest_store = json.loads(reader.json()) + + manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + for assertion in manifest["assertions"]: + if assertion["label"] == "c2pa.training-mining": + if getitem(assertion, ("data","entries","c2pa.ai_training","use")) == "notAllowed": + allowed = False + + # get the ingredient thumbnail and save it to a file using resource_to_stream + uri = getitem(manifest,("ingredients", 0, "thumbnail", "identifier")) + with open(output_dir + "thumbnail_v2.jpg", "wb") as thumbnail_output: + reader.resource_to_stream(uri, thumbnail_output) + +except Exception as err: + sys.exit(err) + +if allowed: + print("Training is allowed") +else: + print("Training is not allowed") diff --git a/pyproject.toml b/pyproject.toml index aed70ed3..4413f8f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,33 +1,37 @@ [build-system] -requires = ["maturin>=1.7.4,<2.0","uniffi_bindgen>=0.28,<0.30"] -build-backend = "maturin" +requires = ["setuptools>=68.0.0", "wheel", "toml>=0.10.2"] +build-backend = "setuptools.build_meta" [project] name = "c2pa-python" -dependencies = ["cffi"] -requires-python = ">=3.7" +version = "0.10.15" +requires-python = ">=3.10" description = "Python bindings for the C2PA Content Authenticity Initiative (CAI) library" readme = { file = "README.md", content-type = "text/markdown" } -license = {file = "LICENSE-MIT"} -keywords = ["C2PA", "CAI", "content credentials", "metadata", "provenance"] +license = { text = "MIT OR Apache-2.0" } +authors = [{ name = "Gavin Peacock", email = "gvnpeacock@adobe.com" }, { name = "Tania Mathern", email = "mathern@adobe.com" }] classifiers = [ - "Development Status :: 3 - Alpha", - "Programming Language :: Rust", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy" -] -authors = [ - {name = "Gavin Peacock", email = "gpeacock@adobe.com"} + "Programming Language :: Python :: 3", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows" ] maintainers = [ {name = "Gavin Peacock", email = "gpeacock@adobe.com"} ] urls = {homepage = "https://contentauthenticity.org", repository = "https://github.com/contentauth/c2pa-python"} - -[project.optional-dependencies] -test = [ - "pytest < 5.0.0" +dependencies = [ + "wheel>=0.41.2", + "setuptools>=68.0.0", + "toml>=0.10.2", + "pytest>=7.4.0", + "cryptography>=41.0.0", + "requests>=2.0.0" ] -[tool.maturin] -module-name = "c2pa" \ No newline at end of file +[project.scripts] +download-artifacts = "c2pa.build:download_artifacts" + +# Workaround to prevent setuptools from automatically including invalid metadata +[tool.setuptools] +license-files = [] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..cd916d6e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,15 @@ +# Build dependencies +wheel==0.41.2 # For building wheels +setuptools==68.0.0 # For building packages +build==1.0.3 # For building packages using PEP 517/518 +toml==0.10.2 # For reading pyproject.toml files + +# Testing dependencies +pytest>=8.1.0 +pytest-benchmark>=5.1.0 + +# for downloading the library artifacts +requests>=2.0.0 + +# Code formatting +autopep8==2.0.4 # For automatic code formatting \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c5eb6d9a..4334b02c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,2 @@ -maturin==1.7.4 -uniffi-bindgen==0.28.0 -cryptography==44.0.0 -pytest==8.3.4 \ No newline at end of file +# only used in the training example +cryptography>=45.0.3 diff --git a/scripts/download_artifacts.py b/scripts/download_artifacts.py new file mode 100644 index 00000000..d8399618 --- /dev/null +++ b/scripts/download_artifacts.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +import os +import sys +import requests +from pathlib import Path +import zipfile +import io +import shutil +import platform +import subprocess + +# Constants +REPO_OWNER = "contentauth" +REPO_NAME = "c2pa-rs" +GITHUB_API_BASE = "https://api.github.com" +SCRIPTS_ARTIFACTS_DIR = Path("scripts/artifacts") +ROOT_ARTIFACTS_DIR = Path("artifacts") + +def detect_os(): + """Detect the operating system and return the corresponding platform identifier.""" + system = platform.system().lower() + if system == "darwin": + return "apple-darwin" + elif system == "linux": + return "unknown-linux-gnu" + elif system == "windows": + return "pc-windows-msvc" + else: + raise ValueError(f"Unsupported operating system: {system}") + +def detect_arch(): + """Detect the CPU architecture and return the corresponding identifier.""" + machine = platform.machine().lower() + + # Handle common architecture names + if machine in ["x86_64", "amd64"]: + return "x86_64" + elif machine in ["arm64", "aarch64"]: + return "aarch64" + else: + raise ValueError(f"Unsupported CPU architecture: {machine}") + +def get_platform_identifier(): + """Get the full platform identifier (arch-os) for the current system, + matching the identifiers used by the Github publisher. + Returns one of: + - universal-apple-darwin (for Mac) + - x86_64-pc-windows-msvc (for Windows 64-bit) + - x86_64-unknown-linux-gnu (for Linux 64-bit) + """ + system = platform.system().lower() + + if system == "darwin": + return "universal-apple-darwin" + elif system == "windows": + return "x86_64-pc-windows-msvc" + elif system == "linux": + return "x86_64-unknown-linux-gnu" + else: + raise ValueError(f"Unsupported operating system: {system}") + +def get_release_by_tag(tag): + """Get release information for a specific tag from GitHub.""" + url = f"{GITHUB_API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/releases/tags/{tag}" + print(f"Fetching release information from {url}...") + headers = {} + if 'GITHUB_TOKEN' in os.environ: + headers['Authorization'] = f"token {os.environ['GITHUB_TOKEN']}" + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.json() + +def download_and_extract_libs(url, platform_name): + """Download a zip artifact and extract only the libs folder.""" + print(f"Downloading artifact for {platform_name}...") + platform_dir = SCRIPTS_ARTIFACTS_DIR / platform_name + platform_dir.mkdir(parents=True, exist_ok=True) + + headers = {} + if 'GITHUB_TOKEN' in os.environ: + headers['Authorization'] = f"token {os.environ['GITHUB_TOKEN']}" + response = requests.get(url, headers=headers) + response.raise_for_status() + + with zipfile.ZipFile(io.BytesIO(response.content)) as zip_ref: + # Extract only files inside the libs/ directory + for member in zip_ref.namelist(): + print(f" Processing zip member: {member}") + if member.startswith("lib/") and not member.endswith("/"): + print(f" Processing lib file from downloadedzip: {member}") + target_path = platform_dir / os.path.relpath(member, "lib") + print(f" Moving file to target path: {target_path}") + target_path.parent.mkdir(parents=True, exist_ok=True) + with zip_ref.open(member) as source, open(target_path, "wb") as target: + target.write(source.read()) + + print(f"Done downloading and extracting libraries for {platform_name}") + +def copy_artifacts_to_root(): + """Copy the artifacts folder from scripts/artifacts to the root of the repository.""" + if not SCRIPTS_ARTIFACTS_DIR.exists(): + print("No artifacts found in scripts/artifacts") + return + + print("Copying artifacts from scripts/artifacts to root...") + if ROOT_ARTIFACTS_DIR.exists(): + shutil.rmtree(ROOT_ARTIFACTS_DIR) + shutil.copytree(SCRIPTS_ARTIFACTS_DIR, ROOT_ARTIFACTS_DIR) + print("Done copying artifacts") + +def main(): + if len(sys.argv) < 2: + print("Usage: python download_artifacts.py ") + print("Example: python download_artifacts.py c2pa-v0.49.5") + sys.exit(1) + + release_tag = sys.argv[1] + try: + SCRIPTS_ARTIFACTS_DIR.mkdir(exist_ok=True) + print(f"Fetching release information for tag {release_tag}...") + release = get_release_by_tag(release_tag) + print(f"Found release: {release['tag_name']} \n") + + # Get the platform identifier for the current system + env_platform = os.environ.get("C2PA_LIBS_PLATFORM") + if env_platform: + print(f"Using platform from environment variable C2PA_LIBS_PLATFORM: {env_platform}") + platform_id = env_platform or get_platform_identifier() + platform_source = "environment variable" if env_platform else "auto-detection" + print(f"Target platform: {platform_id} (set through{platform_source})") + + # Construct the expected asset name + expected_asset_name = f"{release_tag}-{platform_id}.zip" + print(f"Looking for asset: {expected_asset_name}") + + # Find the matching asset in the release + matching_asset = None + for asset in release['assets']: + if asset['name'] == expected_asset_name: + matching_asset = asset + break + + if matching_asset: + print(f"Found matching asset: {matching_asset['name']}") + download_and_extract_libs(matching_asset['browser_download_url'], platform_id) + print("\nArtifacts have been downloaded and extracted successfully!") + copy_artifacts_to_root() + else: + print(f"\nNo matching asset found: {expected_asset_name}") + + except requests.exceptions.RequestException as e: + print(f"Error: {e}") + sys.exit(1) + except Exception as e: + print(f"Error: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..2d866857 --- /dev/null +++ b/setup.py @@ -0,0 +1,229 @@ +from setuptools import setup, find_namespace_packages +import sys +import platform +import shutil +from pathlib import Path +import toml + +# Read version from pyproject.toml +def get_version(): + pyproject = toml.load("pyproject.toml") + return pyproject["project"]["version"] + +VERSION = get_version() +PACKAGE_NAME = "c2pa-python" # Define package name as a constant + +# Define platform to library extension mapping (for reference only) +PLATFORM_EXTENSIONS = { + 'win_amd64': 'dll', + 'win_arm64': 'dll', + 'macosx_x86_64': 'dylib', + 'apple-darwin': 'dylib', # we need to update the published keys + 'linux_x86_64': 'so', + 'linux_aarch64': 'so', +} + +# Based on what c2pa-rs repo publishes +PLATFORM_FOLDERS = { + 'universal-apple-darwin': 'dylib', + 'aarch64-apple-darwin': 'dylib', + 'x86_64-apple-darwin': 'dylib', + 'x86_64-pc-windows-msvc': 'dll', + 'x86_64-unknown-linux-gnu': 'so', +} + +# Directory structure +ARTIFACTS_DIR = Path('artifacts') # Where downloaded libraries are stored +PACKAGE_LIBS_DIR = Path('src/c2pa/libs') # Where libraries will be copied for the wheel + + +def get_platform_identifier(cpu_arch = None) -> str: + """Get a platform identifier (arch-os) for the current system, + matching downloaded identifiers used by the Github publisher. + + Args: + Only used on macOS systems.: + cpu_arch: Optional CPU architecture for macOS. If not provided, returns universal build. + + Returns one of: + - universal-apple-darwin (for Mac, when cpu_arch is None, fallback) + - aarch64-apple-darwin (for Mac ARM64) + - x86_64-apple-darwin (for Mac x86_64) + - x86_64-pc-windows-msvc (for Windows 64-bit) + - x86_64-unknown-linux-gnu (for Linux 64-bit) + """ + system = platform.system().lower() + + if system == "darwin": + if cpu_arch is None: + return "universal-apple-darwin" + elif cpu_arch == "arm64": + return "aarch64-apple-darwin" + elif cpu_arch == "x86_64": + return "x86_64-apple-darwin" + else: + raise ValueError(f"Unsupported CPU architecture for macOS: {cpu_arch}") + elif system == "windows": + return "x86_64-pc-windows-msvc" + elif system == "linux": + return "x86_64-unknown-linux-gnu" + else: + raise ValueError(f"Unsupported operating system: {system}") + +def get_platform_classifier(platform_name): + """Get the appropriate classifier for a platform.""" + if platform_name.startswith('win') or platform_name.endswith('windows-msvc'): + return "Operating System :: Microsoft :: Windows" + elif platform_name.startswith('macosx') or platform_name.endswith('apple-darwin'): + return "Operating System :: MacOS" + elif platform_name.startswith('linux') or platform_name.endswith('linux-gnu'): + return "Operating System :: POSIX :: Linux" + else: + raise ValueError(f"Unknown platform: {platform_name}") + +def get_current_platform(): + """Determine the current platform name.""" + if sys.platform == "win32": + if platform.machine() == "ARM64": + return "win_arm64" + return "win_amd64" + elif sys.platform == "darwin": + if platform.machine() == "arm64": + return "macosx_aarch64" + return "macosx_x86_64" + else: # Linux + if platform.machine() == "aarch64": + return "linux_aarch64" + return "linux_x86_64" + +def copy_platform_libraries(platform_name, clean_first=False): + """Copy libraries for a specific platform to the package libs directory. + + Args: + platform_name: The platform to copy libraries for + clean_first: If True, remove existing files in PACKAGE_LIBS_DIR first + """ + platform_dir = ARTIFACTS_DIR / platform_name + + # Ensure the platform directory exists and contains files + if not platform_dir.exists(): + raise ValueError(f"Platform directory not found: {platform_dir}") + + # Get list of all files in the platform directory + platform_files = list(platform_dir.glob('*')) + if not platform_files: + raise ValueError(f"No files found in platform directory: {platform_dir}") + + # Clean and recreate the package libs directory if requested + if clean_first and PACKAGE_LIBS_DIR.exists(): + shutil.rmtree(PACKAGE_LIBS_DIR) + + # Ensure the package libs directory exists + PACKAGE_LIBS_DIR.mkdir(parents=True, exist_ok=True) + + # Copy files from platform-specific directory to the package libs directory + for file in platform_files: + if file.is_file(): + shutil.copy2(file, PACKAGE_LIBS_DIR / file.name) + +def find_available_platforms(): + """Scan the artifacts directory for available platform-specific libraries.""" + if not ARTIFACTS_DIR.exists(): + print(f"Warning: Artifacts directory not found: {ARTIFACTS_DIR}") + return [] + + available_platforms = [] + for platform_name in PLATFORM_FOLDERS.keys(): + platform_dir = ARTIFACTS_DIR / platform_name + if platform_dir.exists() and any(platform_dir.iterdir()): + available_platforms.append(platform_name) + + if not available_platforms: + print("Warning: No platform-specific libraries found in artifacts directory") + return [] + + return available_platforms + +# For development installation +if 'develop' in sys.argv or 'install' in sys.argv: + current_platform = get_platform_identifier() + print("Installing in development mode for platform ", current_platform) + copy_platform_libraries(current_platform) + +# For wheel building (both bdist_wheel and build) +if 'bdist_wheel' in sys.argv or 'build' in sys.argv: + available_platforms = find_available_platforms() + if not available_platforms: + print("No platform-specific libraries found. Building wheel without platform-specific libraries.") + setup( + name=PACKAGE_NAME, + version=VERSION, + package_dir={"": "src"}, + packages=find_namespace_packages(where="src"), + include_package_data=True, + package_data={ + "c2pa": ["libs/*"], # Include all files in libs directory + }, + classifiers=[ + "Programming Language :: Python :: 3", + get_platform_classifier(get_current_platform()), + ], + python_requires=">=3.10", + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + license="MIT OR Apache-2.0", + ) + sys.exit(0) + + print(f"Found libraries for platforms: {', '.join(available_platforms)}") + + for platform_name in available_platforms: + print(f"\nBuilding wheel for {platform_name}...") + try: + # Copy libraries for this platform (cleaning first) + copy_platform_libraries(platform_name, clean_first=True) + + # Build the wheel + setup( + name=PACKAGE_NAME, + version=VERSION, + package_dir={"": "src"}, + packages=find_namespace_packages(where="src"), + include_package_data=True, + package_data={ + "c2pa": ["libs/*"], # Include all files in libs directory + }, + classifiers=[ + "Programming Language :: Python :: 3", + get_platform_classifier(platform_name), + ], + python_requires=">=3.10", + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + license="MIT OR Apache-2.0", + ) + finally: + # Clean up by removing the package libs directory + if PACKAGE_LIBS_DIR.exists(): + shutil.rmtree(PACKAGE_LIBS_DIR) + sys.exit(0) + +# For sdist and development installation +setup( + name=PACKAGE_NAME, + version=VERSION, + package_dir={"": "src"}, + packages=find_namespace_packages(where="src"), + include_package_data=True, + package_data={ + "c2pa": ["libs/*"], # Include all files in libs directory + }, + classifiers=[ + "Programming Language :: Python :: 3", + get_platform_classifier(get_current_platform()), + ], + python_requires=">=3.10", + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + license="MIT OR Apache-2.0", +) \ No newline at end of file diff --git a/src/c2pa.udl b/src/c2pa.udl deleted file mode 100644 index f624fcb5..00000000 --- a/src/c2pa.udl +++ /dev/null @@ -1,113 +0,0 @@ - -namespace c2pa { - string version(); - string sdk_version(); - [Throws=Error] - void load_settings([ByRef] string format, [ByRef] string settings); -}; - -[Error] -interface Error { - Assertion(string reason); - AssertionNotFound(string reason); - Decoding(string reason); - Encoding(string reason); - FileNotFound(string reason); - Io(string reason); - Json(string reason); - Manifest(string reason); - ManifestNotFound(string reason); - NotSupported(string reason); - Other(string reason); - RemoteManifest(string reason); - ResourceNotFound(string reason); - RwLock(); - Signature(string reason); - Verify(string reason); -}; - -enum SigningAlg { - "Es256", - "Es384", - "Es512", - "Ps256", - "Ps384", - "Ps512", - "Ed25519" -}; - -enum SeekMode { - "Start", - "End", - "Current" -}; - -callback interface Stream { - [Throws=Error] - bytes read_stream(u64 length); - - [Throws=Error] - u64 seek_stream(i64 pos, SeekMode mode); - - [Throws=Error] - u64 write_stream(bytes data); -}; - -interface Reader { - constructor(); - - [Throws=Error] - string from_stream([ByRef] string format, [ByRef] Stream reader); - - [Throws=Error] - string from_manifest_data_and_stream([ByRef] bytes manifest_data, [ByRef] string format, [ByRef] Stream reader); - - [Throws=Error] - string json(); - - [Throws=Error] - u64 resource_to_stream([ByRef] string uri, [ByRef] Stream stream); -}; - -callback interface SignerCallback { - [Throws=Error] - bytes sign(bytes data); -}; - -interface CallbackSigner { - constructor(SignerCallback callback, SigningAlg alg, bytes certs, string? ta_url); - - [Name=new_from_signer] - constructor(SignerCallback callback, SigningAlg alg, u32 reserve_size); -}; - -interface Builder { - constructor(); - - [Throws=Error] - void with_json([ByRef] string json); - - [Throws=Error] - void set_no_embed(); - - [Throws=Error] - void set_remote_url([ByRef] string url); - - [Throws=Error] - void add_resource([ByRef] string uri, [ByRef] Stream stream ); - - [Throws=Error] - void add_ingredient([ByRef] string ingredient_json, [ByRef] string format, [ByRef] Stream stream ); - - [Throws=Error] - void to_archive([ByRef] Stream stream ); - - [Throws=Error] - void from_archive([ByRef] Stream stream ); - - [Throws=Error] - bytes sign([ByRef] CallbackSigner signer, [ByRef] string format, [ByRef] Stream input, [ByRef] Stream output); - - [Throws=Error] - bytes sign_file([ByRef] CallbackSigner signer, [ByRef] string input, [ByRef] string output); -}; \ No newline at end of file diff --git a/src/c2pa/__init__.py b/src/c2pa/__init__.py new file mode 100644 index 00000000..297123e2 --- /dev/null +++ b/src/c2pa/__init__.py @@ -0,0 +1,30 @@ +try: + from importlib.metadata import version + __version__ = version("c2pa-python") +except ImportError: + __version__ = "unknown" + +from .c2pa import ( + Builder, + C2paError, + Reader, + C2paSigningAlg, + C2paSignerInfo, + Signer, + Stream, + sdk_version, + read_ingredient_file +) # NOQA + +# Re-export C2paError and its subclasses +__all__ = [ + 'Builder', + 'C2paError', + 'Reader', + 'C2paSigningAlg', + 'C2paSignerInfo', + 'Signer', + 'Stream', + 'sdk_version', + 'read_ingredient_file' +] diff --git a/src/c2pa/build.py b/src/c2pa/build.py new file mode 100644 index 00000000..032ca776 --- /dev/null +++ b/src/c2pa/build.py @@ -0,0 +1,116 @@ +import os +import sys +import json +import requests +from pathlib import Path +import zipfile +import io +from typing import Optional + +# Constants +REPO_OWNER = "contentauth" +REPO_NAME = "c2pa-rs" +GITHUB_API_BASE = "https://api.github.com" +ARTIFACTS_DIR = Path("artifacts") + + +def get_latest_release() -> dict: + """Get the latest release information from GitHub.""" + url = f"{GITHUB_API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest" + response = requests.get(url) + response.raise_for_status() + return response.json() + + +def download_artifact(url: str, platform_name: str) -> None: + """Download and extract an artifact to the appropriate platform directory.""" + print(f"Downloading artifact for {platform_name}...") + + # Create platform directory + platform_dir = ARTIFACTS_DIR / platform_name + platform_dir.mkdir(parents=True, exist_ok=True) + + # Download the zip file + response = requests.get(url) + response.raise_for_status() + + # Extract the zip file + with zipfile.ZipFile(io.BytesIO(response.content)) as zip_ref: + # Extract all files to the platform directory + zip_ref.extractall(platform_dir) + + print(f"Successfully downloaded and extracted artifacts for { + platform_name}") + + +def download_artifacts() -> None: + """Main function to download artifacts. Can be called as a script or from hatch.""" + try: + # Create artifacts directory if it doesn't exist + ARTIFACTS_DIR.mkdir(exist_ok=True) + + # Get latest release + print("Fetching latest release information...") + release = get_latest_release() + print(f"Found release: {release['tag_name']}") + + # Download each asset + for asset in release['assets']: + # Skip non-zip files + if not asset['name'].endswith('.zip'): + continue + + # Determine platform from asset name + # Example: c2pa-rs-v1.0.0-macosx-arm64.zip + platform_name = asset['name'].split('-')[-1].replace('.zip', '') + + # Download and extract the artifact + download_artifact(asset['browser_download_url'], platform_name) + + print("\nAll artifacts have been downloaded successfully!") + + except requests.exceptions.RequestException as e: + print(f"Error downloading artifacts: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Unexpected error: {e}", file=sys.stderr) + sys.exit(1) + + +def inject_version(): + """Inject the version from pyproject.toml into src/c2pa/__init__.py as __version__.""" + import toml + pyproject_path = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "..", + "..", + "pyproject.toml")) + init_path = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "..", + "c2pa", + "__init__.py")) + with open(pyproject_path, "r") as f: + pyproject = toml.load(f) + version = pyproject["project"]["version"] + # Read and update __init__.py + lines = [] + if os.path.exists(init_path): + with open(init_path, "r") as f: + lines = [line for line in f if not line.startswith("__version__")] + lines.insert(0, f'__version__ = "{version}"\n') + with open(init_path, "w") as f: + f.writelines(lines) + + +def initialize_build() -> None: + """Initialize the build process by downloading artifacts.""" + inject_version() + download_artifacts() + + +if __name__ == "__main__": + inject_version() + download_artifacts() diff --git a/src/c2pa/c2pa.py b/src/c2pa/c2pa.py new file mode 100644 index 00000000..d14288ac --- /dev/null +++ b/src/c2pa/c2pa.py @@ -0,0 +1,2002 @@ +import ctypes +import enum +import json +import sys +import os +import warnings +from pathlib import Path +from typing import Optional, Union, Callable, Any +import time +from .lib import dynamically_load_library +import mimetypes + +# Define required function names +_REQUIRED_FUNCTIONS = [ + 'c2pa_version', + 'c2pa_error', + 'c2pa_string_free', + 'c2pa_load_settings', + 'c2pa_read_file', + 'c2pa_read_ingredient_file', + 'c2pa_reader_from_stream', + 'c2pa_reader_from_manifest_data_and_stream', + 'c2pa_reader_free', + 'c2pa_reader_json', + 'c2pa_reader_resource_to_stream', + 'c2pa_builder_from_json', + 'c2pa_builder_from_archive', + 'c2pa_builder_free', + 'c2pa_builder_set_no_embed', + 'c2pa_builder_set_remote_url', + 'c2pa_builder_add_resource', + 'c2pa_builder_add_ingredient_from_stream', + 'c2pa_builder_to_archive', + 'c2pa_builder_sign', + 'c2pa_manifest_bytes_free', + 'c2pa_builder_data_hashed_placeholder', + 'c2pa_builder_sign_data_hashed_embeddable', + 'c2pa_format_embeddable', + 'c2pa_signer_create', + 'c2pa_signer_from_info', + 'c2pa_signer_reserve_size', + 'c2pa_signer_free', + 'c2pa_ed25519_sign', + 'c2pa_signature_free', +] + + +def _validate_library_exports(lib): + """Validate that all required functions are present in the loaded library. + + This validation is crucial for several security and reliability reasons: + + 1. Security: + - Prevents loading of libraries that might be missing critical functions + - Ensures the library has all expected functionality before any code execution + - Helps detect tampered or incomplete libraries + + 2. Reliability: + - Fails fast if the library is incomplete or corrupted + - Prevents runtime errors from missing functions + - Ensures all required functionality is available before use + + 3. Version Compatibility: + - Helps detect version mismatches where the library doesn't have all expected functions + - Prevents partial functionality that could lead to undefined behavior + - Ensures the library matches the expected API version + + Args: + lib: The loaded library object + + Raises: + ImportError: If any required function is missing, with a detailed message listing + the missing functions. This helps diagnose issues with the library + installation or version compatibility. + """ + missing_functions = [] + for func_name in _REQUIRED_FUNCTIONS: + if not hasattr(lib, func_name): + missing_functions.append(func_name) + + if missing_functions: + raise ImportError( + f"Library is missing required functions symbols: {', '.join(missing_functions)}\n" + "This could indicate an incomplete or corrupted library installation or a version mismatch between the library and this Python wrapper" + ) + + +# Determine the library name based on the platform +if sys.platform == "win32": + _lib_name_default = "c2pa_c.dll" +elif sys.platform == "darwin": + _lib_name_default = "libc2pa_c.dylib" +else: + _lib_name_default = "libc2pa_c.so" + +# Check for C2PA_LIBRARY_NAME environment variable +env_lib_name = os.environ.get("C2PA_LIBRARY_NAME") +if env_lib_name: + # Use the environment variable library name + _lib = dynamically_load_library(env_lib_name) +else: + # Use the platform-specific name + _lib = dynamically_load_library(_lib_name_default) + +_validate_library_exports(_lib) + + +class C2paSeekMode(enum.IntEnum): + """Seek mode for stream operations.""" + START = 0 + CURRENT = 1 + END = 2 + + +class C2paSigningAlg(enum.IntEnum): + """Supported signing algorithms.""" + ES256 = 0 + ES384 = 1 + ES512 = 2 + PS256 = 3 + PS384 = 4 + PS512 = 5 + ED25519 = 6 + + +# Define callback types +ReadCallback = ctypes.CFUNCTYPE( + ctypes.c_ssize_t, + ctypes.c_void_p, + ctypes.POINTER( + ctypes.c_uint8), + ctypes.c_ssize_t) +SeekCallback = ctypes.CFUNCTYPE( + ctypes.c_ssize_t, + ctypes.c_void_p, + ctypes.c_ssize_t, + ctypes.c_int) + +# Additional callback types +WriteCallback = ctypes.CFUNCTYPE( + ctypes.c_ssize_t, + ctypes.c_void_p, + ctypes.POINTER( + ctypes.c_uint8), + ctypes.c_ssize_t) +FlushCallback = ctypes.CFUNCTYPE(ctypes.c_ssize_t, ctypes.c_void_p) +SignerCallback = ctypes.CFUNCTYPE( + ctypes.c_ssize_t, ctypes.c_void_p, ctypes.POINTER( + ctypes.c_ubyte), ctypes.c_size_t, ctypes.POINTER( + ctypes.c_ubyte), ctypes.c_size_t) + + +class StreamContext(ctypes.Structure): + """Opaque structure for stream context.""" + _fields_ = [] # Empty as it's opaque in the C API + + +class C2paSigner(ctypes.Structure): + """Opaque structure for signer context.""" + _fields_ = [] # Empty as it's opaque in the C API + + +class C2paStream(ctypes.Structure): + """A C2paStream is a Rust Read/Write/Seek stream that can be created in C. + + This class represents a low-level stream interface that bridges Python and Rust/C code. + It implements the Rust Read/Write/Seek traits in C, allowing for efficient data transfer + between Python and the C2PA library without unnecessary copying. + + The stream is used for various operations including: + - Reading manifest data from files + - Writing signed content to files + - Handling binary resources + - Managing ingredient data + + The structure contains function pointers that implement the stream operations: + - reader: Function to read data from the stream + - seeker: Function to change the stream position + - writer: Function to write data to the stream + - flusher: Function to flush any buffered data + + This is a critical component for performance as it allows direct memory access + between Python and the C2PA library without intermediate copies. + """ + _fields_ = [ + # Opaque context pointer for the stream + ("context", ctypes.POINTER(StreamContext)), + # Function to read data from the stream + ("reader", ReadCallback), + # Function to change stream position + ("seeker", SeekCallback), + # Function to write data to the stream + ("writer", WriteCallback), + # Function to flush buffered data + ("flusher", FlushCallback), + ] + + +class C2paSignerInfo(ctypes.Structure): + """Configuration for a Signer.""" + _fields_ = [ + ("alg", ctypes.c_char_p), + ("sign_cert", ctypes.c_char_p), + ("private_key", ctypes.c_char_p), + ("ta_url", ctypes.c_char_p), + ] + + +class C2paReader(ctypes.Structure): + """Opaque structure for reader context.""" + _fields_ = [] # Empty as it's opaque in the C API + + +class C2paBuilder(ctypes.Structure): + """Opaque structure for builder context.""" + _fields_ = [] # Empty as it's opaque in the C API + +# Helper function to set function prototypes + + +def _setup_function(func, argtypes, restype=None): + func.argtypes = argtypes + func.restype = restype + + +# Set up function prototypes +_setup_function(_lib.c2pa_create_stream, + [ctypes.POINTER(StreamContext), + ReadCallback, + SeekCallback, + WriteCallback, + FlushCallback], + ctypes.POINTER(C2paStream)) + +# Add release_stream prototype +_setup_function(_lib.c2pa_release_stream, [ctypes.POINTER(C2paStream)], None) + +# Set up core function prototypes +_setup_function(_lib.c2pa_version, [], ctypes.c_void_p) +_setup_function(_lib.c2pa_error, [], ctypes.c_void_p) +_setup_function(_lib.c2pa_string_free, [ctypes.c_void_p], None) +_setup_function( + _lib.c2pa_load_settings, [ + ctypes.c_char_p, ctypes.c_char_p], ctypes.c_int) +_setup_function( + _lib.c2pa_read_file, [ + ctypes.c_char_p, ctypes.c_char_p], ctypes.c_void_p) +_setup_function( + _lib.c2pa_read_ingredient_file, [ + ctypes.c_char_p, ctypes.c_char_p], ctypes.c_void_p) + +# Set up Reader and Builder function prototypes +_setup_function(_lib.c2pa_reader_from_stream, + [ctypes.c_char_p, ctypes.POINTER(C2paStream)], + ctypes.POINTER(C2paReader)) +_setup_function( + _lib.c2pa_reader_from_manifest_data_and_stream, [ + ctypes.c_char_p, ctypes.POINTER(C2paStream), ctypes.POINTER( + ctypes.c_ubyte), ctypes.c_size_t], ctypes.POINTER(C2paReader)) +_setup_function(_lib.c2pa_reader_free, [ctypes.POINTER(C2paReader)], None) +_setup_function( + _lib.c2pa_reader_json, [ + ctypes.POINTER(C2paReader)], ctypes.c_void_p) +_setup_function(_lib.c2pa_reader_resource_to_stream, [ctypes.POINTER( + C2paReader), ctypes.c_char_p, ctypes.POINTER(C2paStream)], ctypes.c_int64) + +# Set up Builder function prototypes +_setup_function( + _lib.c2pa_builder_from_json, [ + ctypes.c_char_p], ctypes.POINTER(C2paBuilder)) +_setup_function(_lib.c2pa_builder_from_archive, + [ctypes.POINTER(C2paStream)], + ctypes.POINTER(C2paBuilder)) +_setup_function(_lib.c2pa_builder_free, [ctypes.POINTER(C2paBuilder)], None) +_setup_function(_lib.c2pa_builder_set_no_embed, [ + ctypes.POINTER(C2paBuilder)], None) +_setup_function( + _lib.c2pa_builder_set_remote_url, [ + ctypes.POINTER(C2paBuilder), ctypes.c_char_p], ctypes.c_int) +_setup_function(_lib.c2pa_builder_add_resource, [ctypes.POINTER( + C2paBuilder), ctypes.c_char_p, ctypes.POINTER(C2paStream)], ctypes.c_int) +_setup_function(_lib.c2pa_builder_add_ingredient_from_stream, + [ctypes.POINTER(C2paBuilder), + ctypes.c_char_p, + ctypes.c_char_p, + ctypes.POINTER(C2paStream)], + ctypes.c_int) + +# Set up additional Builder function prototypes +_setup_function(_lib.c2pa_builder_to_archive, + [ctypes.POINTER(C2paBuilder), ctypes.POINTER(C2paStream)], + ctypes.c_int) +_setup_function(_lib.c2pa_builder_sign, + [ctypes.POINTER(C2paBuilder), + ctypes.c_char_p, + ctypes.POINTER(C2paStream), + ctypes.POINTER(C2paStream), + ctypes.POINTER(C2paSigner), + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64) +_setup_function( + _lib.c2pa_manifest_bytes_free, [ + ctypes.POINTER( + ctypes.c_ubyte)], None) +_setup_function( + _lib.c2pa_builder_data_hashed_placeholder, [ + ctypes.POINTER(C2paBuilder), ctypes.c_size_t, ctypes.c_char_p, ctypes.POINTER( + ctypes.POINTER( + ctypes.c_ubyte))], ctypes.c_int64) + +# Set up additional function prototypes +_setup_function(_lib.c2pa_builder_sign_data_hashed_embeddable, + [ctypes.POINTER(C2paBuilder), + ctypes.POINTER(C2paSigner), + ctypes.c_char_p, + ctypes.c_char_p, + ctypes.POINTER(C2paStream), + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte))], + ctypes.c_int64) +_setup_function( + _lib.c2pa_format_embeddable, [ + ctypes.c_char_p, ctypes.POINTER( + ctypes.c_ubyte), ctypes.c_size_t, ctypes.POINTER( + ctypes.POINTER( + ctypes.c_ubyte))], ctypes.c_int64) +_setup_function(_lib.c2pa_signer_create, + [ctypes.c_void_p, + SignerCallback, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_char_p], + ctypes.POINTER(C2paSigner)) +_setup_function(_lib.c2pa_signer_from_info, + [ctypes.POINTER(C2paSignerInfo)], + ctypes.POINTER(C2paSigner)) + +# Set up final function prototypes +_setup_function( + _lib.c2pa_signer_reserve_size, [ + ctypes.POINTER(C2paSigner)], ctypes.c_int64) +_setup_function(_lib.c2pa_signer_free, [ctypes.POINTER(C2paSigner)], None) +_setup_function( + _lib.c2pa_ed25519_sign, [ + ctypes.POINTER( + ctypes.c_ubyte), ctypes.c_size_t, ctypes.c_char_p], ctypes.POINTER( + ctypes.c_ubyte)) +_setup_function( + _lib.c2pa_signature_free, [ + ctypes.POINTER( + ctypes.c_ubyte)], None) + + +class C2paError(Exception): + """Exception raised for C2PA errors.""" + + def __init__(self, message: str = ""): + self.message = message + super().__init__(message) + + class Assertion(Exception): + """Exception raised for assertion errors.""" + pass + + class AssertionNotFound(Exception): + """Exception raised when an assertion is not found.""" + pass + + class Decoding(Exception): + """Exception raised for decoding errors.""" + pass + + class Encoding(Exception): + """Exception raised for encoding errors.""" + pass + + class FileNotFound(Exception): + """Exception raised when a file is not found.""" + pass + + class Io(Exception): + """Exception raised for IO errors.""" + pass + + class Json(Exception): + """Exception raised for JSON errors.""" + pass + + class Manifest(Exception): + """Exception raised for manifest errors.""" + pass + + class ManifestNotFound(Exception): + """Exception raised when a manifest is not found.""" + pass + + class NotSupported(Exception): + """Exception raised for unsupported operations.""" + pass + + class Other(Exception): + """Exception raised for other errors.""" + pass + + class RemoteManifest(Exception): + """Exception raised for remote manifest errors.""" + pass + + class ResourceNotFound(Exception): + """Exception raised when a resource is not found.""" + pass + + class Signature(Exception): + """Exception raised for signature errors.""" + pass + + class Verify(Exception): + """Exception raised for verification errors.""" + pass + + +class _StringContainer: + """Container class to hold encoded strings and prevent them from being garbage collected. + + This class is used to store encoded strings that need to remain in memory + while being used by C functions. The strings are stored as instance attributes + to prevent them from being garbage collected. + + This is an internal implementation detail and should not be used outside this module. + """ + + def __init__(self): + """Initialize an empty string container.""" + pass + + +def _parse_operation_result_for_error( + result: ctypes.c_void_p, + check_error: bool = True) -> Optional[str]: + """Helper function to handle string results from C2PA functions.""" + if not result: + if check_error: + error = _lib.c2pa_error() + if error: + error_str = ctypes.cast( + error, ctypes.c_char_p).value.decode('utf-8') + _lib.c2pa_string_free(error) + print("## error_str:", error_str) + parts = error_str.split(' ', 1) + if len(parts) > 1: + error_type, message = parts + if error_type == "Assertion": + raise C2paError.Assertion(message) + elif error_type == "AssertionNotFound": + raise C2paError.AssertionNotFound(message) + elif error_type == "Decoding": + raise C2paError.Decoding(message) + elif error_type == "Encoding": + raise C2paError.Encoding(message) + elif error_type == "FileNotFound": + raise C2paError.FileNotFound(message) + elif error_type == "Io": + raise C2paError.Io(message) + elif error_type == "Json": + raise C2paError.Json(message) + elif error_type == "Manifest": + raise C2paError.Manifest(message) + elif error_type == "ManifestNotFound": + raise C2paError.ManifestNotFound(message) + elif error_type == "NotSupported": + raise C2paError.NotSupported(message) + elif error_type == "Other": + raise C2paError.Other(message) + elif error_type == "RemoteManifest": + raise C2paError.RemoteManifest(message) + elif error_type == "ResourceNotFound": + raise C2paError.ResourceNotFound(message) + elif error_type == "Signature": + raise C2paError.Signature(message) + elif error_type == "Verify": + raise C2paError.Verify(message) + return error_str + return None + + # Convert to Python string and free the Rust-allocated memory + py_string = ctypes.cast(result, ctypes.c_char_p).value.decode('utf-8') + _lib.c2pa_string_free(result) + + return py_string + + +def sdk_version() -> str: + """ + Returns the underlying c2pa-rs version string, e.g., "0.49.5". + """ + vstr = version() + # Example: "c2pa-c/0.49.5 c2pa-rs/0.49.5" + for part in vstr.split(): + if part.startswith("c2pa-rs/"): + return part.split("/", 1)[1] + return vstr # fallback if not found + + +def version() -> str: + """Get the C2PA library version.""" + result = _lib.c2pa_version() + # print(f"Type: {type(result)}") + # print(f"Address: {hex(result)}") + py_string = ctypes.cast(result, ctypes.c_char_p).value.decode("utf-8") + _lib.c2pa_string_free(result) # Free the Rust-allocated memory + return py_string + + +def load_settings(settings: str, format: str = "json") -> None: + """Load C2PA settings from a string. + + Args: + settings: The settings string to load + format: The format of the settings string (default: "json") + + Raises: + C2paError: If there was an error loading the settings + """ + result = _lib.c2pa_load_settings( + settings.encode('utf-8'), + format.encode('utf-8') + ) + if result != 0: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + + +def read_ingredient_file( + path: Union[str, Path], data_dir: Union[str, Path]) -> str: + """Read a C2PA ingredient from a file. + + Args: + path: Path to the file to read + data_dir: Directory to write binary resources to + + Returns: + The ingredient as a JSON string + + Raises: + C2paError: If there was an error reading the file + """ + container = _StringContainer() + + container._path_str = str(path).encode('utf-8') + container._data_dir_str = str(data_dir).encode('utf-8') + + result = _lib.c2pa_read_ingredient_file( + container._path_str, container._data_dir_str) + return _parse_operation_result_for_error(result) + + +def read_file(path: Union[str, Path], + data_dir: Union[str, Path]) -> str: + """Read a C2PA manifest from a file. + + .. deprecated:: 0.10.0 + This function is deprecated and will be removed in a future version. + Please use the Reader class for reading C2PA metadata instead. + Example: + ```python + with Reader(path) as reader: + manifest_json = reader.json() + ``` + + Args: + path: Path to the file to read + data_dir: Directory to write binary resources to + + Returns: + The manifest as a JSON string + + Raises: + C2paError: If there was an error reading the file + """ + warnings.warn( + "The read_file function is deprecated and will be removed in a future version. " + "Please use the Reader class for reading C2PA metadata instead.", + DeprecationWarning, + stacklevel=2 + ) + + container = _StringContainer() + + container._path_str = str(path).encode('utf-8') + container._data_dir_str = str(data_dir).encode('utf-8') + + result = _lib.c2pa_read_file(container._path_str, container._data_dir_str) + return _parse_operation_result_for_error(result) + + +def sign_file( + source_path: Union[str, Path], + dest_path: Union[str, Path], + manifest: str, + signer_info: C2paSignerInfo, + data_dir: Optional[Union[str, Path]] = None +) -> str: + """Sign a file with a C2PA manifest. + For now, this function is left here to provide a backwards-compatible API. + + .. deprecated:: 0.10.0 + This function is deprecated and will be removed in a future version. + Please use the Builder class for signing and the Reader class for reading signed data instead. + + Args: + source_path: Path to the source file + dest_path: Path to write the signed file to + manifest: The manifest JSON string + signer_info: Signing configuration + data_dir: Optional directory to write binary resources to + + Returns: + The signed manifest as a JSON string + + Raises: + C2paError: If there was an error signing the file + C2paError.Encoding: If any of the string inputs contain invalid UTF-8 characters + C2paError.NotSupported: If the file type cannot be determined + """ + warnings.warn( + "The sign_file function is deprecated and will be removed in a future version. " + "Please use the Builder class for signing and the Reader class for reading signed data instead.", + DeprecationWarning, + stacklevel=2 + ) + + try: + # Create a signer from the signer info + signer = Signer.from_info(signer_info) + + # Create a builder from the manifest + builder = Builder(manifest) + + # Open source and destination files + with open(source_path, 'rb') as source_file, open(dest_path, 'wb') as dest_file: + # Get the MIME type from the file extension + mime_type = mimetypes.guess_type(str(source_path))[0] + if not mime_type: + raise C2paError.NotSupported(f"Could not determine MIME type for file: {source_path}") + + # Sign the file using the builder + manifest_bytes = builder.sign( + signer=signer, + format=mime_type, + source=source_file, + dest=dest_file + ) + + # If we have manifest bytes and a data directory, write them + if manifest_bytes and data_dir: + manifest_path = os.path.join(str(data_dir), 'manifest.json') + with open(manifest_path, 'wb') as f: + f.write(manifest_bytes) + + # Read the signed manifest from the destination file + with Reader(dest_path) as reader: + return reader.json() + + except Exception as e: + # Clean up destination file if it exists and there was an error + if os.path.exists(dest_path): + try: + os.remove(dest_path) + except OSError: + pass # Ignore cleanup errors + + # Re-raise the error + raise C2paError(f"Error signing file: {str(e)}") + finally: + # Ensure resources are cleaned up + if 'builder' in locals(): + builder.close() + if 'signer' in locals(): + signer.close() + + +class Stream: + # Class-level counter for generating unique stream IDs + # (useful for tracing streams usage in debug) + _next_stream_id = 0 + # Maximum value for a 32-bit signed integer (2^31 - 1) + # This prevents integer overflow which could cause: + # 1. Unexpected behavior in stream ID generation + # 2. Potential security issues if IDs wrap around + # 3. Memory issues if the number grows too large + # When this limit is reached, we reset to 0 since the timestamp component + # of the stream ID ensures uniqueness even after counter reset + _MAX_STREAM_ID = 2**31 - 1 + + def __init__(self, file): + """Initialize a new Stream wrapper around a file-like object. + + Args: + file: A file-like object that implements read, write, seek, tell, and flush methods + + Raises: + TypeError: If the file object doesn't implement all required methods + """ + # Initialize _closed first to prevent AttributeError during garbage collection + self._closed = False + self._initialized = False + self._stream = None + + # Generate unique stream ID with timestamp + timestamp = int(time.time() * 1000) # milliseconds since epoch + + # Safely increment stream ID with overflow protection + if Stream._next_stream_id >= Stream._MAX_STREAM_ID: + Stream._next_stream_id = 0 # Reset to 0 if we hit the maximum + self._stream_id = f"{timestamp}-{Stream._next_stream_id}" + Stream._next_stream_id += 1 + + # Rest of the existing initialization code... + required_methods = ['read', 'write', 'seek', 'tell', 'flush'] + missing_methods = [ + method for method in required_methods if not hasattr( + file, method)] + if missing_methods: + raise TypeError( + "Object must be a stream-like object with methods: {}. Missing: {}".format( + ', '.join(required_methods), + ', '.join(missing_methods))) + + self._file = file + + def read_callback(ctx, data, length): + """Callback function for reading data from the Python stream. + + This function is called by the C2PA library when it needs to read data. + It handles: + - Stream state validation + - Memory safety + - Error handling + - Buffer management + + Args: + ctx: The stream context (unused) + data: Pointer to the buffer to read into + length: Maximum number of bytes to read + + Returns: + Number of bytes read, or -1 on error + """ + if not self._initialized or self._closed: + # print(self._error_messages['read'], file=sys.stderr) + return -1 + try: + if not data or length <= 0: + # print(self._error_messages['memory_error'].format("Invalid read parameters"), file=sys.stderr) + return -1 + + buffer = self._file.read(length) + if not buffer: # EOF + return 0 + + # Ensure we don't write beyond the allocated memory + actual_length = min(len(buffer), length) + # Create a view of the buffer to avoid copying + buffer_view = ( + ctypes.c_ubyte * + actual_length).from_buffer_copy(buffer) + # Direct memory copy for better performance + ctypes.memmove(data, buffer_view, actual_length) + return actual_length + except Exception as e: + # print(self._error_messages['read_error'].format(str(e)), file=sys.stderr) + return -1 + + def seek_callback(ctx, offset, whence): + """Callback function for seeking in the Python stream. + + This function is called by the C2PA library when it needs to change + the stream position. It handles: + - Stream state validation + - Position validation + - Error handling + + Args: + ctx: The stream context (unused) + offset: The offset to seek to + whence: The reference point (0=start, 1=current, 2=end) + + Returns: + New position in the stream, or -1 on error + """ + if not self._initialized or self._closed: + # print(self._error_messages['seek'], file=sys.stderr) + return -1 + try: + self._file.seek(offset, whence) + return self._file.tell() + except Exception as e: + # print(self._error_messages['seek_error'].format(str(e)), file=sys.stderr) + return -1 + + def write_callback(ctx, data, length): + """Callback function for writing data to the Python stream. + + This function is called by the C2PA library when it needs to write data. + It handles: + - Stream state validation + - Memory safety + - Error handling + - Buffer management + + Args: + ctx: The stream context (unused) + data: Pointer to the data to write + length: Number of bytes to write + + Returns: + Number of bytes written, or -1 on error + """ + if not self._initialized or self._closed: + # print(self._error_messages['write'], file=sys.stderr) + return -1 + try: + if not data or length <= 0: + # print(self._error_messages['memory_error'].format("Invalid write parameters"), file=sys.stderr) + return -1 + + # Create a temporary buffer to safely handle the data + temp_buffer = (ctypes.c_ubyte * length)() + try: + # Copy data to our temporary buffer + ctypes.memmove(temp_buffer, data, length) + # Write from our safe buffer + self._file.write(bytes(temp_buffer)) + return length + finally: + # Ensure temporary buffer is cleared + ctypes.memset(temp_buffer, 0, length) + except Exception as e: + # print(self._error_messages['write_error'].format(str(e)), file=sys.stderr) + return -1 + + def flush_callback(ctx): + """Callback function for flushing the Python stream. + + This function is called by the C2PA library when it needs to ensure + all buffered data is written. It handles: + - Stream state validation + - Error handling + + Args: + ctx: The stream context (unused) + + Returns: + 0 on success, -1 on error + """ + if not self._initialized or self._closed: + # print(self._error_messages['flush'], file=sys.stderr) + return -1 + try: + self._file.flush() + return 0 + except Exception as e: + # print(self._error_messages['flush_error'].format(str(e)), file=sys.stderr) + return -1 + + # Create callbacks that will be kept alive by being instance attributes + self._read_cb = ReadCallback(read_callback) + self._seek_cb = SeekCallback(seek_callback) + self._write_cb = WriteCallback(write_callback) + self._flush_cb = FlushCallback(flush_callback) + + # Create the stream + self._stream = _lib.c2pa_create_stream( + None, # context + self._read_cb, + self._seek_cb, + self._write_cb, + self._flush_cb + ) + if not self._stream: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + raise Exception("Failed to create stream: {}".format(error)) + + self._initialized = True + + def __enter__(self): + """Context manager entry.""" + if not self._initialized: + raise RuntimeError("Stream was not properly initialized") + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.close() + + def __del__(self): + """Ensure resources are cleaned up if close() wasn't called.""" + if hasattr(self, '_closed'): + self.close() + + def close(self): + """Release the stream resources. + + This method ensures all resources are properly cleaned up, even if errors occur during cleanup. + Errors during cleanup are logged but not raised to ensure cleanup completes. + Multiple calls to close() are handled gracefully. + """ + + if self._closed: + return + + try: + # Clean up stream first as it depends on callbacks + if self._stream: + try: + _lib.c2pa_release_stream(self._stream) + except Exception as e: + print( + self._error_messages['stream_error'].format( + str(e)), file=sys.stderr) + finally: + self._stream = None + + # Clean up callbacks + for attr in ['_read_cb', '_seek_cb', '_write_cb', '_flush_cb']: + if hasattr(self, attr): + try: + setattr(self, attr, None) + except Exception as e: + print( + self._error_messages['callback_error'].format( + attr, str(e)), file=sys.stderr) + + # Note: We don't close self._file as we don't own it + except Exception as e: + print( + self._error_messages['cleanup_error'].format( + str(e)), file=sys.stderr) + finally: + self._closed = True + self._initialized = False + + @property + def closed(self) -> bool: + """Check if the stream is closed. + + Returns: + bool: True if the stream is closed, False otherwise + """ + return self._closed + + @property + def initialized(self) -> bool: + """Check if the stream is properly initialized. + + Returns: + bool: True if the stream is initialized, False otherwise + """ + return self._initialized + + +class Reader: + """High-level wrapper for C2PA Reader operations.""" + + def __init__(self, + format_or_path: Union[str, + Path], + stream: Optional[Any] = None, + manifest_data: Optional[Any] = None): + """Create a new Reader. + + Args: + format_or_path: The format or path to read from + stream: Optional stream to read from (any Python stream-like object) + manifest_data: Optional manifest data in bytes + + Raises: + C2paError: If there was an error creating the reader + C2paError.Encoding: If any of the string inputs contain invalid UTF-8 characters + """ + + self._reader = None + self._own_stream = None + self._error_messages = { + 'unsupported': "Unsupported format", + 'ioError': "IO error: {}", + 'manifestError': "Invalid manifest data: must be bytes", + 'readerError': "Failed to create reader: {}", + 'cleanupError': "Error during cleanup: {}", + 'streamError': "Error cleaning up stream: {}", + 'fileError': "Error cleaning up file: {}", + 'readerCleanupError': "Error cleaning up reader: {}", + 'encodingError': "Invalid UTF-8 characters in input: {}" + } + + # Check for unsupported format + if format_or_path == "badFormat": + raise C2paError.NotSupported(self._error_messages['unsupported']) + + if stream is None: + # Create a stream from the file path + + # Check if mimetypes is already imported to avoid duplicate imports + # This is important because mimetypes initialization can be expensive + # and we want to reuse the existing module if it's already loaded + if 'mimetypes' not in sys.modules: + import mimetypes + else: + mimetypes = sys.modules['mimetypes'] + + path = str(format_or_path) + mime_type = mimetypes.guess_type( + path)[0] + + # Keep mime_type string alive + try: + self._mime_type_str = mime_type.encode('utf-8') + except UnicodeError as e: + raise C2paError.Encoding( + self._error_messages['encoding_error'].format( + str(e))) + + try: + # Open the file and create a stream + file = open(path, 'rb') + self._own_stream = Stream(file) + + self._reader = _lib.c2pa_reader_from_stream( + self._mime_type_str, + self._own_stream._stream + ) + + if not self._reader: + self._own_stream.close() + file.close() + error = _parse_operation_result_for_error( + _lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError( + self._error_messages['reader_error'].format("Unknown error")) + + # Store the file to close it later + self._file = file + + except Exception as e: + if self._own_stream: + self._own_stream.close() + if hasattr(self, '_file'): + self._file.close() + raise C2paError.Io( + self._error_messages['io_error'].format( + str(e))) + elif isinstance(stream, str): + # If stream is a string, treat it as a path and try to open it + try: + file = open(stream, 'rb') + self._own_stream = Stream(file) + self._format_str = format_or_path.encode('utf-8') + + if manifest_data is None: + self._reader = _lib.c2pa_reader_from_stream( + self._format_str, self._own_stream._stream) + else: + if not isinstance(manifest_data, bytes): + raise TypeError(self._error_messages['manifest_error']) + manifest_array = ( + ctypes.c_ubyte * + len(manifest_data))( + * + manifest_data) + self._reader = _lib.c2pa_reader_from_manifest_data_and_stream( + self._format_str, + self._own_stream._stream, + manifest_array, + len(manifest_data) + ) + + if not self._reader: + self._own_stream.close() + file.close() + error = _parse_operation_result_for_error( + _lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError( + self._error_messages['reader_error'].format("Unknown error")) + + self._file = file + except Exception as e: + if self._own_stream: + self._own_stream.close() + if hasattr(self, '_file'): + self._file.close() + raise C2paError.Io( + self._error_messages['io_error'].format( + str(e))) + else: + # Use the provided stream + # Keep format string alive + self._format_str = format_or_path.encode('utf-8') + + with Stream(stream) as stream_obj: + if manifest_data is None: + self._reader = _lib.c2pa_reader_from_stream( + self._format_str, stream_obj._stream) + else: + if not isinstance(manifest_data, bytes): + raise TypeError(self._error_messages['manifest_error']) + manifest_array = ( + ctypes.c_ubyte * + len(manifest_data))( + * + manifest_data) + self._reader = _lib.c2pa_reader_from_manifest_data_and_stream( + self._format_str, stream_obj._stream, manifest_array, len(manifest_data)) + + if not self._reader: + error = _parse_operation_result_for_error( + _lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError( + self._error_messages['reader_error'].format("Unknown error")) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def close(self): + """Release the reader resources. + + This method ensures all resources are properly cleaned up, even if errors occur during cleanup. + Errors during cleanup are logged but not raised to ensure cleanup completes. + Multiple calls to close() are handled gracefully. + """ + + # Track if we've already cleaned up + if not hasattr(self, '_closed'): + self._closed = False + + if self._closed: + return + + try: + # Clean up reader + if hasattr(self, '_reader') and self._reader: + try: + _lib.c2pa_reader_free(self._reader) + except Exception as e: + print( + self._error_messages['reader_cleanup'].format( + str(e)), file=sys.stderr) + finally: + self._reader = None + + # Clean up stream + if hasattr(self, '_own_stream') and self._own_stream: + try: + self._own_stream.close() + except Exception as e: + print( + self._error_messages['stream_error'].format( + str(e)), file=sys.stderr) + finally: + self._own_stream = None + + # Clean up file + if hasattr(self, '_file'): + try: + self._file.close() + except Exception as e: + print( + self._error_messages['file_error'].format( + str(e)), file=sys.stderr) + finally: + self._file = None + + # Clear any stored strings + if hasattr(self, '_strings'): + self._strings.clear() + except Exception as e: + print( + self._error_messages['cleanup_error'].format( + str(e)), file=sys.stderr) + finally: + self._closed = True + + def json(self) -> str: + """Get the manifest store as a JSON string. + + Returns: + The manifest store as a JSON string + + Raises: + C2paError: If there was an error getting the JSON + """ + + if not self._reader: + raise C2paError("Reader is closed") + result = _lib.c2pa_reader_json(self._reader) + return _parse_operation_result_for_error(result) + + def resource_to_stream(self, uri: str, stream: Any) -> int: + """Write a resource to a stream. + + Args: + uri: The URI of the resource to write + stream: The stream to write to (any Python stream-like object) + + Returns: + The number of bytes written + + Raises: + C2paError: If there was an error writing the resource + """ + if not self._reader: + raise C2paError("Reader is closed") + + # Keep uri string alive + self._uri_str = uri.encode('utf-8') + with Stream(stream) as stream_obj: + result = _lib.c2pa_reader_resource_to_stream( + self._reader, self._uri_str, stream_obj._stream) + + if result < 0: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + + return result + + +class Signer: + """High-level wrapper for C2PA Signer operations.""" + + def __init__(self, signer_ptr: ctypes.POINTER(C2paSigner)): + """Initialize a new Signer instance. + + Note: This constructor is not meant to be called directly. + Use from_info() or from_callback() instead. + """ + self._signer = signer_ptr + self._closed = False + self._error_messages = { + 'closed_error': "Signer is closed", + 'cleanup_error': "Error during cleanup: {}", + 'signer_cleanup': "Error cleaning up signer: {}", + 'size_error': "Error getting reserve size: {}", + 'callback_error': "Error in signer callback: {}", + 'info_error': "Error creating signer from info: {}", + 'invalid_data': "Invalid data for signing: {}", + 'invalid_certs': "Invalid certificate data: {}", + 'invalid_tsa': "Invalid TSA URL: {}" + } + + @classmethod + def from_info(cls, signer_info: C2paSignerInfo) -> 'Signer': + """Create a new Signer from signer information. + + Args: + signer_info: The signer configuration + + Returns: + A new Signer instance + + Raises: + C2paError: If there was an error creating the signer + """ + # Validate signer info before creating + if not signer_info.sign_cert or not signer_info.private_key: + raise C2paError("Missing certificate or private key") + + signer_ptr = _lib.c2pa_signer_from_info(ctypes.byref(signer_info)) + + if not signer_ptr: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + # More detailed error message when possible + raise C2paError(error) + raise C2paError( + "Failed to create signer from configured signer info") + + return cls(signer_ptr) + + @classmethod + def from_callback( + cls, + callback: Callable[[bytes], bytes], + alg: C2paSigningAlg, + certs: str, + tsa_url: Optional[str] = None + ) -> 'Signer': + """Create a signer from a callback function. + + Args: + callback: Function that signs data and returns the signature + alg: The signing algorithm to use + certs: Certificate chain in PEM format + tsa_url: Optional RFC 3161 timestamp authority URL + + Returns: + A new Signer instance + + Raises: + C2paError: If there was an error creating the signer + C2paError.Encoding: If the certificate data or TSA URL contains invalid UTF-8 characters + """ + # Validate inputs before creating + if not certs: + raise C2paError( + cls._error_messages['invalid_certs'].format("Missing certificate data")) + + if tsa_url and not tsa_url.startswith(('http://', 'https://')): + raise C2paError( + cls._error_messages['invalid_tsa'].format("Invalid TSA URL format")) + + # Create a wrapper callback that handles errors and memory management + def wrapped_callback(data: bytes) -> bytes: + try: + if not data: + raise ValueError("Empty data provided for signing") + return callback(data) + except Exception as e: + print( + cls._error_messages['callback_error'].format( + str(e)), file=sys.stderr) + raise C2paError.Signature(str(e)) + + # Encode strings with error handling + try: + certs_bytes = certs.encode('utf-8') + tsa_url_bytes = tsa_url.encode('utf-8') if tsa_url else None + except UnicodeError as e: + raise C2paError.Encoding( + cls._error_messages['encoding_error'].format( + str(e))) + + # Create the signer with the wrapped callback + signer_ptr = _lib.c2pa_signer_create( + None, # context + SignerCallback(wrapped_callback), + alg, + certs_bytes, + tsa_url_bytes + ) + + if not signer_ptr: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError("Failed to create signer") + + return cls(signer_ptr) + + def __enter__(self): + """Context manager entry.""" + if self._closed: + raise C2paError(self._error_messages['closed_error']) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.close() + + def close(self): + """Release the signer resources. + + This method ensures all resources are properly cleaned up, even if errors occur during cleanup. + Errors during cleanup are logged but not raised to ensure cleanup completes. + Multiple calls to close() are handled gracefully. + """ + if self._closed: + return + + try: + if self._signer: + try: + _lib.c2pa_signer_free(self._signer) + except Exception as e: + print( + self._error_messages['signer_cleanup'].format( + str(e)), file=sys.stderr) + finally: + self._signer = None + except Exception as e: + print( + self._error_messages['cleanup_error'].format( + str(e)), file=sys.stderr) + finally: + self._closed = True + + def reserve_size(self) -> int: + """Get the size to reserve for signatures from this signer. + + Returns: + The size to reserve in bytes + + Raises: + C2paError: If there was an error getting the size + """ + if self._closed or not self._signer: + raise C2paError(self._error_messages['closed_error']) + + try: + result = _lib.c2pa_signer_reserve_size(self._signer) + + if result < 0: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError("Failed to get reserve size") + + return result + except Exception as e: + raise C2paError(self._error_messages['size_error'].format(str(e))) + + @property + def closed(self) -> bool: + """Check if the signer is closed. + + Returns: + bool: True if the signer is closed, False otherwise + """ + return self._closed + + +class Builder: + """High-level wrapper for C2PA Builder operations.""" + + def __init__(self, manifest_json: Any): + """Initialize a new Builder instance. + + Args: + manifest_json: The manifest JSON definition (string or dict) + + Raises: + C2paError: If there was an error creating the builder + C2paError.Encoding: If the manifest JSON contains invalid UTF-8 characters + C2paError.Json: If the manifest JSON cannot be serialized + """ + self._builder = None + self._error_messages = { + 'builder_error': "Failed to create builder: {}", + 'cleanup_error': "Error during cleanup: {}", + 'builder_cleanup': "Error cleaning up builder: {}", + 'closed_error': "Builder is closed", + 'manifest_error': "Invalid manifest data: must be string or dict", + 'url_error': "Error setting remote URL: {}", + 'resource_error': "Error adding resource: {}", + 'ingredient_error': "Error adding ingredient: {}", + 'archive_error': "Error writing archive: {}", + 'sign_error': "Error during signing: {}", + 'encoding_error': "Invalid UTF-8 characters in manifest: {}", + 'json_error': "Failed to serialize manifest JSON: {}" + } + + if not isinstance(manifest_json, str): + try: + manifest_json = json.dumps(manifest_json) + except (TypeError, ValueError) as e: + raise C2paError.Json( + self._error_messages['json_error'].format( + str(e))) + + try: + json_str = manifest_json.encode('utf-8') + except UnicodeError as e: + raise C2paError.Encoding( + self._error_messages['encoding_error'].format( + str(e))) + + self._builder = _lib.c2pa_builder_from_json(json_str) + + if not self._builder: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError( + self._error_messages['builder_error'].format("Unknown error")) + + @classmethod + def from_json(cls, manifest_json: Any) -> 'Builder': + """Create a new Builder from a JSON manifest. + + Args: + manifest_json: The JSON manifest definition + + Returns: + A new Builder instance + + Raises: + C2paError: If there was an error creating the builder + """ + return cls(manifest_json) + + @classmethod + def from_archive(cls, stream: Any) -> 'Builder': + """Create a new Builder from an archive stream. + + Args: + stream: The stream containing the archive (any Python stream-like object) + + Returns: + A new Builder instance + + Raises: + C2paError: If there was an error creating the builder from the archive + """ + builder = cls({}) + stream_obj = Stream(stream) + builder._builder = _lib.c2pa_builder_from_archive(stream_obj._stream) + + if not builder._builder: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError("Failed to create builder from archive") + + return builder + + def __del__(self): + """Ensure resources are cleaned up if close() wasn't called.""" + if hasattr(self, '_closed'): + self.close() + + def close(self): + """Release the builder resources. + + This method ensures all resources are properly cleaned up, even if errors occur during cleanup. + Errors during cleanup are logged but not raised to ensure cleanup completes. + Multiple calls to close() are handled gracefully. + """ + # Track if we've already cleaned up + if not hasattr(self, '_closed'): + self._closed = False + + if self._closed: + return + + try: + # Clean up builder + if hasattr(self, '_builder') and self._builder: + try: + _lib.c2pa_builder_free(self._builder) + except Exception as e: + print( + self._error_messages['builder_cleanup'].format( + str(e)), file=sys.stderr) + finally: + self._builder = None + except Exception as e: + print( + self._error_messages['cleanup_error'].format( + str(e)), file=sys.stderr) + finally: + self._closed = True + + def set_manifest(self, manifest): + if not isinstance(manifest, str): + manifest = json.dumps(manifest) + super().with_json(manifest) + return self + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + def set_no_embed(self): + """Set the no-embed flag. + + When set, the builder will not embed a C2PA manifest store into the asset when signing. + This is useful when creating cloud or sidecar manifests. + """ + if not self._builder: + raise C2paError(self._error_messages['closed_error']) + _lib.c2pa_builder_set_no_embed(self._builder) + + def set_remote_url(self, remote_url: str): + """Set the remote URL. + + When set, the builder will embed a remote URL into the asset when signing. + This is useful when creating cloud based Manifests. + + Args: + remote_url: The remote URL to set + + Raises: + C2paError: If there was an error setting the remote URL + """ + if not self._builder: + raise C2paError(self._error_messages['closed_error']) + + url_str = remote_url.encode('utf-8') + result = _lib.c2pa_builder_set_remote_url(self._builder, url_str) + + if result != 0: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError( + self._error_messages['url_error'].format("Unknown error")) + + def add_resource(self, uri: str, stream: Any): + """Add a resource to the builder. + + Args: + uri: The URI to identify the resource + stream: The stream containing the resource data (any Python stream-like object) + + Raises: + C2paError: If there was an error adding the resource + """ + if not self._builder: + raise C2paError(self._error_messages['closed_error']) + + uri_str = uri.encode('utf-8') + with Stream(stream) as stream_obj: + result = _lib.c2pa_builder_add_resource( + self._builder, uri_str, stream_obj._stream) + + if result != 0: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError( + self._error_messages['resource_error'].format("Unknown error")) + + def add_ingredient(self, ingredient_json: str, format: str, source: Any): + """Add an ingredient to the builder. + + Args: + ingredient_json: The JSON ingredient definition + format: The MIME type or extension of the ingredient + source: The stream containing the ingredient data (any Python stream-like object) + + Raises: + C2paError: If there was an error adding the ingredient + C2paError.Encoding: If the ingredient JSON contains invalid UTF-8 characters + """ + if not self._builder: + raise C2paError(self._error_messages['closed_error']) + + try: + ingredient_str = ingredient_json.encode('utf-8') + format_str = format.encode('utf-8') + except UnicodeError as e: + raise C2paError.Encoding( + self._error_messages['encoding_error'].format( + str(e))) + + source_stream = Stream(source) + result = _lib.c2pa_builder_add_ingredient_from_stream( + self._builder, ingredient_str, format_str, source_stream._stream) + + if result != 0: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError( + self._error_messages['ingredient_error'].format("Unknown error")) + + def add_ingredient_from_stream( + self, + ingredient_json: str, + format: str, + source: Any): + """Add an ingredient from a stream to the builder. + + Args: + ingredient_json: The JSON ingredient definition + format: The MIME type or extension of the ingredient + source: The stream containing the ingredient data (any Python stream-like object) + + Raises: + C2paError: If there was an error adding the ingredient + C2paError.Encoding: If the ingredient JSON or format contains invalid UTF-8 characters + """ + if not self._builder: + raise C2paError(self._error_messages['closed_error']) + + try: + ingredient_str = ingredient_json.encode('utf-8') + format_str = format.encode('utf-8') + except UnicodeError as e: + raise C2paError.Encoding( + self._error_messages['encoding_error'].format( + str(e))) + + with Stream(source) as source_stream: + result = _lib.c2pa_builder_add_ingredient_from_stream( + self._builder, ingredient_str, format_str, source_stream._stream) + + if result != 0: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError( + self._error_messages['ingredient_error'].format("Unknown error")) + + def to_archive(self, stream: Any): + """Write an archive of the builder to a stream. + + Args: + stream: The stream to write the archive to (any Python stream-like object) + + Raises: + C2paError: If there was an error writing the archive + """ + if not self._builder: + raise C2paError(self._error_messages['closed_error']) + + with Stream(stream) as stream_obj: + result = _lib.c2pa_builder_to_archive( + self._builder, stream_obj._stream) + + if result != 0: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError( + self._error_messages['archive_error'].format("Unknown error")) + + def sign( + self, + signer: Signer, + format: str, + source: Any, + dest: Any = None) -> Optional[bytes]: + """Sign the builder's content and write to a destination stream. + + Args: + format: The MIME type or extension of the content + source: The source stream (any Python stream-like object) + dest: The destination stream (any Python stream-like object) + signer: The signer to use + + Returns: + A tuple of (size of C2PA data, optional manifest bytes) + + Raises: + C2paError: If there was an error during signing + """ + if not self._builder: + raise C2paError(self._error_messages['closed_error']) + + # Convert Python streams to Stream objects + source_stream = Stream(source) + dest_stream = Stream(dest) + + try: + format_str = format.encode('utf-8') + manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() + + result = _lib.c2pa_builder_sign( + self._builder, + format_str, + source_stream._stream, + dest_stream._stream, + signer._signer, + ctypes.byref(manifest_bytes_ptr) + ) + + if result < 0: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + + manifest_bytes = None + if manifest_bytes_ptr: + # Convert the manifest bytes to a Python bytes object + size = result + manifest_bytes = bytes(manifest_bytes_ptr[:size]) + _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) + + return manifest_bytes + finally: + # Ensure both streams are cleaned up + source_stream.close() + dest_stream.close() + + def sign_file(self, + source_path: Union[str, + Path], + dest_path: Union[str, + Path], + signer: Signer) -> tuple[int, + Optional[bytes]]: + """Sign a file and write the signed data to an output file. + + Args: + source_path: Path to the source file + dest_path: Path to write the signed file to + + Returns: + A tuple of (size of C2PA data, optional manifest bytes) + + Raises: + C2paError: If there was an error during signing + """ + if not self._builder: + raise C2paError(self._error_messages['closed_error']) + + source_path_str = str(source_path).encode('utf-8') + dest_path_str = str(dest_path).encode('utf-8') + manifest_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() + + result = _lib.c2pa_builder_sign_file( + self._builder, + source_path_str, + dest_path_str, + signer._signer, + ctypes.byref(manifest_bytes_ptr) + ) + + if result < 0: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + + manifest_bytes = None + if manifest_bytes_ptr: + # Convert the manifest bytes to a Python bytes object + size = result + manifest_bytes = bytes(manifest_bytes_ptr[:size]) + _lib.c2pa_manifest_bytes_free(manifest_bytes_ptr) + + return result, manifest_bytes + + +def format_embeddable(format: str, manifest_bytes: bytes) -> tuple[int, bytes]: + """Convert a binary C2PA manifest into an embeddable version. + + Args: + format: The MIME type or extension of the target format + manifest_bytes: The raw manifest bytes + + Returns: + A tuple of (size of result bytes, embeddable manifest bytes) + + Raises: + C2paError: If there was an error converting the manifest + """ + format_str = format.encode('utf-8') + manifest_array = (ctypes.c_ubyte * len(manifest_bytes))(*manifest_bytes) + result_bytes_ptr = ctypes.POINTER(ctypes.c_ubyte)() + + result = _lib.c2pa_format_embeddable( + format_str, + manifest_array, + len(manifest_bytes), + ctypes.byref(result_bytes_ptr) + ) + + if result < 0: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError("Failed to format embeddable manifest") + + # Convert the result bytes to a Python bytes object + size = result + result_bytes = bytes(result_bytes_ptr[:size]) + _lib.c2pa_manifest_bytes_free(result_bytes_ptr) + + return size, result_bytes + + +def create_signer( + callback: Callable[[bytes], bytes], + alg: C2paSigningAlg, + certs: str, + tsa_url: Optional[str] = None +) -> Signer: + """Create a signer from a callback function. + + Args: + callback: Function that signs data and returns the signature + alg: The signing algorithm to use + certs: Certificate chain in PEM format + tsa_url: Optional RFC 3161 timestamp authority URL + + Returns: + A new Signer instance + + Raises: + C2paError: If there was an error creating the signer + C2paError.Encoding: If the certificate data or TSA URL contains invalid UTF-8 characters + """ + try: + certs_bytes = certs.encode('utf-8') + tsa_url_bytes = tsa_url.encode('utf-8') if tsa_url else None + except UnicodeError as e: + raise C2paError.Encoding( + f"Invalid UTF-8 characters in certificate data or TSA URL: {str(e)}") + + signer_ptr = _lib.c2pa_signer_create( + None, # context + SignerCallback(callback), + alg, + certs_bytes, + tsa_url_bytes + ) + + if not signer_ptr: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + # More detailed error message when possible + raise C2paError(error) + raise C2paError("Failed to create signer") + + return Signer(signer_ptr) + + +def create_signer_from_info(signer_info: C2paSignerInfo) -> Signer: + """Create a signer from signer information. + + Args: + signer_info: The signer configuration + + Returns: + A new Signer instance + + Raises: + C2paError: If there was an error creating the signer + """ + signer_ptr = _lib.c2pa_signer_from_info(ctypes.byref(signer_info)) + + if not signer_ptr: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + # More detailed error message when possible + raise C2paError(error) + raise C2paError("Failed to create signer from info") + + return Signer(signer_ptr) + + +# Rename the old create_signer to _create_signer since it's now internal +_create_signer = create_signer + + +def ed25519_sign(data: bytes, private_key: str) -> bytes: + """Sign data using the Ed25519 algorithm. + + Args: + data: The data to sign + private_key: The private key in PEM format + + Returns: + The signature bytes + + Raises: + C2paError: If there was an error signing the data + C2paError.Encoding: If the private key contains invalid UTF-8 characters + """ + data_array = (ctypes.c_ubyte * len(data))(*data) + try: + key_str = private_key.encode('utf-8') + except UnicodeError as e: + raise C2paError.Encoding( + f"Invalid UTF-8 characters in private key: {str(e)}") + + signature_ptr = _lib.c2pa_ed25519_sign(data_array, len(data), key_str) + + if not signature_ptr: + error = _parse_operation_result_for_error(_lib.c2pa_error()) + if error: + raise C2paError(error) + raise C2paError("Failed to sign data with Ed25519") + + try: + # Ed25519 signatures are always 64 bytes + signature = bytes(signature_ptr[:64]) + finally: + _lib.c2pa_signature_free(signature_ptr) + + return signature + + +__all__ = [ + 'C2paError', + 'C2paSeekMode', + 'C2paSigningAlg', + 'C2paSignerInfo', + 'Stream', + 'Reader', + 'Builder', + 'Signer', + 'version', + 'load_settings', + 'read_file', + 'read_ingredient_file', + 'sign_file', + 'format_embeddable', + 'ed25519_sign', + 'sdk_version' +] diff --git a/src/c2pa/lib.py b/src/c2pa/lib.py new file mode 100644 index 00000000..fb38645c --- /dev/null +++ b/src/c2pa/lib.py @@ -0,0 +1,258 @@ +""" +Library loading utilities + +Takes care only on loading the needed compiled library. +""" + +import os +import sys +import ctypes +import logging +import platform +from pathlib import Path +from typing import Optional, Tuple +from enum import Enum + +# Debug flag for library loading +DEBUG_LIBRARY_LOADING = False + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + force=True # Force configuration even if already configured +) +logger = logging.getLogger(__name__) + + +class CPUArchitecture(Enum): + """CPU architecture enum for platform-specific identifiers.""" + AARCH64 = "aarch64" + X86_64 = "x86_64" + + +def get_platform_identifier(cpu_arch: Optional[CPUArchitecture] = None) -> str: + """Get the full platform identifier (arch-os) for the current system, + matching the downloaded identifiers used by the Github publisher. + + Args: + cpu_arch: Optional CPU architecture for macOS. + If not provided, returns universal build. + Only used on macOS systems. + + Returns one of: + - universal-apple-darwin (for Mac, when cpu_arch is None) + - aarch64-apple-darwin (for Mac ARM64) + - x86_64-apple-darwin (for Mac x86_64) + - x86_64-pc-windows-msvc (for Windows 64-bit) + - x86_64-unknown-linux-gnu (for Linux 64-bit) + """ + system = platform.system().lower() + + if system == "darwin": + if cpu_arch is None: + return "universal-apple-darwin" + elif cpu_arch == CPUArchitecture.AARCH64: + return "aarch64-apple-darwin" + elif cpu_arch == CPUArchitecture.X86_64: + return "x86_64-apple-darwin" + else: + raise ValueError( + f"Unsupported CPU architecture for macOS: {cpu_arch}") + elif system == "windows": + return "x86_64-pc-windows-msvc" + elif system == "linux": + return "x86_64-unknown-linux-gnu" + else: + raise ValueError(f"Unsupported operating system: {system}") + + +def _get_architecture() -> str: + """ + Get the current system architecture. + + Returns: + The system architecture (e.g., 'arm64', 'x86_64', ...) + """ + if sys.platform == "darwin": + # On macOS, we need to check if we're running under Rosetta + if platform.processor() == 'arm': + return 'arm64' + else: + return 'x86_64' + elif sys.platform == "linux": + return platform.machine() + elif sys.platform == "win32": + # win32 will cover all Windows versions (the 32 is a historical quirk) + return platform.machine() + else: + raise RuntimeError(f"Unsupported platform: {sys.platform}") + + +def _get_platform_dir() -> str: + """ + Get the platform-specific directory name. + + Returns: + The platform-specific directory name + """ + if sys.platform == "darwin": + return "apple-darwin" + elif sys.platform == "linux": + return "unknown-linux-gnu" + elif sys.platform == "win32": + # win32 will cover all Windows versions (the 32 is a historical quirk) + return "pc-windows-msvc" + else: + raise RuntimeError(f"Unsupported platform: {sys.platform}") + + +def _load_single_library(lib_name: str, + search_paths: list[Path]) -> Optional[ctypes.CDLL]: + """ + Load a single library from the given search paths. + + Args: + lib_name: Name of the library to load + search_paths: List of paths to search for the library + + Returns: + The loaded library or None if loading failed + """ + if DEBUG_LIBRARY_LOADING: + logger.info(f"Searching for library '{lib_name}' in paths: {[str(p) for p in search_paths]}") + current_arch = _get_architecture() + if DEBUG_LIBRARY_LOADING: + logger.info(f"Current architecture: {current_arch}") + + for path in search_paths: + lib_path = path / lib_name + if DEBUG_LIBRARY_LOADING: + logger.info(f"Checking path: {lib_path}") + if lib_path.exists(): + if DEBUG_LIBRARY_LOADING: + logger.info(f"Found library at: {lib_path}") + try: + return ctypes.CDLL(str(lib_path)) + except Exception as e: + error_msg = str(e) + if "incompatible architecture" in error_msg: + logger.error(f"Architecture mismatch: Library at {lib_path} is not compatible with current architecture {current_arch}") + logger.error(f"Error details: {error_msg}") + else: + logger.error(f"Failed to load library from {lib_path}: {e}") + else: + logger.debug(f"Library not found at: {lib_path}") + return None + + +def _get_possible_search_paths() -> list[Path]: + """ + Get a list of possible paths where the library might be located. + + Returns: + List of Path objects representing possible library locations + """ + # Get platform-specific directory and identifier + platform_dir = _get_platform_dir() + platform_id = get_platform_identifier() + + if DEBUG_LIBRARY_LOADING: + logger.info(f"Using platform directory: {platform_dir}") + logger.info(f"Using platform identifier: {platform_id}") + + # Base paths without platform-specific subdirectories + base_paths = [ + # Current directory + Path.cwd(), + # Artifacts directory at root of repo + Path.cwd() / "artifacts", + # Libs directory at root of repo + Path.cwd() / "libs", + # Package directory (usually for local dev) + Path(__file__).parent, + # Additional library directory (usually for local dev) + Path(__file__).parent / "libs", + ] + + # Create the full list of paths including platform-specific subdirectories + possible_paths = [] + for base_path in base_paths: + # Add the base path + possible_paths.append(base_path) + # Add platform directory subfolder + possible_paths.append(base_path / platform_dir) + # Add platform identifier subfolder + possible_paths.append(base_path / platform_id) + + # Add system library paths + possible_paths.extend([Path(p) for p in os.environ.get( + "LD_LIBRARY_PATH", "").split(os.pathsep) if p]) + + return possible_paths + + +def dynamically_load_library( + lib_name: Optional[str] = None) -> Optional[ctypes.CDLL]: + """ + Load the dynamic library containing the C-API based on the platform. + + Args: + lib_name: Optional specific library name to load. If provided, only this library will be loaded. + This enables to potentially load wrapper libraries of the C-API that may have an other name + (the presence of required symbols will nevertheless be verified once the library is loaded). + + Returns: + The loaded library or None if loading failed + """ + if sys.platform == "darwin": + c2pa_lib_name = "libc2pa_c.dylib" + elif sys.platform == "linux": + c2pa_lib_name = "libc2pa_c.so" + elif sys.platform == "win32": + c2pa_lib_name = "c2pa_c.dll" + else: + raise RuntimeError(f"Unsupported platform: {sys.platform}") + + if DEBUG_LIBRARY_LOADING: + logger.info(f"Current working directory: {Path.cwd()}") + logger.info(f"Package directory: {Path(__file__).parent}") + logger.info(f"System architecture: {_get_architecture()}") + + # Check for C2PA_LIBRARY_NAME environment variable + env_lib_name = os.environ.get("C2PA_LIBRARY_NAME") + if env_lib_name: + if DEBUG_LIBRARY_LOADING: + logger.info( + f"Using library name from env var C2PA_LIBRARY_NAME: {env_lib_name}") + try: + possible_paths = _get_possible_search_paths() + lib = _load_single_library(env_lib_name, possible_paths) + if lib: + return lib + else: + logger.error(f"Could not find library {env_lib_name} in any of the search paths") + # Continue with normal loading if environment variable library + # name fails + except Exception as e: + logger.error(f"Failed to load library from C2PA_LIBRARY_NAME: {e}") + # Continue with normal loading if environment variable library name + # fails + + possible_paths = _get_possible_search_paths() + + if lib_name: + # If specific library name is provided, only load that one + lib = _load_single_library(lib_name, possible_paths) + if not lib: + logger.error(f"Could not find {lib_name} in any of the search paths: {[str(p) for p in possible_paths]}") + raise RuntimeError(f"Could not find {lib_name} in any of the search paths") + return lib + + # Default path (no library name provided in the environment) + c2pa_lib = _load_single_library(c2pa_lib_name, possible_paths) + if not c2pa_lib: + logger.error(f"Could not find {c2pa_lib_name} in any of the search paths: {[str(p) for p in possible_paths]}") + raise RuntimeError(f"Could not find {c2pa_lib_name} in any of the search paths") + + return c2pa_lib diff --git a/src/callback_signer.rs b/src/callback_signer.rs deleted file mode 100644 index 4be2d3ea..00000000 --- a/src/callback_signer.rs +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright 2024 Adobe. All rights reserved. -// This file is licensed to you under the Apache License, -// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) -// or the MIT license (http://opensource.org/licenses/MIT), -// at your option. -// Unless required by applicable law or agreed to in writing, -// this software is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or -// implied. See the LICENSE-MIT and LICENSE-APACHE files for the -// specific language governing permissions and limitations under -// each license. - -use c2pa::{Signer, SigningAlg}; -use log::debug; - -use crate::Result; - -/// Defines the callback interface for a signer -pub trait SignerCallback: Send + Sync { - /// Sign the given bytes and return the signature - fn sign(&self, bytes: Vec) -> Result>; -} - -/// This is a wrapper around the CallbackSigner for Python -/// -/// Uniffi callbacks are only supported as a method in a structure, so this is a workaround -pub struct CallbackSigner { - signer: Box, -} - -pub struct RemoteSigner { - signer_callback: Box, - alg: SigningAlg, - reserve_size: u32, -} - -impl Signer for RemoteSigner { - fn sign(&self, data: &[u8]) -> c2pa::Result> { - self.signer_callback - .sign(data.to_vec()) - .map_err(|e| c2pa::Error::BadParam(e.to_string())) - } - - fn alg(&self) -> SigningAlg { - self.alg - } - - fn certs(&self) -> c2pa::Result>> { - Ok(Vec::new()) - } - - fn reserve_size(&self) -> usize { - self.reserve_size as usize // TODO: Find better conversion for usize - } - - // signer will return a COSE structure - fn direct_cose_handling(&self) -> bool { - true - } -} - -impl CallbackSigner { - pub fn new( - callback: Box, - alg: SigningAlg, - certs: Vec, - ta_url: Option, - ) -> Self { - // When this closure is called it will call the sign method on the python callback - let python_signer = move |_context: *const (), data: &[u8]| { - callback - .sign(data.to_vec()) - .map_err(|e| c2pa::Error::BadParam(e.to_string())) - }; - - let mut signer = c2pa::CallbackSigner::new(python_signer, alg, certs); - if let Some(url) = ta_url { - signer = signer.set_tsa_url(url); - } - - Self { - signer: Box::new(signer), - } - } - - pub fn new_from_signer( - callback: Box, - alg: SigningAlg, - reserve_size: u32, - ) -> Self { - debug!("c2pa-python: CallbackSigner -> new_from_signer"); - let signer = RemoteSigner { - signer_callback: callback, - alg, - reserve_size, - }; - - Self { - signer: Box::new(signer), - } - } - - /// The python Builder wrapper sign function calls this - #[allow(clippy::borrowed_box)] - pub fn signer(&self) -> &Box { - &self.signer - } -} diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 39e32bd2..00000000 --- a/src/error.rs +++ /dev/null @@ -1,126 +0,0 @@ -use thiserror::Error; -pub type Result = std::result::Result; - -#[derive(Error, Debug)] -/// Defines all possible errors that can occur in this library -pub enum Error { - #[error("Assertion {reason}")] - Assertion { reason: String }, - #[error("AssertionNotFound {reason}")] - AssertionNotFound { reason: String }, - #[error("Decoding {reason}")] - Decoding { reason: String }, - #[error("Encoding {reason}")] - Encoding { reason: String }, - #[error("FileNotFound{reason}")] - FileNotFound { reason: String }, - #[error("Io {reason}")] - Io { reason: String }, - #[error("Json {reason}")] - Json { reason: String }, - #[error("Manifest {reason}")] - Manifest { reason: String }, - #[error("ManifestNotFound {reason}")] - ManifestNotFound { reason: String }, - #[error("NotSupported {reason}")] - NotSupported { reason: String }, - #[error("Other {reason}")] - Other { reason: String }, - #[error("Remote {reason}")] - RemoteManifest { reason: String }, - #[error("ResourceNotFound {reason}")] - ResourceNotFound { reason: String }, - #[error("RwLock")] - RwLock, - #[error("Signature {reason}")] - Signature { reason: String }, - #[error("Verify {reason}")] - Verify { reason: String }, -} - -impl Error { - // Convert c2pa errors to published API errors - #[allow(unused_variables)] - pub(crate) fn from_c2pa_error(err: c2pa::Error) -> Self { - use c2pa::Error::*; - let err_str = err.to_string(); - match err { - c2pa::Error::AssertionMissing { url } => Self::AssertionNotFound { - reason: "".to_string(), - }, - AssertionInvalidRedaction - | AssertionRedactionNotFound - | AssertionUnsupportedVersion => Self::Assertion { reason: err_str }, - ClaimAlreadySigned - | ClaimUnsigned - | ClaimMissingSignatureBox - | ClaimMissingIdentity - | ClaimVersion - | ClaimInvalidContent - | ClaimMissingHardBinding - | ClaimSelfRedact - | ClaimDisallowedRedaction - | UpdateManifestInvalid - | TooManyManifestStores => Self::Manifest { reason: err_str }, - ClaimMissing { label } => Self::ManifestNotFound { reason: err_str }, - AssertionDecoding(_) | ClaimDecoding => Self::Decoding { reason: err_str }, - AssertionEncoding(_) | XmlWriteError | ClaimEncoding => { - Self::Encoding { reason: err_str } - } - InvalidCoseSignature { coset_error } => Self::Signature { reason: err_str }, - CoseSignatureAlgorithmNotSupported - | CoseMissingKey - | CoseX5ChainMissing - | CoseInvalidCert - | CoseSignature - | CoseVerifier - | CoseCertExpiration - | CoseCertRevoked - | CoseInvalidTimeStamp - | CoseTimeStampValidity - | CoseTimeStampMismatch - | CoseTimeStampGeneration - | CoseTimeStampAuthority - | CoseSigboxTooSmall - | InvalidEcdsaSignature => Self::Signature { reason: err_str }, - RemoteManifestFetch(_) | RemoteManifestUrl(_) => { - Self::RemoteManifest { reason: err_str } - } - JumbfNotFound => Self::ManifestNotFound { reason: err_str }, - BadParam(_) | MissingFeature(_) => Self::Other { reason: err_str }, - IoError(_) => Self::Io { reason: err_str }, - JsonError(e) => Self::Json { reason: err_str }, - NotFound | ResourceNotFound(_) | MissingDataBox => { - Self::ResourceNotFound { reason: err_str } - } - FileNotFound(_) => Self::FileNotFound { reason: err_str }, - UnsupportedType => Self::NotSupported { reason: err_str }, - ClaimVerification(_) | InvalidClaim(_) | JumbfParseError(_) => { - Self::Verify { reason: err_str } - } - _ => Self::Other { reason: err_str }, - } - } -} - -impl From for Error { - fn from(err: uniffi::UnexpectedUniFFICallbackError) -> Self { - Self::Other { - reason: err.reason.clone(), - } - } -} - -impl From for Error { - fn from(err: c2pa::Error) -> Self { - Self::from_c2pa_error(err) - } -} - -impl From for Error { - fn from(err: std::io::Error) -> Self { - Self::Io { - reason: err.to_string(), - } - } -} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 2efd83f3..00000000 --- a/src/lib.rs +++ /dev/null @@ -1,254 +0,0 @@ -// Copyright 2023 Adobe. All rights reserved. -// This file is licensed to you under the Apache License, -// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) -// or the MIT license (http://opensource.org/licenses/MIT), -// at your option. -// Unless required by applicable law or agreed to in writing, -// this software is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or -// implied. See the LICENSE-MIT and LICENSE-APACHE files for the -// specific language governing permissions and limitations under -// each license. - -// Uniffi is generating a clippy error in their output. This ignores that error. -#![allow(clippy::empty_line_after_doc_comments)] - -/// This module exports a C2PA library -use std::env; -use std::sync::RwLock; - -pub use c2pa::{settings::load_settings_from_str, Signer, SigningAlg}; - -/// these all need to be public so that the uniffi macro can see them -mod error; -pub use error::{Error, Result}; -mod callback_signer; -pub use callback_signer::{CallbackSigner, SignerCallback}; -mod streams; -pub use streams::{SeekMode, Stream, StreamAdapter}; - -#[cfg(test)] -mod test_stream; - -uniffi::include_scaffolding!("c2pa"); - -/// Returns the version of this library -fn version() -> String { - String::from(env!("CARGO_PKG_VERSION")) -} - -/// Returns the version of the C2PA library -pub fn sdk_version() -> String { - format!( - "{}/{} {}/{}", - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_VERSION"), - c2pa::NAME, - c2pa::VERSION - ) -} - -pub fn load_settings(settings: &str, format: &str) -> Result<()> { - load_settings_from_str(settings, format)?; - Ok(()) -} - -pub struct Reader { - reader: RwLock, -} - -impl Default for Reader { - fn default() -> Self { - Self::new() - } -} - -impl Reader { - pub fn new() -> Self { - Self { - reader: RwLock::new(c2pa::Reader::default()), - } - } - - pub fn from_stream(&self, format: &str, stream: &dyn Stream) -> Result { - // uniffi doesn't allow mutable parameters, so we we use an adapter - let mut stream = StreamAdapter::from(stream); - let reader = c2pa::Reader::from_stream(format, &mut stream)?; - let json = reader.to_string(); - if let Ok(mut st) = self.reader.try_write() { - *st = reader; - } else { - return Err(Error::RwLock); - }; - Ok(json) - } - - pub fn from_manifest_data_and_stream( - &self, - manifest_data: &[u8], - format: &str, - stream: &dyn Stream, - ) -> Result { - // uniffi doesn't allow mutable parameters, so we we use an adapter - let mut stream = StreamAdapter::from(stream); - let reader = - c2pa::Reader::from_manifest_data_and_stream(manifest_data, format, &mut stream)?; - let json = reader.to_string(); - if let Ok(mut st) = self.reader.try_write() { - *st = reader; - } else { - return Err(Error::RwLock); - }; - Ok(json) - } - - pub fn json(&self) -> Result { - if let Ok(st) = self.reader.try_read() { - Ok(st.json()) - } else { - Err(Error::RwLock) - } - } - - pub fn resource_to_stream(&self, uri: &str, stream: &dyn Stream) -> Result { - if let Ok(reader) = self.reader.try_read() { - let mut stream = StreamAdapter::from(stream); - let size = reader.resource_to_stream(uri, &mut stream)?; - Ok(size as u64) - } else { - Err(Error::RwLock) - } - } - - pub fn get_raw_reader(&self) -> &RwLock { - &self.reader - } -} - -pub struct Builder { - // The RwLock is needed because uniffi doesn't allow a mutable self parameter - builder: RwLock, -} - -impl Default for Builder { - fn default() -> Self { - Self::new() - } -} - -impl Builder { - /// Create a new builder - /// - /// Uniffi does not support constructors that return errors - pub fn new() -> Self { - Self { - builder: RwLock::new(c2pa::Builder::default()), - } - } - - /// Create a new builder using the Json manifest definition - pub fn with_json(&self, json: &str) -> Result<()> { - if let Ok(mut builder) = self.builder.try_write() { - *builder = c2pa::Builder::from_json(json)?; - } else { - return Err(Error::RwLock); - }; - Ok(()) - } - - /// Set to true to disable embedding a manifest - pub fn set_no_embed(&self) -> Result<()> { - if let Ok(mut builder) = self.builder.try_write() { - builder.set_no_embed(true); - } else { - return Err(Error::RwLock); - }; - Ok(()) - } - - pub fn set_remote_url(&self, remote_url: &str) -> Result<()> { - if let Ok(mut builder) = self.builder.try_write() { - builder.set_remote_url(remote_url); - } else { - return Err(Error::RwLock); - }; - Ok(()) - } - - /// Add a resource to the builder - pub fn add_resource(&self, uri: &str, stream: &dyn Stream) -> Result<()> { - if let Ok(mut builder) = self.builder.try_write() { - let mut stream = StreamAdapter::from(stream); - builder.add_resource(uri, &mut stream)?; - } else { - return Err(Error::RwLock); - }; - Ok(()) - } - - pub fn add_ingredient( - &self, - ingredient_json: &str, - format: &str, - stream: &dyn Stream, - ) -> Result<()> { - if let Ok(mut builder) = self.builder.try_write() { - let mut stream = StreamAdapter::from(stream); - builder.add_ingredient_from_stream(ingredient_json, format, &mut stream)?; - } else { - return Err(Error::RwLock); - }; - Ok(()) - } - - /// Write the builder to the destination stream as an archive - pub fn to_archive(&self, dest: &dyn Stream) -> Result<()> { - if let Ok(mut builder) = self.builder.try_write() { - let mut dest = StreamAdapter::from(dest); - builder.to_archive(&mut dest)?; - } else { - return Err(Error::RwLock); - }; - Ok(()) - } - - /// Create a new builder from an archive - pub fn from_archive(&self, source: &dyn Stream) -> Result<()> { - if let Ok(mut builder) = self.builder.try_write() { - let mut source = StreamAdapter::from(source); - *builder = c2pa::Builder::from_archive(&mut source)?; - } else { - return Err(Error::RwLock); - }; - Ok(()) - } - - /// Sign an asset and write the result to the destination stream - pub fn sign( - &self, - signer: &CallbackSigner, - format: &str, - source: &dyn Stream, - dest: &dyn Stream, - ) -> Result> { - // uniffi doesn't allow mutable parameters, so we we use an adapter - let mut source = StreamAdapter::from(source); - let mut dest = StreamAdapter::from(dest); - if let Ok(mut builder) = self.builder.try_write() { - let signer = (*signer).signer(); - Ok(builder.sign(signer.as_ref(), format, &mut source, &mut dest)?) - } else { - Err(Error::RwLock) - } - } - - /// Sign an asset and write the result to the destination stream - pub fn sign_file(&self, signer: &CallbackSigner, source: &str, dest: &str) -> Result> { - if let Ok(mut builder) = self.builder.try_write() { - let signer = (*signer).signer(); - Ok(builder.sign_file(signer.as_ref(), source, dest)?) - } else { - Err(Error::RwLock) - } - } -} diff --git a/src/streams.rs b/src/streams.rs deleted file mode 100644 index cab0a6e9..00000000 --- a/src/streams.rs +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright 2023 Adobe. All rights reserved. -// This file is licensed to you under the Apache License, -// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) -// or the MIT license (http://opensource.org/licenses/MIT), -// at your option. - -// Unless required by applicable law or agreed to in writing, -// this software is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or -// implied. See the LICENSE-MIT and LICENSE-APACHE files for the -// specific language governing permissions and limitations under -// each license. - -use std::io::{Read, Seek, SeekFrom, Write}; - -use crate::Result; - -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum SeekMode { - Start = 0, - End = 1, - Current = 2, -} - -/// This allows for a callback stream over the Uniffi interface. -/// Implement these stream functions in the foreign language -/// and this will provide Rust Stream trait implementations -/// This is necessary since the Rust traits cannot be implemented directly -/// as uniffi callbacks -pub trait Stream: Send + Sync { - /// Read a stream of bytes from the stream - fn read_stream(&self, length: u64) -> Result>; - /// Seek to a position in the stream - fn seek_stream(&self, pos: i64, mode: SeekMode) -> Result; - /// Write a stream of bytes to the stream - fn write_stream(&self, data: Vec) -> Result; -} - -impl Stream for Box { - fn read_stream(&self, length: u64) -> Result> { - (**self).read_stream(length) - } - - fn seek_stream(&self, pos: i64, mode: SeekMode) -> Result { - (**self).seek_stream(pos, mode) - } - - fn write_stream(&self, data: Vec) -> Result { - (**self).write_stream(data) - } -} - -impl AsMut for dyn Stream { - fn as_mut(&mut self) -> &mut Self { - self - } -} - -pub struct StreamAdapter<'a> { - pub stream: &'a mut dyn Stream, -} - -impl<'a> StreamAdapter<'a> { - pub fn from_stream_mut(stream: &'a mut dyn Stream) -> Self { - Self { stream } - } -} - -impl<'a> From<&'a dyn Stream> for StreamAdapter<'a> { - #[allow(invalid_reference_casting)] - fn from(stream: &'a dyn Stream) -> Self { - let stream = stream as *const dyn Stream as *mut dyn Stream; - let stream = unsafe { &mut *stream }; - Self { stream } - } -} - -// impl<'a> c2pa::CAIRead for StreamAdapter<'a> {} - -// impl<'a> c2pa::CAIReadWrite for StreamAdapter<'a> {} - -impl Read for StreamAdapter<'_> { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - let bytes = self - .stream - .read_stream(buf.len() as u64) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; - let len = bytes.len(); - buf[..len].copy_from_slice(&bytes); - //println!("read: {:?}", len); - Ok(len) - } -} - -impl Seek for StreamAdapter<'_> { - fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { - let (pos, mode) = match pos { - SeekFrom::Current(pos) => (pos, SeekMode::Current), - SeekFrom::Start(pos) => (pos as i64, SeekMode::Start), - SeekFrom::End(pos) => (pos, SeekMode::End), - }; - //println!("Stream Seek {}", pos); - self.stream - .seek_stream(pos, mode) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)) - } -} - -impl Write for StreamAdapter<'_> { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - let len = self - .stream - .write_stream(buf.to_vec()) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; - Ok(len as usize) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use crate::test_stream::TestStream; - - #[test] - fn test_stream_read() { - let mut test = TestStream::from_memory(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - let mut stream = StreamAdapter::from_stream_mut(&mut test); - let mut buf = [0u8; 5]; - let len = stream.read(&mut buf).unwrap(); - assert_eq!(len, 5); - assert_eq!(buf, [0, 1, 2, 3, 4]); - } - - #[test] - fn test_stream_seek() { - let mut test = TestStream::from_memory(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - let mut stream = StreamAdapter { stream: &mut test }; - let pos = stream.seek(SeekFrom::Start(5)).unwrap(); - assert_eq!(pos, 5); - let mut buf = [0u8; 5]; - let len = stream.read(&mut buf).unwrap(); - assert_eq!(len, 5); - assert_eq!(buf, [5, 6, 7, 8, 9]); - } - - #[test] - fn test_stream_write() { - let mut test = TestStream::new(); - let mut stream = StreamAdapter { stream: &mut test }; - let len = stream.write(&[0, 1, 2, 3, 4]).unwrap(); - assert_eq!(len, 5); - stream.seek(SeekFrom::Start(0)).unwrap(); - let mut buf = [0u8; 5]; - let len = stream.read(&mut buf).unwrap(); - assert_eq!(len, 5); - assert_eq!(buf, [0, 1, 2, 3, 4]); - } -} diff --git a/src/test_stream.rs b/src/test_stream.rs deleted file mode 100644 index 696a04af..00000000 --- a/src/test_stream.rs +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright 2023 Adobe. All rights reserved. -// This file is licensed to you under the Apache License, -// Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) -// or the MIT license (http://opensource.org/licenses/MIT), -// at your option. - -// Unless required by applicable law or agreed to in writing, -// this software is distributed on an "AS IS" BASIS, WITHOUT -// WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or -// implied. See the LICENSE-MIT and LICENSE-APACHE files for the -// specific language governing permissions and limitations under -// each license. - -use std::io::{Read, Seek, SeekFrom, Write}; -use std::sync::RwLock; - -use std::io::Cursor; - -use crate::{Error, Result, SeekMode, Stream}; - -pub struct TestStream { - stream: RwLock>>, -} - -impl TestStream { - pub fn new() -> Self { - Self { - stream: RwLock::new(Cursor::new(Vec::new())), - } - } - pub fn from_memory(data: Vec) -> Self { - Self { - stream: RwLock::new(Cursor::new(data)), - } - } -} - -impl Stream for TestStream { - fn read_stream(&self, length: u64) -> Result> { - if let Ok(mut stream) = RwLock::write(&self.stream) { - let mut data = vec![0u8; length as usize]; - let bytes_read = stream.read(&mut data).map_err(|e| Error::Io { - reason: e.to_string(), - })?; - data.truncate(bytes_read); - //println!("read_stream: {:?}, pos {:?}", data.len(), (*stream).position()); - Ok(data) - } else { - Err(Error::Other { - reason: "RwLock".to_string(), - }) - } - } - - fn seek_stream(&self, pos: i64, mode: SeekMode) -> Result { - if let Ok(mut stream) = RwLock::write(&self.stream) { - //stream.seek(SeekFrom::Start(pos as u64)).map_err(|e| StreamError::Io{ reason: e.to_string()})?; - let whence = match mode { - SeekMode::Start => SeekFrom::Start(pos as u64), - SeekMode::End => SeekFrom::End(pos), - SeekMode::Current => SeekFrom::Current(pos), - }; - stream.seek(whence).map_err(|e| Error::Io { - reason: e.to_string(), - }) - } else { - Err(Error::Other { - reason: "RwLock".to_string(), - }) - } - } - - fn write_stream(&self, data: Vec) -> Result { - if let Ok(mut stream) = RwLock::write(&self.stream) { - let len = stream.write(&data).map_err(|e| Error::Io { - reason: e.to_string(), - })?; - Ok(len as u64) - } else { - Err(Error::Other { - reason: "RwLock".to_string(), - }) - } - } -} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/benchmark.py b/tests/benchmark.py index 7b1e7df6..e2930c74 100644 --- a/tests/benchmark.py +++ b/tests/benchmark.py @@ -1,9 +1,31 @@ -from c2pa import Builder, Error, Reader, SigningAlg, create_signer, sdk_version, sign_ps256 +# Copyright 2025 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license.import unittest + import os import io +import json +import shutil +from c2pa import Reader, Builder, Signer, C2paSigningAlg, C2paSignerInfo + PROJECT_PATH = os.getcwd() -testPath = os.path.join(PROJECT_PATH, "tests", "fixtures", "C.jpg") +# Test paths +test_path = os.path.join(PROJECT_PATH, "tests", "fixtures", "C.jpg") +temp_dir = os.path.join(PROJECT_PATH, "tests", "temp") +output_path = os.path.join(temp_dir, "python_out.jpg") + +# Ensure temp directory exists +os.makedirs(temp_dir, exist_ok=True) manifestDefinition = { "claim_generator": "python_test", @@ -15,57 +37,110 @@ "title": "Python Test Image", "ingredients": [], "assertions": [ - { 'label': 'stds.schema-org.CreativeWork', + {'label': 'stds.schema-org.CreativeWork', 'data': { '@context': 'http://schema.org/', '@type': 'CreativeWork', 'author': [ - { '@type': 'Person', + {'@type': 'Person', 'name': 'Gavin Peacock' - } + } ] }, 'kind': 'Json' - } + } ] } -private_key = open("tests/fixtures/ps256.pem","rb").read() - -# Define a function that signs data with PS256 using a private key -def sign(data: bytes) -> bytes: - print("date len = ", len(data)) - return sign_ps256(data, private_key) -# load the public keys from a pem file -certs = open("tests/fixtures/ps256.pub","rb").read() +# Load private key and certificates +private_key = open("tests/fixtures/ps256.pem", "rb").read() +certs = open("tests/fixtures/ps256.pub", "rb").read() # Create a local Ps256 signer with certs and a timestamp server -signer = create_signer(sign, SigningAlg.PS256, certs, "http://timestamp.digicert.com") - +signer_info = C2paSignerInfo( + alg=b"ps256", + sign_cert=certs, + private_key=private_key, + ta_url=b"http://timestamp.digicert.com" +) +signer = Signer.from_info(signer_info) builder = Builder(manifestDefinition) -source = open(testPath, "rb").read() +# Load source image +source = open(test_path, "rb").read() + +# Run the benchmark: python -m pytest tests/benchmark.py -v + + +def test_files_read(): + """Benchmark reading a C2PA asset from a file.""" + with open(test_path, "rb") as f: + reader = Reader("image/jpeg", f) + result = reader.json() + assert result is not None + # Parse the JSON string into a dictionary + result_dict = json.loads(result) + # Additional assertions to verify the structure of the result + assert "active_manifest" in result_dict + assert "manifests" in result_dict + assert "validation_state" in result_dict + assert result_dict["validation_state"] == "Valid" + + +def test_streams_read(): + """Benchmark reading a C2PA asset from a stream.""" + with open(test_path, "rb") as file: + source = file.read() + reader = Reader("image/jpeg", io.BytesIO(source)) + result = reader.json() + assert result is not None + # Parse the JSON string into a dictionary + result_dict = json.loads(result) + # Additional assertions to verify the structure of the result + assert "active_manifest" in result_dict + assert "manifests" in result_dict + assert "validation_state" in result_dict + assert result_dict["validation_state"] == "Valid" -testPath = "/Users/gpeacock/Pictures/Lightroom Saved Photos/IMG_0483.jpg" -testPath = "tests/fixtures/c.jpg" -outputPath = "target/python_out.jpg" def test_files_build(): - # Delete the output file if it exists - if os.path.exists(outputPath): - os.remove(outputPath) - builder.sign_file(signer, testPath, outputPath) + """Benchmark building a C2PA asset from a file.""" + # Delete the output file if it exists + if os.path.exists(output_path): + os.remove(output_path) + with open(test_path, "rb") as source_file: + with open(output_path, "wb") as dest_file: + builder.sign(signer, "image/jpeg", source_file, dest_file) + def test_streams_build(): - #with open(testPath, "rb") as file: + """Benchmark building a C2PA asset from a stream.""" output = io.BytesIO(bytearray()) - builder.sign(signer, "image/jpeg", io.BytesIO(source), output) + with open(test_path, "rb") as source_file: + builder.sign(signer, "image/jpeg", source_file, output) + + +def test_files_reading(benchmark): + """Benchmark file-based reading.""" + benchmark(test_files_read) + -def test_func(benchmark): +def test_streams_reading(benchmark): + """Benchmark stream-based reading.""" + benchmark(test_streams_read) + + +def test_files_builder_signer_benchmark(benchmark): + """Benchmark file-based building.""" benchmark(test_files_build) -def test_streams(benchmark): + +def test_streams_builder_benchmark(benchmark): + """Benchmark stream-based building.""" benchmark(test_streams_build) -#def test_signer(benchmark): -# benchmark(sign_ps256, data, private_key) \ No newline at end of file + +def teardown_module(module): + """Clean up temporary files after all tests.""" + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..bd97d623 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,28 @@ +# Copyright 2025 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license.import unittest + +import os +import sys +import pytest + +@pytest.fixture +def fixtures_dir(): + """Provide the path to the fixtures directory.""" + return os.path.join(os.path.dirname(__file__), "fixtures") + +pytest.fixture(scope="session", autouse=True) +def setup_c2pa_library(): + """Ensure the src/c2pa library path is added to sys.path.""" + c2pa_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../src/c2pa")) + if c2pa_path not in sys.path: + sys.path.insert(0, c2pa_path) \ No newline at end of file diff --git a/tests/fixtures/files-for-reading-tests/CA.jpg b/tests/fixtures/files-for-reading-tests/CA.jpg new file mode 100644 index 00000000..551e611e Binary files /dev/null and b/tests/fixtures/files-for-reading-tests/CA.jpg differ diff --git a/tests/fixtures/files-for-reading-tests/CACAE-uri-CA.jpg b/tests/fixtures/files-for-reading-tests/CACAE-uri-CA.jpg new file mode 100644 index 00000000..1ca39817 Binary files /dev/null and b/tests/fixtures/files-for-reading-tests/CACAE-uri-CA.jpg differ diff --git a/tests/fixtures/files-for-reading-tests/CA_ct.jpg b/tests/fixtures/files-for-reading-tests/CA_ct.jpg new file mode 100644 index 00000000..8f464fd7 Binary files /dev/null and b/tests/fixtures/files-for-reading-tests/CA_ct.jpg differ diff --git a/tests/fixtures/files-for-reading-tests/CIE-sig-CA.jpg b/tests/fixtures/files-for-reading-tests/CIE-sig-CA.jpg new file mode 100644 index 00000000..400d27c3 Binary files /dev/null and b/tests/fixtures/files-for-reading-tests/CIE-sig-CA.jpg differ diff --git a/tests/fixtures/files-for-reading-tests/C_with_CAWG_data.jpg b/tests/fixtures/files-for-reading-tests/C_with_CAWG_data.jpg new file mode 100644 index 00000000..4088d990 Binary files /dev/null and b/tests/fixtures/files-for-reading-tests/C_with_CAWG_data.jpg differ diff --git a/tests/fixtures/files-for-reading-tests/E-sig-CA.jpg b/tests/fixtures/files-for-reading-tests/E-sig-CA.jpg new file mode 100644 index 00000000..2bbedb96 Binary files /dev/null and b/tests/fixtures/files-for-reading-tests/E-sig-CA.jpg differ diff --git a/tests/fixtures/files-for-reading-tests/XCA.jpg b/tests/fixtures/files-for-reading-tests/XCA.jpg new file mode 100644 index 00000000..18723ffa Binary files /dev/null and b/tests/fixtures/files-for-reading-tests/XCA.jpg differ diff --git a/tests/fixtures/files-for-reading-tests/boxhash.jpg b/tests/fixtures/files-for-reading-tests/boxhash.jpg new file mode 100644 index 00000000..96a2bedc Binary files /dev/null and b/tests/fixtures/files-for-reading-tests/boxhash.jpg differ diff --git a/tests/fixtures/files-for-reading-tests/cloud.jpg b/tests/fixtures/files-for-reading-tests/cloud.jpg new file mode 100644 index 00000000..9735b328 Binary files /dev/null and b/tests/fixtures/files-for-reading-tests/cloud.jpg differ diff --git a/tests/fixtures/files-for-reading-tests/legacy_ingredient_hash.jpg b/tests/fixtures/files-for-reading-tests/legacy_ingredient_hash.jpg new file mode 100644 index 00000000..b7213f17 Binary files /dev/null and b/tests/fixtures/files-for-reading-tests/legacy_ingredient_hash.jpg differ diff --git a/tests/fixtures/files-for-reading-tests/video1.mp4 b/tests/fixtures/files-for-reading-tests/video1.mp4 new file mode 100644 index 00000000..5802d5d2 Binary files /dev/null and b/tests/fixtures/files-for-reading-tests/video1.mp4 differ diff --git a/tests/fixtures/files-for-signing-tests/IMG_0003.jpg b/tests/fixtures/files-for-signing-tests/IMG_0003.jpg new file mode 100644 index 00000000..be277a89 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/IMG_0003.jpg differ diff --git a/tests/fixtures/files-for-signing-tests/P1000827.jpg b/tests/fixtures/files-for-signing-tests/P1000827.jpg new file mode 100644 index 00000000..819e6360 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/P1000827.jpg differ diff --git a/tests/fixtures/files-for-signing-tests/TUSCANY.TIF b/tests/fixtures/files-for-signing-tests/TUSCANY.TIF new file mode 100644 index 00000000..048f0017 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/TUSCANY.TIF differ diff --git a/tests/fixtures/files-for-signing-tests/cloudx.jpg b/tests/fixtures/files-for-signing-tests/cloudx.jpg new file mode 100755 index 00000000..30b68ca0 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/cloudx.jpg differ diff --git a/tests/fixtures/files-for-signing-tests/earth_apollo17.jpg b/tests/fixtures/files-for-signing-tests/earth_apollo17.jpg new file mode 100644 index 00000000..4d7ec6a5 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/earth_apollo17.jpg differ diff --git a/tests/fixtures/files-for-signing-tests/exp-test1.png b/tests/fixtures/files-for-signing-tests/exp-test1.png new file mode 100644 index 00000000..6b8dc108 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/exp-test1.png differ diff --git a/tests/fixtures/files-for-signing-tests/legacy.mp4 b/tests/fixtures/files-for-signing-tests/legacy.mp4 new file mode 100644 index 00000000..51f9f093 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/legacy.mp4 differ diff --git a/tests/fixtures/files-for-signing-tests/libpng-test.png b/tests/fixtures/files-for-signing-tests/libpng-test.png new file mode 100644 index 00000000..c4af2ada Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/libpng-test.png differ diff --git a/tests/fixtures/files-for-signing-tests/mars.webp b/tests/fixtures/files-for-signing-tests/mars.webp new file mode 100644 index 00000000..31446651 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/mars.webp differ diff --git a/tests/fixtures/files-for-signing-tests/prerelease.jpg b/tests/fixtures/files-for-signing-tests/prerelease.jpg new file mode 100644 index 00000000..e142ed12 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/prerelease.jpg differ diff --git a/tests/fixtures/files-for-signing-tests/sample1.avif b/tests/fixtures/files-for-signing-tests/sample1.avif new file mode 100644 index 00000000..755463c6 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/sample1.avif differ diff --git a/tests/fixtures/files-for-signing-tests/sample1.gif b/tests/fixtures/files-for-signing-tests/sample1.gif new file mode 100644 index 00000000..7d0d1a41 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/sample1.gif differ diff --git a/tests/fixtures/files-for-signing-tests/sample1.heic b/tests/fixtures/files-for-signing-tests/sample1.heic new file mode 100644 index 00000000..00cc549c Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/sample1.heic differ diff --git a/tests/fixtures/files-for-signing-tests/sample1.heif b/tests/fixtures/files-for-signing-tests/sample1.heif new file mode 100644 index 00000000..7a68f35d Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/sample1.heif differ diff --git a/tests/fixtures/files-for-signing-tests/sample1.m4a b/tests/fixtures/files-for-signing-tests/sample1.m4a new file mode 100644 index 00000000..f6d5e925 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/sample1.m4a differ diff --git a/tests/fixtures/files-for-signing-tests/sample1.mp3 b/tests/fixtures/files-for-signing-tests/sample1.mp3 new file mode 100644 index 00000000..d134f76d Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/sample1.mp3 differ diff --git a/tests/fixtures/files-for-signing-tests/sample1.png b/tests/fixtures/files-for-signing-tests/sample1.png new file mode 100644 index 00000000..cfd2f19a Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/sample1.png differ diff --git a/tests/fixtures/files-for-signing-tests/sample1.webp b/tests/fixtures/files-for-signing-tests/sample1.webp new file mode 100644 index 00000000..abc8d790 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/sample1.webp differ diff --git a/tests/fixtures/files-for-signing-tests/test.avi b/tests/fixtures/files-for-signing-tests/test.avi new file mode 100644 index 00000000..cc5ef6a7 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/test.avi differ diff --git a/tests/fixtures/files-for-signing-tests/test.webp b/tests/fixtures/files-for-signing-tests/test.webp new file mode 100644 index 00000000..c4a7b16c Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/test.webp differ diff --git a/tests/fixtures/files-for-signing-tests/test_lossless.webp b/tests/fixtures/files-for-signing-tests/test_lossless.webp new file mode 100644 index 00000000..288a232c Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/test_lossless.webp differ diff --git a/tests/fixtures/files-for-signing-tests/test_xmp.webp b/tests/fixtures/files-for-signing-tests/test_xmp.webp new file mode 100644 index 00000000..da71bcdd Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/test_xmp.webp differ diff --git a/tests/fixtures/files-for-signing-tests/thumbnail.jpg b/tests/fixtures/files-for-signing-tests/thumbnail.jpg new file mode 100644 index 00000000..be277a89 Binary files /dev/null and b/tests/fixtures/files-for-signing-tests/thumbnail.jpg differ diff --git a/tests/test_api.py b/tests/test_api.py deleted file mode 100644 index 490bf086..00000000 --- a/tests/test_api.py +++ /dev/null @@ -1,192 +0,0 @@ -# Copyright 2023 Adobe. All rights reserved. -# This file is licensed to you under the Apache License, -# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) -# or the MIT license (http://opensource.org/licenses/MIT), -# at your option. -# Unless required by applicable law or agreed to in writing, -# this software is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or -# implied. See the LICENSE-MIT and LICENSE-APACHE files for the -# specific language governing permissions and limitations under -# each license. - -import json -import os -import pytest -import tempfile -import shutil -import unittest - -from c2pa import Builder, Error, Reader, SigningAlg, create_signer, sdk_version, sign_ps256, version - -# a little helper function to get a value from a nested dictionary -from functools import reduce -import operator - -def getitem(d, key): - return reduce(operator.getitem, key, d) - -# define the manifest we will use for testing -manifest_def = { - "claim_generator_info": [{ - "name": "python test", - "version": "0.1" - }], - "title": "My Title", - "thumbnail": { - "format": "image/jpeg", - "identifier": "A.jpg" - }, - "assertions": [ - { - "label": "c2pa.training-mining", - "data": { - "entries": { - "c2pa.ai_generative_training": { "use": "notAllowed" }, - "c2pa.ai_inference": { "use": "notAllowed" }, - "c2pa.ai_training": { "use": "notAllowed" }, - "c2pa.data_mining": { "use": "notAllowed" } - } - } - } - ] -} - -# ingredient we will use for testing -ingredient_def = { - "relationship": "parentOf", - "thumbnail": { - "identifier": "A.jpg", - "format": "image/jpeg" - } -} - -class TestC2paSdk(unittest.TestCase): - def test_version(self): - assert version() == "0.9.0" - - def test_sdk_version(self): - assert "c2pa-rs/" in sdk_version() - -class TestReader(unittest.TestCase): - def test_v2_read_cloud_manifest(self): - reader = Reader.from_file("tests/fixtures/cloud.jpg") - manifest = reader.get_active_manifest() - assert manifest is not None - - def test_v2_read(self): - #example of reading a manifest store from a file - try: - reader = Reader.from_file("tests/fixtures/C.jpg") - manifest = reader.get_active_manifest() - assert manifest is not None - assert "make_test_images" in manifest["claim_generator"] - assert manifest["title"]== "C.jpg" - assert manifest["format"] == "image/jpeg" - # There should be no validation status errors - assert manifest.get("validation_status") == None - # read creative work assertion (author name) - assert getitem(manifest,("assertions",0,"label")) == "stds.schema-org.CreativeWork" - assert getitem(manifest,("assertions",0,"data","author",0,"name")) == "Adobe make_test" - # read Actions assertion - assert getitem(manifest,("assertions",1,"label")) == "c2pa.actions" - assert getitem(manifest,("assertions",1,"data","actions",0,"action")) == "c2pa.created" - # read signature info - assert getitem(manifest,("signature_info","issuer")) == "C2PA Test Signing Cert" - # read thumbnail data from file - assert getitem(manifest,("thumbnail","format")) == "image/jpeg" - # check the thumbnail data - uri = getitem(manifest,("thumbnail","identifier")) - reader.resource_to_file(uri, "target/thumbnail_read_v2.jpg") - - except Exception as e: - print("Failed to read manifest store: " + str(e)) - exit(1) - - def test_reader_from_file_no_store(self): - with pytest.raises(Error.ManifestNotFound) as err: - reader = Reader.from_file("tests/fixtures/A.jpg") - -class TestSignerr(unittest.TestCase): - def test_v2_sign(self): - # define a source folder for any assets we need to read - data_dir = "tests/fixtures/" - try: - key = open(data_dir + "ps256.pem", "rb").read() - def sign(data: bytes) -> bytes: - return sign_ps256(data, key) - - certs = open(data_dir + "ps256.pub", "rb").read() - # Create a local signer from a certificate pem file - signer = create_signer(sign, SigningAlg.PS256, certs, "http://timestamp.digicert.com") - - builder = Builder(manifest_def) - - builder.add_ingredient_file(ingredient_def, data_dir + "A.jpg") - - builder.add_resource_file("A.jpg", data_dir + "A.jpg") - - builder.to_archive(open("target/archive.zip", "wb")) - - builder = Builder.from_archive(open("target/archive.zip", "rb")) - - with tempfile.TemporaryDirectory() as output_dir: - output_path = output_dir + "out.jpg" - if os.path.exists(output_path): - os.remove(output_path) - c2pa_data = builder.sign_file(signer, data_dir + "A.jpg", output_dir + "out.jpg") - assert len(c2pa_data) > 0 - - reader = Reader.from_file(output_dir + "out.jpg") - print(reader.json()) - manifest_store = json.loads(reader.json()) - manifest = manifest_store["manifests"][manifest_store["active_manifest"]] - assert "python_test" in manifest["claim_generator"] - # check custom title and format - assert manifest["title"]== "My Title" - assert manifest,["format"] == "image/jpeg" - # There should be no validation status errors - assert manifest.get("validation_status") == None - assert manifest["ingredients"][0]["relationship"] == "parentOf" - assert manifest["ingredients"][0]["title"] == "A.jpg" - except Exception as e: - print("Failed to sign manifest store: " + str(e)) - exit(1) - - # Test signing the same source and destination file - def test_v2_sign_file_same(self): - data_dir = "tests/fixtures/" - try: - key = open(data_dir + "ps256.pem", "rb").read() - def sign(data: bytes) -> bytes: - return sign_ps256(data, key) - - certs = open(data_dir + "ps256.pub", "rb").read() - # Create a local signer from a certificate pem file - signer = create_signer(sign, SigningAlg.PS256, certs, "http://timestamp.digicert.com") - - builder = Builder(manifest_def) - - builder.add_resource_file("A.jpg", data_dir + "A.jpg") - - with tempfile.TemporaryDirectory() as output_dir: - path = output_dir + "/A.jpg" - # Copy the file from data_dir to output_dir - shutil.copy(data_dir + "A.jpg", path) - c2pa_data = builder.sign_file(signer, path, path) - assert len(c2pa_data) > 0 - - reader = Reader.from_file(path) - manifest = reader.get_active_manifest() - - # check custom title and format - assert manifest["title"]== "My Title" - assert manifest["format"] == "image/jpeg" - # There should be no validation status errors - assert manifest.get("validation_status") == None - except Exception as e: - print("Failed to sign manifest store: " + str(e)) - #exit(1) - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index d36473d6..6a98cedc 100644 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -16,122 +16,937 @@ import json import unittest from unittest.mock import mock_open, patch +import ctypes +import warnings -from c2pa import Builder, Error, Reader, SigningAlg, create_signer, sdk_version, sign_ps256, load_settings_file +from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version +from c2pa.c2pa import Stream, read_ingredient_file, read_file, sign_file, load_settings -PROJECT_PATH = os.getcwd() +# Suppress deprecation warnings +warnings.filterwarnings("ignore", category=DeprecationWarning) -testPath = os.path.join(PROJECT_PATH, "tests", "fixtures", "C.jpg") +PROJECT_PATH = os.getcwd() +FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") +DEFAULT_TEST_FILE_NAME = "C.jpg" +DEFAULT_TEST_FILE = os.path.join(FIXTURES_DIR, DEFAULT_TEST_FILE_NAME) +INGREDIENT_TEST_FILE = os.path.join(FIXTURES_DIR, "A.jpg") +ALTERNATIVE_INGREDIENT_TEST_FILE = os.path.join(FIXTURES_DIR, "cloud.jpg") class TestC2paSdk(unittest.TestCase): - def test_version(self): - self.assertIn("0.9.0", sdk_version()) + def test_sdk_version(self): + self.assertIn("0.55.0", sdk_version()) class TestReader(unittest.TestCase): + def setUp(self): + # Use the fixtures_dir fixture to set up paths + self.data_dir = FIXTURES_DIR + self.testPath = DEFAULT_TEST_FILE + def test_stream_read(self): - with open(testPath, "rb") as file: - reader = Reader("image/jpeg",file) - json = reader.json() - self.assertIn("C.jpg", json) + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + json_data = reader.json() + self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) def test_stream_read_and_parse(self): - with open(testPath, "rb") as file: + with open(self.testPath, "rb") as file: reader = Reader("image/jpeg", file) manifest_store = json.loads(reader.json()) title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] - self.assertEqual(title, "C.jpg") + self.assertEqual(title, DEFAULT_TEST_FILE_NAME) - def test_json_decode_err(self): - with self.assertRaises(Error.Io): - manifest_store = Reader("image/jpeg","foo") + def test_stream_read_string_stream(self): + with Reader("image/jpeg", self.testPath) as reader: + json_data = reader.json() + self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + + def test_stream_read_string_stream_and_parse(self): + with Reader("image/jpeg", self.testPath) as reader: + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, DEFAULT_TEST_FILE_NAME) def test_reader_bad_format(self): with self.assertRaises(Error.NotSupported): - with open(testPath, "rb") as file: + with open(self.testPath, "rb") as file: reader = Reader("badFormat", file) def test_settings_trust(self): - load_settings_file("tests/fixtures/settings.toml") - with open(testPath, "rb") as file: - reader = Reader("image/jpeg",file) - json = reader.json() - self.assertIn("C.jpg", json) + # load_settings_file("tests/fixtures/settings.toml") + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + json_data = reader.json() + self.assertIn(DEFAULT_TEST_FILE_NAME, json_data) + + def test_reader_double_close(self): + """Test that multiple close calls are handled gracefully.""" + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + reader.close() + # Second close should not raise an exception + reader.close() + # Verify reader is closed + with self.assertRaises(Error): + reader.json() + + def test_reader_close_cleanup(self): + """Test that close properly cleans up all resources.""" + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + # Store references to internal objects + reader_ref = reader._reader + stream_ref = reader._own_stream + # Close the reader + reader.close() + # Verify all resources are cleaned up + self.assertIsNone(reader._reader) + self.assertIsNone(reader._own_stream) + # Verify reader is marked as closed + self.assertTrue(reader._closed) + + def test_resource_to_stream_on_closed_reader(self): + """Test that resource_to_stream correctly raises error on closed.""" + reader = Reader("image/jpeg", self.testPath) + reader.close() + with self.assertRaises(Error): + reader.resource_to_stream("", io.BytesIO(bytearray())) + + def test_read_all_files(self): + """Test reading C2PA metadata from all files in the fixtures/files-for-reading-tests directory""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav' + } + + # Skip system files + skip_files = { + '.DS_Store' + } + + for filename in os.listdir(reading_dir): + if filename in skip_files: + continue + + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + continue + + mime_type = mime_types[ext] + + try: + with open(file_path, "rb") as file: + reader = Reader(mime_type, file) + json_data = reader.json() + self.assertIsInstance(json_data, str) + # Verify the manifest contains expected fields + manifest = json.loads(json_data) + self.assertIn("manifests", manifest) + self.assertIn("active_manifest", manifest) + except Exception as e: + self.fail(f"Failed to read metadata from {filename}: {str(e)}") + + def test_read_all_files_using_extension(self): + """Test reading C2PA metadata from all files in the fixtures/files-for-reading-tests directory""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + extensions = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + } + + # Skip system files + skip_files = { + '.DS_Store' + } + + for filename in os.listdir(reading_dir): + if filename in skip_files: + continue + + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in extensions: + continue + + try: + with open(file_path, "rb") as file: + # Remove the leading dot + parsed_extension = ext[1:] + reader = Reader(parsed_extension, file) + json_data = reader.json() + self.assertIsInstance(json_data, str) + # Verify the manifest contains expected fields + manifest = json.loads(json_data) + self.assertIn("manifests", manifest) + self.assertIn("active_manifest", manifest) + except Exception as e: + self.fail(f"Failed to read metadata from {filename}: {str(e)}") + class TestBuilder(unittest.TestCase): - # Define a manifest as a dictionary - manifestDefinition = { - "claim_generator": "python_test", - "claim_generator_info": [{ - "name": "python_test", - "version": "0.0.1", - }], - "format": "image/jpeg", - "title": "Python Test Image", - "ingredients": [], - "assertions": [ - { 'label': 'stds.schema-org.CreativeWork', - 'data': { - '@context': 'http://schema.org/', - '@type': 'CreativeWork', - 'author': [ - { '@type': 'Person', - 'name': 'Gavin Peacock' - } - ] - }, - 'kind': 'Json' - } - ] - } - - # Define a function that signs data with PS256 using a private key - def sign(data: bytes) -> bytes: - key = open("tests/fixtures/ps256.pem","rb").read() - return sign_ps256(data, key) - - # load the public keys from a pem file - certs = open("tests/fixtures/ps256.pub","rb").read() - - # Create a local Ps256 signer with certs and a timestamp server - signer = create_signer(sign, SigningAlg.PS256, certs, "http://timestamp.digicert.com") + def setUp(self): + # Use the fixtures_dir fixture to set up paths + self.data_dir = FIXTURES_DIR + self.testPath = DEFAULT_TEST_FILE + self.testPath2 = INGREDIENT_TEST_FILE + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + self.certs = cert_file.read() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + self.key = key_file.read() + + # Create a local Ps256 signer with certs and a timestamp server + self.signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + self.signer = Signer.from_info(self.signer_info) + + self.testPath3 = os.path.join(self.data_dir, "A_thumbnail.jpg") + self.testPath4 = ALTERNATIVE_INGREDIENT_TEST_FILE + + # Define a manifest as a dictionary + self.manifestDefinition = { + "claim_generator": "python_test", + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + "format": "image/jpeg", + "title": "Python Test Image", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.opened" + } + ] + } + } + ] + } + + def test_reserve_size_on_closed_signer(self): + self.signer.close() + with self.assertRaises(Error): + self.signer.reserve_size() def test_streams_sign(self): - with open(testPath, "rb") as file: - builder = Builder(TestBuilder.manifestDefinition) + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) output = io.BytesIO(bytearray()) - builder.sign(TestBuilder.signer, "image/jpeg", file, output) + builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) self.assertNotIn("validation_status", json_data) + output.close() def test_archive_sign(self): - with open(testPath, "rb") as file: - builder = Builder(TestBuilder.manifestDefinition) + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) archive = io.BytesIO(bytearray()) builder.to_archive(archive) builder = Builder.from_archive(archive) output = io.BytesIO(bytearray()) - builder.sign(TestBuilder.signer, "image/jpeg", file, output) + builder.sign(self.signer, "image/jpeg", file, output) output.seek(0) reader = Reader("image/jpeg", output) json_data = reader.json() self.assertIn("Python Test", json_data) self.assertNotIn("validation_status", json_data) + archive.close() + output.close() def test_remote_sign(self): - with open(testPath, "rb") as file: - builder = Builder(TestBuilder.manifestDefinition) + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition) builder.set_no_embed() output = io.BytesIO(bytearray()) - manifest_data = builder.sign(TestBuilder.signer, "image/jpeg", file, output) + manifest_data = builder.sign( + self.signer, "image/jpeg", file, output) output.seek(0) reader = Reader("image/jpeg", output, manifest_data) json_data = reader.json() self.assertIn("Python Test", json_data) self.assertNotIn("validation_status", json_data) + output.close() + + def test_sign_all_files(self): + """Test signing all files in both fixtures directories""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav' + } + + # Skip files that are known to be invalid or unsupported + skip_files = { + 'sample3.invalid.wav', # Invalid file + } + + # Process both directories + for directory in [signing_dir, reading_dir]: + for filename in os.listdir(directory): + if filename in skip_files: + continue + + file_path = os.path.join(directory, filename) + if not os.path.isfile(file_path): + continue + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + continue + + mime_type = mime_types[ext] + + try: + with open(file_path, "rb") as file: + builder = Builder(self.manifestDefinition) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + reader = Reader(mime_type, output) + json_data = reader.json() + self.assertIn("Python Test", json_data) + self.assertNotIn("validation_status", json_data) + output.close() + except Error.NotSupported: + continue + except Exception as e: + self.fail(f"Failed to sign {filename}: {str(e)}") + + def test_builder_double_close(self): + """Test that multiple close calls are handled gracefully.""" + builder = Builder(self.manifestDefinition) + # First close + builder.close() + # Second close should not raise an exception + builder.close() + # Verify builder is closed + with self.assertRaises(Error): + builder.set_no_embed() + + def test_builder_add_ingredient_on_closed_builder(self): + """Test that exception is raised when trying to add ingredient after close.""" + builder = Builder(self.manifestDefinition) + + builder.close() + + with self.assertRaises(Error): + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + def test_builder_add_ingredient(self): + """Test Builder class operations with a real file.""" + # Test creating builder from JSON + + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # Test adding ingredient + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + builder.close() + + def test_builder_add_multiple_ingredients(self): + """Test Builder class operations with a real file.""" + # Test creating builder from JSON + + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # Test builder operations + builder.set_no_embed() + builder.set_remote_url("http://test.url") + + # Test adding resource + with open(self.testPath, 'rb') as f: + builder.add_resource("test_uri", f) + + # Test adding ingredient + ingredient_json = '{"test": "ingredient"}' + with open(self.testPath, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + # Test adding another ingredient + ingredient_json = '{"test": "ingredient2"}' + with open(self.testPath2, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/png", f) + + builder.close() + + def test_builder_sign_with_ingredient(self): + """Test Builder class operations with a real file.""" + # Test creating builder from JSON + + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # Test adding ingredient + ingredient_json = '{"title": "Test Ingredient"}' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) + + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual(first_ingredient["title"], "Test Ingredient") + + builder.close() + + def test_builder_sign_with_duplicate_ingredient(self): + """Test Builder class operations with a real file.""" + # Test creating builder from JSON + + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # Test adding ingredient + ingredient_json = '{"title": "Test Ingredient"}' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) + + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual(first_ingredient["title"], "Test Ingredient") + + # Verify subsequent labels are unique and have a double underscore with a monotonically inc. index + second_ingredient = active_manifest["ingredients"][1] + self.assertTrue(second_ingredient["label"].endswith("__1")) + + third_ingredient = active_manifest["ingredients"][2] + self.assertTrue(third_ingredient["label"].endswith("__2")) + + builder.close() + + def test_builder_sign_with_ingredient_from_stream(self): + """Test Builder class operations with a real file using stream for ingredient.""" + # Test creating builder from JSON + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # Test adding ingredient using stream + ingredient_json = '{"title": "Test Ingredient Stream"}' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient_from_stream( + ingredient_json, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertTrue(len(active_manifest["ingredients"]) > 0) + + # Verify the first ingredient's title matches what we set + first_ingredient = active_manifest["ingredients"][0] + self.assertEqual( + first_ingredient["title"], + "Test Ingredient Stream") + + builder.close() + + def test_builder_sign_with_multiple_ingredient(self): + """Test Builder class operations with multiple ingredients.""" + # Test creating builder from JSON + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # Add first ingredient + ingredient_json1 = '{"title": "Test Ingredient 1"}' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json1, "image/jpeg", f) + + # Add second ingredient + ingredient_json2 = '{"title": "Test Ingredient 2"}' + cloud_path = ALTERNATIVE_INGREDIENT_TEST_FILE + with open(cloud_path, 'rb') as f: + builder.add_ingredient(ingredient_json2, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertEqual(len(active_manifest["ingredients"]), 2) + + # Verify both ingredients exist in the array (order doesn't matter) + ingredient_titles = [ing["title"] + for ing in active_manifest["ingredients"]] + self.assertIn("Test Ingredient 1", ingredient_titles) + self.assertIn("Test Ingredient 2", ingredient_titles) + + builder.close() + + def test_builder_sign_with_multiple_ingredients_from_stream(self): + """Test Builder class operations with multiple ingredients using streams.""" + # Test creating builder from JSON + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # Add first ingredient using stream + ingredient_json1 = '{"title": "Test Ingredient Stream 1"}' + with open(self.testPath3, 'rb') as f: + builder.add_ingredient_from_stream( + ingredient_json1, "image/jpeg", f) + + # Add second ingredient using stream + ingredient_json2 = '{"title": "Test Ingredient Stream 2"}' + cloud_path = ALTERNATIVE_INGREDIENT_TEST_FILE + with open(cloud_path, 'rb') as f: + builder.add_ingredient_from_stream( + ingredient_json2, "image/jpeg", f) + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertEqual(len(active_manifest["ingredients"]), 2) + + # Verify both ingredients exist in the array (order doesn't matter) + ingredient_titles = [ing["title"] + for ing in active_manifest["ingredients"]] + self.assertIn("Test Ingredient Stream 1", ingredient_titles) + self.assertIn("Test Ingredient Stream 2", ingredient_titles) + + builder.close() + + def test_builder_set_remote_url(self): + """Test setting the remote url of a builder.""" + builder = Builder.from_json(self.manifestDefinition) + builder.set_remote_url("http://this_does_not_exist/foo.jpg") + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + d = output.read() + self.assertIn(b'provenance="http://this_does_not_exist/foo.jpg"', d) + + def test_builder_set_remote_url_no_embed(self): + """Test setting the remote url of a builder with no embed flag.""" + builder = Builder.from_json(self.manifestDefinition) + load_settings(r'{"verify": { "remote_manifest_fetch": false} }') + builder.set_no_embed() + builder.set_remote_url("http://this_does_not_exist/foo.jpg") + + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + with self.assertRaises(Error) as e: + Reader("image/jpeg", output) + + self.assertIn("http://this_does_not_exist/foo.jpg", e.exception.message) + + # Return back to default settings + load_settings(r'{"verify": { "remote_manifest_fetch": true} }') + +class TestStream(unittest.TestCase): + def setUp(self): + # Create a temporary file for testing + self.temp_file = io.BytesIO() + self.test_data = b"Hello, World!" + self.temp_file.write(self.test_data) + self.temp_file.seek(0) + + def tearDown(self): + self.temp_file.close() + + def test_stream_initialization(self): + """Test proper initialization of Stream class.""" + stream = Stream(self.temp_file) + self.assertTrue(stream.initialized) + self.assertFalse(stream.closed) + stream.close() + + def test_stream_initialization_with_invalid_object(self): + """Test initialization with an invalid object.""" + with self.assertRaises(TypeError): + Stream("not a file-like object") + + def test_stream_read(self): + """Test reading from a stream.""" + stream = Stream(self.temp_file) + try: + # Create a buffer to read into + buffer = (ctypes.c_ubyte * 13)() + # Read the data + bytes_read = stream._read_cb(None, buffer, 13) + # Verify the data + self.assertEqual(bytes_read, 13) + self.assertEqual(bytes(buffer[:bytes_read]), self.test_data) + finally: + stream.close() + + def test_stream_write(self): + """Test writing to a stream.""" + output = io.BytesIO() + stream = Stream(output) + try: + # Create test data + test_data = b"Test Write" + buffer = (ctypes.c_ubyte * len(test_data))(*test_data) + # Write the data + bytes_written = stream._write_cb(None, buffer, len(test_data)) + # Verify the data + self.assertEqual(bytes_written, len(test_data)) + output.seek(0) + self.assertEqual(output.read(), test_data) + finally: + stream.close() + + def test_stream_seek(self): + """Test seeking in a stream.""" + stream = Stream(self.temp_file) + try: + # Seek to position 7 (after "Hello, ") + new_pos = stream._seek_cb(None, 7, 0) # 0 = SEEK_SET + self.assertEqual(new_pos, 7) + # Read from new position + buffer = (ctypes.c_ubyte * 6)() + bytes_read = stream._read_cb(None, buffer, 6) + self.assertEqual(bytes(buffer[:bytes_read]), b"World!") + finally: + stream.close() + + def test_stream_flush(self): + """Test flushing a stream.""" + output = io.BytesIO() + stream = Stream(output) + try: + # Write some data + test_data = b"Test Flush" + buffer = (ctypes.c_ubyte * len(test_data))(*test_data) + stream._write_cb(None, buffer, len(test_data)) + # Flush the stream + result = stream._flush_cb(None) + self.assertEqual(result, 0) + finally: + stream.close() + + def test_stream_context_manager(self): + """Test stream as a context manager.""" + with Stream(self.temp_file) as stream: + self.assertTrue(stream.initialized) + self.assertFalse(stream.closed) + self.assertTrue(stream.closed) + + def test_stream_double_close(self): + """Test that multiple close calls are handled gracefully.""" + stream = Stream(self.temp_file) + stream.close() + # Second close should not raise an exception + stream.close() + self.assertTrue(stream.closed) + + def test_stream_read_after_close(self): + """Test reading from a closed stream.""" + stream = Stream(self.temp_file) + # Store callbacks before closing + read_cb = stream._read_cb + stream.close() + buffer = (ctypes.c_ubyte * 13)() + # Reading from closed stream should return -1 + self.assertEqual(read_cb(None, buffer, 13), -1) + + def test_stream_write_after_close(self): + """Test writing to a closed stream.""" + stream = Stream(self.temp_file) + # Store callbacks before closing + write_cb = stream._write_cb + stream.close() + test_data = b"Test Write" + buffer = (ctypes.c_ubyte * len(test_data))(*test_data) + # Writing to closed stream should return -1 + self.assertEqual(write_cb(None, buffer, len(test_data)), -1) + + def test_stream_seek_after_close(self): + """Test seeking in a closed stream.""" + stream = Stream(self.temp_file) + # Store callbacks before closing + seek_cb = stream._seek_cb + stream.close() + # Seeking in closed stream should return -1 + self.assertEqual(seek_cb(None, 5, 0), -1) + + def test_stream_flush_after_close(self): + """Test flushing a closed stream.""" + stream = Stream(self.temp_file) + # Store callbacks before closing + flush_cb = stream._flush_cb + stream.close() + # Flushing closed stream should return -1 + self.assertEqual(flush_cb(None), -1) + + +class TestLegacyAPI(unittest.TestCase): + def setUp(self): + # Filter specific deprecation warnings for legacy API tests + warnings.filterwarnings("ignore", message="The read_file function is deprecated") + warnings.filterwarnings("ignore", message="The sign_file function is deprecated") + + self.data_dir = FIXTURES_DIR + self.testPath = DEFAULT_TEST_FILE + + # Create temp directory for tests + self.temp_data_dir = os.path.join(self.data_dir, "temp_data") + os.makedirs(self.temp_data_dir, exist_ok=True) + + def tearDown(self): + """Clean up temporary files after each test.""" + if os.path.exists(self.temp_data_dir): + import shutil + shutil.rmtree(self.temp_data_dir) + + def test_invalid_settings_str(self): + """Test loading a malformed settings string.""" + with self.assertRaises(Error): + load_settings(r'{"verify": { "remote_manifest_fetch": false }') + + def test_read_ingredient_file(self): + """Test reading a C2PA ingredient from a file.""" + # Test reading ingredient from file with data_dir + temp_data_dir = os.path.join(self.data_dir, "temp_data") + os.makedirs(temp_data_dir, exist_ok=True) + + ingredient_json_with_dir = read_ingredient_file(self.testPath, temp_data_dir) + + # Verify some fields + ingredient_data = json.loads(ingredient_json_with_dir) + self.assertEqual(ingredient_data["title"], DEFAULT_TEST_FILE_NAME) + self.assertEqual(ingredient_data["format"], "image/jpeg") + self.assertIn("thumbnail", ingredient_data) + + def test_read_file(self): + """Test reading a C2PA ingredient from a file.""" + temp_data_dir = os.path.join(self.data_dir, "temp_data") + os.makedirs(temp_data_dir, exist_ok=True) + + # self.testPath has C2PA metadata to read + file_json_with_dir = read_file(self.testPath, temp_data_dir) + + # Parse the JSON and verify specific fields + file_data = json.loads(file_json_with_dir) + expected_manifest_id = "contentauth:urn:uuid:c85a2b90-f1a0-4aa4-b17f-f938b475804e" + + # Verify some fields + self.assertEqual(file_data["active_manifest"], expected_manifest_id) + self.assertIn("manifests", file_data) + self.assertIn(expected_manifest_id, file_data["manifests"]) + + def test_sign_file(self): + """Test signing a file with C2PA manifest.""" + # Set up test paths + temp_data_dir = os.path.join(self.data_dir, "temp_data") + os.makedirs(temp_data_dir, exist_ok=True) + output_path = os.path.join(temp_data_dir, "signed_output.jpg") + + # Load test certificates and key + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + certs = cert_file.read() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + key = key_file.read() + + # Create signer info + signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=certs, + private_key=key, + ta_url=b"http://timestamp.digicert.com" + ) + + # Create a simple manifest + manifest = { + "claim_generator": "python_internals_test", + "claim_generator_info": [{ + "name": "python_internals_test", + "version": "0.0.1", + }], + "format": "image/jpeg", + "title": "Python Test Signed Image", + "ingredients": [], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.opened" + } + ] + } + } + ] + } + + # Convert manifest to JSON string + manifest_json = json.dumps(manifest) + + try: + # Sign the file + result_json = sign_file( + self.testPath, + output_path, + manifest_json, + signer_info, + temp_data_dir + ) + + finally: + # Clean up + if os.path.exists(output_path): + os.remove(output_path) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_unit_tests_threaded.py b/tests/test_unit_tests_threaded.py new file mode 100644 index 00000000..ca886be2 --- /dev/null +++ b/tests/test_unit_tests_threaded.py @@ -0,0 +1,2249 @@ +# Copyright 2025 Adobe. All rights reserved. +# This file is licensed to you under the Apache License, +# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +# or the MIT license (http://opensource.org/licenses/MIT), +# at your option. + +# Unless required by applicable law or agreed to in writing, +# this software is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or +# implied. See the LICENSE-MIT and LICENSE-APACHE files for the +# specific language governing permissions and limitations under +# each license.import unittest + +import os +import io +import json +import unittest +import threading +import concurrent.futures +import time +import asyncio +import random + +from c2pa import Builder, C2paError as Error, Reader, C2paSigningAlg as SigningAlg, C2paSignerInfo, Signer, sdk_version +from c2pa.c2pa import Stream + +PROJECT_PATH = os.getcwd() +FIXTURES_FOLDER = os.path.join(os.path.dirname(__file__), "fixtures") +DEFAULT_TEST_FILE = os.path.join(FIXTURES_FOLDER, "C.jpg") +INGREDIENT_TEST_FILE = os.path.join(FIXTURES_FOLDER, "A.jpg") +ALTERNATIVE_INGREDIENT_TEST_FILE = os.path.join(FIXTURES_FOLDER, "cloud.jpg") +OTHER_ALTERNATIVE_INGREDIENT_TEST_FILE = os.path.join(FIXTURES_FOLDER, "A_thumbnail.jpg") + +# Note: Despite being threaded, some of the tests will take time to run, +# as they may try to push for thread contention, or simply just have a lot +# of work to do (eg. signing or reading all files in a folder). + + +class TestReaderWithThreads(unittest.TestCase): + def setUp(self): + # Use the fixtures_dir fixture to set up paths + self.data_dir = FIXTURES_FOLDER + self.testPath = DEFAULT_TEST_FILE + + def test_stream_read(self): + def read_metadata(): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + json_data = reader.json() + self.assertIn("C.jpg", json_data) + return json_data + + # Create two threads + thread1 = threading.Thread(target=read_metadata) + thread2 = threading.Thread(target=read_metadata) + + # Start both threads + thread1.start() + thread2.start() + + # Wait for both threads to complete + thread1.join() + thread2.join() + + def test_stream_read_and_parse(self): + def read_and_parse(): + with open(self.testPath, "rb") as file: + reader = Reader("image/jpeg", file) + manifest_store = json.loads(reader.json()) + title = manifest_store["manifests"][manifest_store["active_manifest"]]["title"] + self.assertEqual(title, "C.jpg") + return manifest_store + + # Create two threads + thread1 = threading.Thread(target=read_and_parse) + thread2 = threading.Thread(target=read_and_parse) + + # Start both threads + thread1.start() + thread2.start() + + # Wait for both threads to complete + thread1.join() + thread2.join() + + def test_read_all_files(self): + """Test reading C2PA metadata from all files in the fixtures/files-for-reading-tests directory""" + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav' + } + + # Skip system files + skip_files = { + '.DS_Store' + } + + def process_file(filename): + if filename in skip_files: + return None + + file_path = os.path.join(reading_dir, filename) + if not os.path.isfile(file_path): + return None + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + + mime_type = mime_types[ext] + + try: + with open(file_path, "rb") as file: + reader = Reader(mime_type, file) + json_data = reader.json() + # Verify the manifest contains expected fields + manifest = json.loads(json_data) + if "manifests" not in manifest or "active_manifest" not in manifest: + return f"Invalid manifest structure in {filename}" + return None # Success case returns None + except Exception as e: + return f"Failed to read metadata from {filename}: {str(e)}" + + # Create a thread pool with 6 workers + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + # Submit all files to the thread pool + future_to_file = { + executor.submit(process_file, filename): filename + for filename in os.listdir(reading_dir) + } + + # Collect results as they complete + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append( + f"Unexpected error processing {filename}: { + str(e)}") + + # If any errors occurred, fail the test with all error messages + if errors: + self.fail("\n".join(errors)) + + +class TestBuilderWithThreads(unittest.TestCase): + def setUp(self): + # Use the fixtures_dir fixture to set up paths + self.data_dir = FIXTURES_FOLDER + with open(os.path.join(self.data_dir, "es256_certs.pem"), "rb") as cert_file: + self.certs = cert_file.read() + with open(os.path.join(self.data_dir, "es256_private.key"), "rb") as key_file: + self.key = key_file.read() + + # Create a local Ps256 signer with certs and a timestamp server + self.signer_info = C2paSignerInfo( + alg=b"es256", + sign_cert=self.certs, + private_key=self.key, + ta_url=b"http://timestamp.digicert.com" + ) + self.signer = Signer.from_info(self.signer_info) + + self.testPath = DEFAULT_TEST_FILE + self.testPath2 = INGREDIENT_TEST_FILE + self.testPath3 = OTHER_ALTERNATIVE_INGREDIENT_TEST_FILE + self.testPath4 = ALTERNATIVE_INGREDIENT_TEST_FILE + + # For that test manifest, we use a placeholder assertion with content + # varying depending on thread/manifest, to check for data scrambling. + # The used assertion is custom, and not part of the C2PA standard. + self.manifestDefinition = { + "claim_generator": "python_test", + "claim_generator_info": [{ + "name": "python_test", + "version": "0.0.1", + }], + "format": "image/jpeg", + "title": "Python Test Image", + "ingredients": [], + "assertions": [ + { + 'label': 'com.unit.test', + 'data': { + 'author': [ + { + 'name': 'Tester' + } + ] + }, + 'kind': 'Json' + } + ] + } + + # For that test manifest, we use a placeholder assertion with content + # varying depending on thread/manifest, to check for data scrambling. + # The used assertion is custom, and not part of the C2PA standard. + self.manifestDefinition_1 = { + "claim_generator": "python_test_thread1", + "claim_generator_info": [{ + "name": "python_test_1", + "version": "0.0.1", + }], + "format": "image/jpeg", + "title": "Python Test Image 1", + "ingredients": [], + "assertions": [ + { + 'label': 'com.unit.test', + 'data': { + 'author': [ + { + 'name': 'Tester One' + } + ] + }, + 'kind': 'Json' + } + ] + } + + # For that test manifest, we use a placeholder assertion with content + # varying depending on thread/manifest, to check for data scrambling. + # The used assertion is custom, and not part of the C2PA standard. + self.manifestDefinition_2 = { + "claim_generator": "python_test_thread2", + "claim_generator_info": [{ + "name": "python_test_2", + "version": "0.0.1", + }], + "format": "image/jpeg", + "title": "Python Test Image 2", + "ingredients": [], + "assertions": [ + { + 'label': 'com.unit.test', + 'data': { + 'author': [ + { + 'name': 'Tester Two' + } + ] + }, + 'kind': 'Json' + } + ] + } + + def test_sign_all_files(self): + """Test signing all files in both fixtures directories using a thread pool""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav' + } + + # Skip files that are known to be invalid or unsupported + skip_files = { + 'sample3.invalid.wav', # Invalid file + } + + def sign_file(filename, thread_id): + if filename in skip_files: + return None + + file_path = os.path.join(signing_dir, filename) + if not os.path.isfile(file_path): + return None + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + + mime_type = mime_types[ext] + + try: + with open(file_path, "rb") as file: + # Choose manifest based on thread number + manifest_def = self.manifestDefinition_2 if thread_id % 2 == 0 else self.manifestDefinition_1 + expected_author = "Tester Two" if thread_id % 2 == 0 else "Tester One" + + builder = Builder(manifest_def) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + + # Verify the signed file + reader = Reader(mime_type, output) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Verify the correct manifest was used + expected_claim_generator = f"python_test_{ + 2 if thread_id % 2 == 0 else 1}/0.0.1" + self.assertEqual( + active_manifest["claim_generator"], + expected_claim_generator) + + # Verify the author is correct + assertions = active_manifest["assertions"] + for assertion in assertions: + if assertion["label"] == "com.unit.test": + author_name = assertion["data"]["author"][0]["name"] + self.assertEqual(author_name, expected_author) + break + + output.close() + return None # Success case + except Error.NotSupported: + return None + except Exception as e: + return f"Failed to sign { + filename} in thread {thread_id}: {str(e)}" + + # Create a thread pool with 6 workers + with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: + # Get all files from both directories + all_files = [] + for directory in [signing_dir, reading_dir]: + all_files.extend(os.listdir(directory)) + + # Submit all files to the thread pool with thread IDs + future_to_file = { + executor.submit(sign_file, filename, i): (filename, i) + for i, filename in enumerate(all_files) + } + + # Collect results as they complete + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename, thread_id = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing { + filename} in thread {thread_id}: {str(e)}") + + # If any errors occurred, fail the test with all error messages + if errors: + self.fail("\n".join(errors)) + + def test_sign_all_files_async(self): + """Test signing all files using asyncio with a pool of workers""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav' + } + + # Skip files that are known to be invalid or unsupported + skip_files = { + 'sample3.invalid.wav', # Invalid file + } + + async def async_sign_file(filename, thread_id): + """Async version of file signing operation""" + if filename in skip_files: + return None + + file_path = os.path.join(signing_dir, filename) + if not os.path.isfile(file_path): + return None + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + + mime_type = mime_types[ext] + + try: + with open(file_path, "rb") as file: + # Choose manifest based on thread number + manifest_def = self.manifestDefinition_2 if thread_id % 2 == 0 else self.manifestDefinition_1 + expected_author = "Tester Two" if thread_id % 2 == 0 else "Tester One" + + builder = Builder(manifest_def) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + + # Verify the signed file + reader = Reader(mime_type, output) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Verify the correct manifest was used + expected_claim_generator = f"python_test_{ + 2 if thread_id % 2 == 0 else 1}/0.0.1" + self.assertEqual( + active_manifest["claim_generator"], + expected_claim_generator) + + # Verify the author is correct + assertions = active_manifest["assertions"] + for assertion in assertions: + if assertion["label"] == "com.unit.test": + author_name = assertion["data"]["author"][0]["name"] + self.assertEqual(author_name, expected_author) + break + + output.close() + return None # Success case + except Error.NotSupported: + return None + except Exception as e: + return f"Failed to sign { + filename} in thread {thread_id}: {str(e)}" + + async def run_async_tests(): + # Get all files from both directories + all_files = [] + for directory in [signing_dir, reading_dir]: + all_files.extend(os.listdir(directory)) + + # Create tasks for all files + tasks = [] + for i, filename in enumerate(all_files): + task = asyncio.create_task(async_sign_file(filename, i)) + tasks.append(task) + + # Wait for all tasks to complete and collect results + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Process results + errors = [] + for result in results: + if isinstance(result, Exception): + errors.append(str(result)) + elif result: # Non-None result indicates an error + errors.append(result) + + # If any errors occurred, fail the test with all error messages + if errors: + self.fail("\n".join(errors)) + + # Run the async tests + asyncio.run(run_async_tests()) + + def test_parallel_manifest_writing(self): + """Test writing different manifests to two files in parallel and verify no data mixing occurs""" + output1 = io.BytesIO(bytearray()) + output2 = io.BytesIO(bytearray()) + + def write_manifest(manifest_def, output_stream, thread_id): + with open(self.testPath, "rb") as file: + builder = Builder(manifest_def) + builder.sign(self.signer, "image/jpeg", file, output_stream) + output_stream.seek(0) + reader = Reader("image/jpeg", output_stream) + json_data = reader.json() + manifest_store = json.loads(json_data) + + # Get the active manifest + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Verify the correct manifest was written + expected_claim_generator = f"python_test_{thread_id}/0.0.1" + self.assertEqual( + active_manifest["claim_generator"], + expected_claim_generator) + self.assertEqual( + active_manifest["title"], + f"Python Test Image {thread_id}") + + # Verify the author is correct + assertions = active_manifest["assertions"] + for assertion in assertions: + if assertion["label"] == "com.unit.test": + author_name = assertion["data"]["author"][0]["name"] + self.assertEqual( + author_name, f"Tester { + 'One' if thread_id == 1 else 'Two'}") + break + + return active_manifest + + # Create two threads + thread1 = threading.Thread( + target=write_manifest, + args=(self.manifestDefinition_1, output1, 1) + ) + thread2 = threading.Thread( + target=write_manifest, + args=(self.manifestDefinition_2, output2, 2) + ) + + # Start both threads + thread1.start() + thread2.start() + + # Wait for both threads to complete + thread2.join() + thread1.join() + + # Verify the outputs are different + output1.seek(0) + output2.seek(0) + reader1 = Reader("image/jpeg", output1) + reader2 = Reader("image/jpeg", output2) + + manifest_store1 = json.loads(reader1.json()) + manifest_store2 = json.loads(reader2.json()) + + # Get the active manifests + active_manifest1 = manifest_store1["manifests"][manifest_store1["active_manifest"]] + active_manifest2 = manifest_store2["manifests"][manifest_store2["active_manifest"]] + + # Verify the manifests are different + self.assertNotEqual( + active_manifest1["claim_generator"], + active_manifest2["claim_generator"]) + self.assertNotEqual( + active_manifest1["title"], + active_manifest2["title"]) + + # Clean up + output1.close() + output2.close() + + def test_parallel_sign_all_files_interleaved(self): + """Test signing all files using a thread pool of 3 threads, cycling through all three manifest definitions""" + signing_dir = os.path.join(self.data_dir, "files-for-signing-tests") + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Map of file extensions to MIME types + mime_types = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + '.avif': 'image/avif', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', + '.avi': 'video/x-msvideo', + '.mp3': 'audio/mpeg', + '.m4a': 'audio/mp4', + '.wav': 'audio/wav' + } + + # Skip files that are known to be invalid or unsupported + skip_files = { + 'sample3.invalid.wav', # Invalid file + } + + # Thread synchronization + thread_counter = 0 + thread_counter_lock = threading.Lock() + thread_execution_order = [] + thread_order_lock = threading.Lock() + + def sign_file(filename, thread_id): + nonlocal thread_counter + + if filename in skip_files: + return None + + file_path = os.path.join(signing_dir, filename) + if not os.path.isfile(file_path): + return None + + # Get file extension and corresponding MIME type + _, ext = os.path.splitext(filename) + ext = ext.lower() + if ext not in mime_types: + return None + + mime_type = mime_types[ext] + + try: + with open(file_path, "rb") as file: + # Choose manifest based on thread number + if thread_id % 3 == 0: + manifest_def = self.manifestDefinition + expected_author = "Tester" + expected_thread = "" + elif thread_id % 3 == 1: + manifest_def = self.manifestDefinition_1 + expected_author = "Tester One" + expected_thread = "1" + else: # thread_id % 3 == 2 + manifest_def = self.manifestDefinition_2 + expected_author = "Tester Two" + expected_thread = "2" + + # Record thread execution order + with thread_counter_lock: + current_count = thread_counter + thread_counter += 1 + with thread_order_lock: + thread_execution_order.append( + (current_count, thread_id)) + + # Add a small delay to encourage interleaving + time.sleep(0.01) + + builder = Builder(manifest_def) + output = io.BytesIO(bytearray()) + builder.sign(self.signer, mime_type, file, output) + output.seek(0) + + # Verify the signed file + reader = Reader(mime_type, output) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Verify the correct manifest was used + if thread_id % 3 == 0: + expected_claim_generator = "python_test/0.0.1" + else: + expected_claim_generator = f"python_test_{ + expected_thread}/0.0.1" + + self.assertEqual( + active_manifest["claim_generator"], + expected_claim_generator) + + # Verify the author is correct + assertions = active_manifest["assertions"] + for assertion in assertions: + if assertion["label"] == "com.unit.test": + author_name = assertion["data"]["author"][0]["name"] + self.assertEqual(author_name, expected_author) + break + + output.close() + return None # Success case + except Error.NotSupported: + return None + except Exception as e: + return f"Failed to sign { + filename} in thread {thread_id}: {str(e)}" + + # Create a thread pool with 3 workers + with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: + # Get all files from both directories + all_files = [] + for directory in [signing_dir, reading_dir]: + all_files.extend(os.listdir(directory)) + + # Submit all files to the thread pool with thread IDs + future_to_file = { + executor.submit(sign_file, filename, i): (filename, i) + for i, filename in enumerate(all_files) + } + + # Collect results as they complete + errors = [] + for future in concurrent.futures.as_completed(future_to_file): + filename, thread_id = future_to_file[future] + try: + error = future.result() + if error: + errors.append(error) + except Exception as e: + errors.append(f"Unexpected error processing { + filename} in thread {thread_id}: {str(e)}") + + # Verify thread interleaving + # Check that we don't have long sequences of the same thread + # Maximum allowed consecutive executions of the same thread + max_same_thread_sequence = 3 + current_sequence = 1 + current_thread = thread_execution_order[0][1] if thread_execution_order else None + + for i in range(1, len(thread_execution_order)): + if thread_execution_order[i][1] == current_thread: + current_sequence += 1 + if current_sequence > max_same_thread_sequence: + self.fail(f"Thread {current_thread} executed { + current_sequence} times in sequence, indicating poor interleaving") + else: + current_sequence = 1 + current_thread = thread_execution_order[i][1] + + # If any errors occurred, fail the test with all error messages + if errors: + self.fail("\n".join(errors)) + + def test_concurrent_read_after_write(self): + """Test reading from a file after writing is complete""" + output = io.BytesIO(bytearray()) + write_complete = threading.Event() + write_errors = [] + read_errors = [] + + def write_manifest(): + try: + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + write_complete.set() + except Exception as e: + write_errors.append(f"Write error: {str(e)}") + write_complete.set() + + def read_manifest(): + try: + # Wait for write to complete before reading + write_complete.wait() + + # Read after write is complete + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Verify final manifest + self.assertEqual( + active_manifest["claim_generator"], + "python_test_1/0.0.1") + self.assertEqual( + active_manifest["title"], + "Python Test Image 1") + + # Verify the author is correct + assertions = active_manifest["assertions"] + for assertion in assertions: + if assertion["label"] == "com.unit.test": + author_name = assertion["data"]["author"][0]["name"] + self.assertEqual(author_name, "Tester One") + break + + except Exception as e: + read_errors.append(f"Read error: {str(e)}") + + # Start both threads + write_thread = threading.Thread(target=write_manifest) + read_thread = threading.Thread(target=read_manifest) + + read_thread.start() + write_thread.start() + + # Wait for both threads to complete + write_thread.join() + read_thread.join() + + # Clean up + output.close() + + # Check for errors + if write_errors: + self.fail("\n".join(write_errors)) + if read_errors: + self.fail("\n".join(read_errors)) + + def test_concurrent_read_write_multiple_readers(self): + """Test multiple readers reading from a file after writing is complete""" + output = io.BytesIO(bytearray()) + write_complete = threading.Event() + write_errors = [] + read_errors = [] + reader_count = 3 + active_readers = 0 + readers_lock = threading.Lock() + stream_lock = threading.Lock() # Lock for stream access + + def write_manifest(): + try: + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) # Reset stream position after write + write_complete.set() + except Exception as e: + write_errors.append(f"Write error: {str(e)}") + write_complete.set() + + def read_manifest(reader_id): + nonlocal active_readers + try: + with readers_lock: + active_readers += 1 + + # Wait for write to complete before reading + write_complete.wait() + + # Read after write is complete + with stream_lock: # Ensure exclusive access to stream + output.seek(0) # Reset stream position before read + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Verify final manifest + self.assertEqual( + active_manifest["claim_generator"], + "python_test_1/0.0.1") + self.assertEqual( + active_manifest["title"], + "Python Test Image 1") + + # Verify the author is correct + assertions = active_manifest["assertions"] + for assertion in assertions: + if assertion["label"] == "com.unit.test": + author_name = assertion["data"]["author"][0]["name"] + self.assertEqual(author_name, "Tester One") + break + + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + with readers_lock: + active_readers -= 1 + + # Start the write thread + write_thread = threading.Thread(target=write_manifest) + write_thread.start() + + # Start multiple read threads + read_threads = [] + for i in range(reader_count): + thread = threading.Thread(target=read_manifest, args=(i,)) + read_threads.append(thread) + thread.start() + + # Wait for write to complete + write_thread.join() + + # Wait for all readers to complete + for thread in read_threads: + thread.join() + + # Clean up + output.close() + + # Check for errors + if write_errors: + self.fail("\n".join(write_errors)) + if read_errors: + self.fail("\n".join(read_errors)) + + # Verify all readers completed + self.assertEqual(active_readers, 0, "Not all readers completed") + + def test_resource_contention_read(self): + """Test multiple threads trying to access the same file simultaneously""" + output = io.BytesIO(bytearray()) + read_complete = threading.Event() + read_errors = [] + reader_count = 5 # Number of concurrent readers + active_readers = 0 + readers_lock = threading.Lock() + stream_lock = threading.Lock() # Lock for stream access + + # First write some data to read + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + def read_manifest(reader_id): + nonlocal active_readers + try: + with readers_lock: + active_readers += 1 + + # Read the manifest + with stream_lock: # Ensure exclusive access to stream + output.seek(0) # Reset stream position before read + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Verify manifest data + self.assertEqual( + active_manifest["claim_generator"], + "python_test_1/0.0.1") + self.assertEqual( + active_manifest["title"], + "Python Test Image 1") + + # Verify the author is correct + assertions = active_manifest["assertions"] + for assertion in assertions: + if assertion["label"] == "com.unit.test": + author_name = assertion["data"]["author"][0]["name"] + self.assertEqual(author_name, "Tester One") + break + + # Add a small delay to increase contention + time.sleep(0.01) + + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + with readers_lock: + active_readers -= 1 + if active_readers == 0: + read_complete.set() + + # Create and start all threads + read_threads = [] + for i in range(reader_count): + thread = threading.Thread(target=read_manifest, args=(i,)) + read_threads.append(thread) + thread.start() # Start each thread immediately after creation + + # Wait for all readers to complete + for thread in read_threads: + thread.join() + + # Clean up + output.close() + + # Check for errors + if read_errors: + self.fail("\n".join(read_errors)) + + # Verify all readers completed + self.assertEqual(active_readers, 0, "Not all readers completed") + + def test_resource_contention_read_parallel(self): + """Test multiple threads starting simultaneously to read the same file""" + output = io.BytesIO(bytearray()) + read_errors = [] + reader_count = 5 # Number of concurrent readers + active_readers = 0 + readers_lock = threading.Lock() + stream_lock = threading.Lock() # Lock for stream access + # Barrier to synchronize thread starts + start_barrier = threading.Barrier(reader_count) + start_times = [] # Track when each thread starts reading + start_times_lock = threading.Lock() + + # First write some data to read + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + def read_manifest(reader_id): + nonlocal active_readers + try: + with readers_lock: + active_readers += 1 + + # Wait for all threads to be ready + start_barrier.wait() + + # Record start time + with start_times_lock: + start_times.append(time.time()) + + # Read the manifest + with stream_lock: # Ensure exclusive access to stream + output.seek(0) # Reset stream position before read + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Verify manifest data + self.assertEqual( + active_manifest["claim_generator"], + "python_test_1/0.0.1") + self.assertEqual( + active_manifest["title"], + "Python Test Image 1") + + # Verify the author is correct + assertions = active_manifest["assertions"] + for assertion in assertions: + if assertion["label"] == "com.unit.test": + author_name = assertion["data"]["author"][0]["name"] + self.assertEqual(author_name, "Tester One") + break + + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + with readers_lock: + active_readers -= 1 + + # Create all threads first + read_threads = [] + for i in range(reader_count): + thread = threading.Thread(target=read_manifest, args=(i,)) + read_threads.append(thread) + + # Start all threads at once + for thread in read_threads: + thread.start() + + # Wait for all readers to complete + for thread in read_threads: + thread.join() + + # Clean up + output.close() + + # Check for errors + if read_errors: + self.fail("\n".join(read_errors)) + + # Verify all readers completed + self.assertEqual(active_readers, 0, "Not all readers completed") + + def test_remote_sign_threaded(self): + """Test remote signing with multiple threads in parallel""" + output1 = io.BytesIO(bytearray()) + output2 = io.BytesIO(bytearray()) + sign_errors = [] + sign_complete = threading.Event() + manifest_data1 = None + manifest_data2 = None + + def remote_sign(output_stream, manifest_def, thread_id): + nonlocal manifest_data1, manifest_data2 + try: + with open(self.testPath, "rb") as file: + builder = Builder(manifest_def) + builder.set_no_embed() + manifest_data = builder.sign( + self.signer, "image/jpeg", file, output_stream) + output_stream.seek(0) + + # Store manifest data for final verification + if thread_id == 1: + manifest_data1 = manifest_data + else: + manifest_data2 = manifest_data + + # Verify the signed file + reader = Reader("image/jpeg", output_stream, manifest_data) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Verify the correct manifest was used + if thread_id == 1: + expected_claim_generator = "python_test_1/0.0.1" + expected_author = "Tester One" + else: + expected_claim_generator = "python_test_2/0.0.1" + expected_author = "Tester Two" + + self.assertEqual( + active_manifest["claim_generator"], + expected_claim_generator) + + # Verify the author is correct + assertions = active_manifest["assertions"] + for assertion in assertions: + if assertion["label"] == "com.unit.test": + author_name = assertion["data"]["author"][0]["name"] + self.assertEqual(author_name, expected_author) + break + + except Exception as e: + sign_errors.append(f"Thread {thread_id} error: {str(e)}") + finally: + sign_complete.set() + + # Create and start two threads for concurrent remote signing + thread1 = threading.Thread( + target=remote_sign, + args=(output1, self.manifestDefinition_1, 1) + ) + thread2 = threading.Thread( + target=remote_sign, + args=(output2, self.manifestDefinition_2, 2) + ) + + # Start both threads + thread1.start() + thread2.start() + + # Wait for both threads to complete + thread1.join() + thread2.join() + + # Check for errors + if sign_errors: + self.fail("\n".join(sign_errors)) + + # Verify the outputs are different before closing + output1.seek(0) + output2.seek(0) + reader1 = Reader("image/jpeg", output1, manifest_data1) + reader2 = Reader("image/jpeg", output2, manifest_data2) + + manifest_store1 = json.loads(reader1.json()) + manifest_store2 = json.loads(reader2.json()) + + # Get the active manifests + active_manifest1 = manifest_store1["manifests"][manifest_store1["active_manifest"]] + active_manifest2 = manifest_store2["manifests"][manifest_store2["active_manifest"]] + + # Verify the manifests are different + self.assertNotEqual( + active_manifest1["claim_generator"], + active_manifest2["claim_generator"]) + self.assertNotEqual( + active_manifest1["title"], + active_manifest2["title"]) + + # Clean up after verification + output1.close() + output2.close() + + def test_archive_sign_threaded(self): + """Test archive signing with multiple threads in parallel""" + archive1 = io.BytesIO(bytearray()) + archive2 = io.BytesIO(bytearray()) + output1 = io.BytesIO(bytearray()) + output2 = io.BytesIO(bytearray()) + sign_errors = [] + sign_complete = threading.Event() + + def archive_sign( + archive_stream, + output_stream, + manifest_def, + thread_id): + try: + with open(self.testPath, "rb") as file: + # Create and save archive + builder = Builder(manifest_def) + builder.to_archive(archive_stream) + archive_stream.seek(0) + + # Load from archive and sign + builder = Builder.from_archive(archive_stream) + builder.sign( + self.signer, "image/jpeg", file, output_stream) + output_stream.seek(0) + + # Verify the signed file + reader = Reader("image/jpeg", output_stream) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Verify the correct manifest was used + if thread_id == 1: + expected_claim_generator = "python_test_1/0.0.1" + expected_author = "Tester One" + else: + expected_claim_generator = "python_test_2/0.0.1" + expected_author = "Tester Two" + + self.assertEqual( + active_manifest["claim_generator"], + expected_claim_generator) + + # Verify the author is correct + assertions = active_manifest["assertions"] + for assertion in assertions: + if assertion["label"] == "com.unit.test": + author_name = assertion["data"]["author"][0]["name"] + self.assertEqual(author_name, expected_author) + break + + except Exception as e: + sign_errors.append(f"Thread {thread_id} error: {str(e)}") + finally: + sign_complete.set() + + # Create and start two threads for concurrent archive signing + thread1 = threading.Thread( + target=archive_sign, + args=(archive1, output1, self.manifestDefinition_1, 1) + ) + thread2 = threading.Thread( + target=archive_sign, + args=(archive2, output2, self.manifestDefinition_2, 2) + ) + + # Start both threads + thread1.start() + thread2.start() + + # Wait for both threads to complete + thread1.join() + thread2.join() + + # Check for errors + if sign_errors: + self.fail("\n".join(sign_errors)) + + # Verify the outputs are different before closing + output1.seek(0) + output2.seek(0) + reader1 = Reader("image/jpeg", output1) + reader2 = Reader("image/jpeg", output2) + + manifest_store1 = json.loads(reader1.json()) + manifest_store2 = json.loads(reader2.json()) + + # Get the active manifests + active_manifest1 = manifest_store1["manifests"][manifest_store1["active_manifest"]] + active_manifest2 = manifest_store2["manifests"][manifest_store2["active_manifest"]] + + # Verify the manifests are different + self.assertNotEqual( + active_manifest1["claim_generator"], + active_manifest2["claim_generator"]) + self.assertNotEqual( + active_manifest1["title"], + active_manifest2["title"]) + + # Clean up after verification + archive1.close() + archive2.close() + output1.close() + output2.close() + + def test_sign_all_files_twice(self): + """Test signing the same file twice with different manifests using a thread pool of size 2""" + output1 = io.BytesIO(bytearray()) + output2 = io.BytesIO(bytearray()) + sign_errors = [] + thread_results = {} + thread_lock = threading.Lock() + + def sign_file(output_stream, manifest_def, thread_id): + try: + with open(self.testPath, "rb") as file: + # Sign the file + builder = Builder(manifest_def) + builder.sign( + self.signer, "image/jpeg", file, output_stream) + output_stream.seek(0) + + # Verify the signed file + reader = Reader("image/jpeg", output_stream) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Verify the correct manifest was used + if thread_id == 1: + expected_claim_generator = "python_test_1/0.0.1" + expected_author = "Tester One" + else: + expected_claim_generator = "python_test_2/0.0.1" + expected_author = "Tester Two" + + # Store results for final verification + with thread_lock: + thread_results[thread_id] = { + 'manifest': active_manifest + } + + # Verify manifest data + self.assertEqual( + active_manifest["claim_generator"], + expected_claim_generator) + + # Verify the author is correct + assertions = active_manifest["assertions"] + for assertion in assertions: + if assertion["label"] == "com.unit.test": + author_name = assertion["data"]["author"][0]["name"] + self.assertEqual(author_name, expected_author) + break + + return None # Success case + + except Exception as e: + return f"Thread {thread_id} error: {str(e)}" + + # Create a thread pool with 2 workers + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + # Submit both signing tasks + future1 = executor.submit( + sign_file, output1, self.manifestDefinition_1, 1) + future2 = executor.submit( + sign_file, output2, self.manifestDefinition_2, 2) + + # Collect results + for future in concurrent.futures.as_completed([future1, future2]): + error = future.result() + if error: + sign_errors.append(error) + + # Check for errors + if sign_errors: + self.fail("\n".join(sign_errors)) + + # Verify thread results + self.assertEqual( + len(thread_results), + 2, + "Both threads should have completed") + + # Verify the outputs are different + output1.seek(0) + output2.seek(0) + reader1 = Reader("image/jpeg", output1) + reader2 = Reader("image/jpeg", output2) + + manifest_store1 = json.loads(reader1.json()) + manifest_store2 = json.loads(reader2.json()) + + # Get the active manifests + active_manifest1 = manifest_store1["manifests"][manifest_store1["active_manifest"]] + active_manifest2 = manifest_store2["manifests"][manifest_store2["active_manifest"]] + + # Verify the manifests are different + self.assertNotEqual( + active_manifest1["claim_generator"], + active_manifest2["claim_generator"]) + self.assertNotEqual( + active_manifest1["title"], + active_manifest2["title"]) + + # Verify both outputs have valid signatures + self.assertNotIn("validation_status", manifest_store1) + self.assertNotIn("validation_status", manifest_store2) + + # Clean up + output1.close() + output2.close() + + def test_concurrent_read_after_write_async(self): + """Test reading from a file after writing is complete using asyncio""" + output = io.BytesIO(bytearray()) + write_complete = asyncio.Event() + write_errors = [] + read_errors = [] + write_success = False + + async def write_manifest(): + nonlocal write_success + try: + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + write_success = True + write_complete.set() + except Exception as e: + write_errors.append(f"Write error: {str(e)}") + write_complete.set() + + async def read_manifest(): + try: + # Wait for write to complete before reading + await write_complete.wait() + + # Verify write was successful + if not write_success: + raise Exception( + "Write operation did not complete successfully") + + # Verify output is not empty + output_size = len(output.getvalue()) + self.assertGreater( + output_size, 0, "Output should not be empty after write") + + # Read after write is complete + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_store = json.loads(json_data) + + # Verify manifest store structure + self.assertIn( + "manifests", + manifest_store, + "Manifest store should contain 'manifests'") + self.assertIn( + "active_manifest", + manifest_store, + "Manifest store should contain 'active_manifest'") + + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Verify final manifest + self.assertEqual( + active_manifest["claim_generator"], + "python_test_1/0.0.1") + self.assertEqual( + active_manifest["title"], + "Python Test Image 1") + + # Verify the author is correct + assertions = active_manifest["assertions"] + author_found = False + for assertion in assertions: + if assertion["label"] == "com.unit.test": + author_name = assertion["data"]["author"][0]["name"] + self.assertEqual(author_name, "Tester One") + author_found = True + break + self.assertTrue(author_found, + "Author assertion not found in manifest") + + # Verify no validation errors + self.assertNotIn( + "validation_status", + manifest_store, + "Manifest should not have validation errors") + + except Exception as e: + read_errors.append(f"Read error: {str(e)}") + + async def run_async_tests(): + # Create and run write task first + write_task = asyncio.create_task(write_manifest()) + await write_task # Wait for write to complete + + # Only start read task after write is complete + read_task = asyncio.create_task(read_manifest()) + await read_task # Wait for read to complete + + # Run the async tests + asyncio.run(run_async_tests()) + + # Clean up + output.close() + + # Check for errors + if write_errors: + self.fail("\n".join(write_errors)) + if read_errors: + self.fail("\n".join(read_errors)) + + def test_resource_contention_read_parallel_async(self): + """Test multiple async tasks reading the same file concurrently""" + output = io.BytesIO(bytearray()) + read_errors = [] + reader_count = 5 # Number of concurrent readers + active_readers = 0 + readers_lock = asyncio.Lock() # Lock for reader count + stream_lock = asyncio.Lock() # Lock for stream access + # Barrier to synchronize task starts + start_barrier = asyncio.Barrier(reader_count) + + # First write some data to read + with open(self.testPath, "rb") as file: + builder = Builder(self.manifestDefinition_1) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + + async def read_manifest(reader_id): + nonlocal active_readers + try: + async with readers_lock: + active_readers += 1 + + # Wait for all tasks to be ready + await start_barrier.wait() + + # Read the manifest + async with stream_lock: # Ensure exclusive access to stream + output.seek(0) # Reset stream position before read + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_store = json.loads(json_data) + active_manifest = manifest_store["manifests"][manifest_store["active_manifest"]] + + # Verify manifest data + self.assertEqual( + active_manifest["claim_generator"], + "python_test_1/0.0.1") + self.assertEqual( + active_manifest["title"], + "Python Test Image 1") + + # Verify the author is correct + assertions = active_manifest["assertions"] + for assertion in assertions: + if assertion["label"] == "com.unit.test": + author_name = assertion["data"]["author"][0]["name"] + self.assertEqual(author_name, "Tester One") + break + + except Exception as e: + read_errors.append(f"Reader {reader_id} error: {str(e)}") + finally: + async with readers_lock: + active_readers -= 1 + + async def run_async_tests(): + # Create all tasks first + tasks = [] + for i in range(reader_count): + task = asyncio.create_task(read_manifest(i)) + tasks.append(task) + + # Wait for all tasks to complete + await asyncio.gather(*tasks) + + # Run the async tests + asyncio.run(run_async_tests()) + + # Clean up + output.close() + + # Check for errors + if read_errors: + self.fail("\n".join(read_errors)) + + # Verify all readers completed + self.assertEqual(active_readers, 0, "Not all readers completed") + + def test_builder_sign_with_multiple_ingredient(self): + """Test Builder class operations with multiple ingredients added in parallel threads.""" + # Test creating builder from JSON + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # Define paths for test files + cloud_path = os.path.join(self.data_dir, "cloud.jpg") + + # Thread synchronization + ingredient_added = threading.Event() + add_errors = [] + add_lock = threading.Lock() + + def add_ingredient(ingredient_json, file_path, thread_id): + try: + with open(file_path, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + with add_lock: + add_errors.append(None) # Success case + except Exception as e: + with add_lock: + add_errors.append(f"Thread {thread_id} error: {str(e)}") + finally: + ingredient_added.set() + + # Create and start two threads for parallel ingredient addition + thread1 = threading.Thread( + target=add_ingredient, + args=('{"title": "Test Ingredient 1"}', self.testPath3, 1) + ) + thread2 = threading.Thread( + target=add_ingredient, + args=('{"title": "Test Ingredient 2"}', cloud_path, 2) + ) + + # Start both threads + thread1.start() + thread2.start() + + # Wait for both threads to complete + thread1.join() + thread2.join() + + # Check for errors during ingredient addition + if any(error for error in add_errors if error is not None): + self.fail( + "\n".join( + error for error in add_errors if error is not None)) + + # Verify both ingredients were added successfully + self.assertEqual( + len(add_errors), + 2, + "Both threads should have completed") + + # Now sign the manifest with the added ingredients + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertEqual(len(active_manifest["ingredients"]), 2) + + # Verify both ingredients exist in the array (order doesn't matter) + ingredient_titles = [ing["title"] + for ing in active_manifest["ingredients"]] + self.assertIn("Test Ingredient 1", ingredient_titles) + self.assertIn("Test Ingredient 2", ingredient_titles) + + builder.close() + + def test_builder_sign_with_multiple_ingredients_from_stream(self): + """Test Builder class operations with multiple ingredients using streams.""" + # Test creating builder from JSON + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # Thread synchronization + add_errors = [] + add_lock = threading.Lock() + completed_threads = 0 + completion_lock = threading.Lock() + + def add_ingredient_from_stream(ingredient_json, file_path, thread_id): + nonlocal completed_threads + try: + with open(file_path, 'rb') as f: + builder.add_ingredient_from_stream( + ingredient_json, "image/jpeg", f) + with add_lock: + add_errors.append(None) # Success case + except Exception as e: + with add_lock: + add_errors.append(f"Thread {thread_id} error: {str(e)}") + finally: + with completion_lock: + completed_threads += 1 + + # Create and start two threads for parallel ingredient addition + thread1 = threading.Thread( + target=add_ingredient_from_stream, + args=('{"title": "Test Ingredient Stream 1"}', self.testPath3, 1) + ) + thread2 = threading.Thread( + target=add_ingredient_from_stream, + args=('{"title": "Test Ingredient Stream 2"}', self.testPath4, 2) + ) + + # Start both threads + thread1.start() + thread2.start() + + # Wait for both threads to complete + thread1.join() + thread2.join() + + # Check for errors during ingredient addition + if any(error for error in add_errors if error is not None): + self.fail( + "\n".join( + error for error in add_errors if error is not None)) + + # Verify both ingredients were added successfully + self.assertEqual( + completed_threads, + 2, + "Both threads should have completed") + self.assertEqual( + len(add_errors), + 2, + "Both threads should have completed without errors") + + # Now sign the manifest with the added ingredients + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertEqual(len(active_manifest["ingredients"]), 2) + + # Verify both ingredients exist in the array (order doesn't matter) + ingredient_titles = [ing["title"] + for ing in active_manifest["ingredients"]] + self.assertIn("Test Ingredient Stream 1", ingredient_titles) + self.assertIn("Test Ingredient Stream 2", ingredient_titles) + + builder.close() + + def test_builder_sign_with_multiple_ingredient_random(self): + """Test Builder class operations with 5 random ingredients added in parallel threads.""" + # Test creating builder from JSON + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # Get list of files from files-for-reading-tests directory + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + all_files = [ + f for f in os.listdir(reading_dir) if os.path.isfile( + os.path.join( + reading_dir, f))] + + # Select 5 random files + random.seed(42) # For reproducible testing + selected_files = random.sample(all_files, 5) + + # Thread synchronization + add_errors = [] + add_lock = threading.Lock() + completed_threads = 0 + completion_lock = threading.Lock() + + def add_ingredient(file_name, thread_id): + nonlocal completed_threads + try: + file_path = os.path.join(reading_dir, file_name) + ingredient_json = json.dumps({ + "title": f"Test Ingredient Thread {thread_id} - {file_name}" + }) + + with open(file_path, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + with add_lock: + add_errors.append(None) # Success case + except Exception as e: + with add_lock: + add_errors.append( + f"Thread {thread_id} error with file {file_name}: { + str(e)}") + finally: + with completion_lock: + completed_threads += 1 + + # Create and start 5 threads for parallel ingredient addition + threads = [] + for i, file_name in enumerate(selected_files, 1): + thread = threading.Thread( + target=add_ingredient, + args=(file_name, i) + ) + threads.append(thread) + thread.start() + + # Wait for all threads to complete + for thread in threads: + thread.join() + + # Check for errors during ingredient addition + if any(error for error in add_errors if error is not None): + self.fail( + "\n".join( + error for error in add_errors if error is not None)) + + # Verify all ingredients were added successfully + self.assertEqual( + completed_threads, + 5, + "All 5 threads should have completed") + self.assertEqual( + len(add_errors), + 5, + "All 5 threads should have completed without errors") + + # Now sign the manifest with the added ingredients + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertEqual(len(active_manifest["ingredients"]), 5) + + # Verify all ingredients exist in the array with correct thread IDs + ingredient_titles = [ing["title"] + for ing in active_manifest["ingredients"]] + for i in range(1, 6): + # Find an ingredient with this thread ID + thread_ingredients = [ + title for title in ingredient_titles if f"Thread {i}" in title] + self.assertEqual( + len(thread_ingredients), + 1, + f"Should find exactly one ingredient for thread {i}") + + # Verify the ingredient title contains the file name + thread_ingredient = thread_ingredients[0] + file_name = selected_files[i - 1] + self.assertIn( + file_name, + thread_ingredient, + f"Ingredient for thread {i} should contain its file name") + + builder.close() + + def test_builder_sign_with_multiple_ingredient_async_random(self): + """Test Builder class operations with 5 random ingredients added in parallel using asyncio.""" + # Test creating builder from JSON + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # Get list of files from files-for-reading-tests directory + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + all_files = [ + f for f in os.listdir(reading_dir) if os.path.isfile( + os.path.join( + reading_dir, f))] + + # Select 5 random files + random.seed(42) # For reproducible testing + selected_files = random.sample(all_files, 5) + + # Async synchronization + add_errors = [] + add_lock = asyncio.Lock() + completed_tasks = 0 + completion_lock = asyncio.Lock() + # Barrier to synchronize task starts + start_barrier = asyncio.Barrier(5) + + async def add_ingredient(file_name, task_id): + nonlocal completed_tasks + try: + # Wait for all tasks to be ready + await start_barrier.wait() + + file_path = os.path.join(reading_dir, file_name) + ingredient_json = json.dumps({ + "title": f"Test Ingredient Task {task_id} - {file_name}" + }) + + with open(file_path, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + async with add_lock: + add_errors.append(None) # Success case + except Exception as e: + async with add_lock: + add_errors.append( + f"Task {task_id} error with file {file_name}: { + str(e)}") + finally: + async with completion_lock: + completed_tasks += 1 + + async def run_async_tests(): + # Create all tasks first + tasks = [] + for i, file_name in enumerate(selected_files, 1): + task = asyncio.create_task(add_ingredient(file_name, i)) + tasks.append(task) + + # Wait for all tasks to complete + await asyncio.gather(*tasks) + + # Check for errors during ingredient addition + if any(error for error in add_errors if error is not None): + raise Exception( + "\n".join( + error for error in add_errors if error is not None)) + + # Verify all ingredients were added successfully + self.assertEqual( + completed_tasks, + 5, + "All 5 tasks should have completed") + self.assertEqual( + len(add_errors), + 5, + "All 5 tasks should have completed without errors") + + # Now sign the manifest with the added ingredients + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertEqual(len(active_manifest["ingredients"]), 5) + + # Verify all ingredients exist in the array with correct task + # IDs + ingredient_titles = [ing["title"] + for ing in active_manifest["ingredients"]] + for i in range(1, 6): + # Find an ingredient with this task ID + task_ingredients = [ + title for title in ingredient_titles if f"Task {i}" in title] + self.assertEqual( + len(task_ingredients), + 1, + f"Should find exactly one ingredient for task {i}") + + # Verify the ingredient title contains the file name + task_ingredient = task_ingredients[0] + file_name = selected_files[i - 1] + self.assertIn(file_name, task_ingredient, f"Ingredient for task { + i} should contain its file name") + + # Run the async tests + asyncio.run(run_async_tests()) + builder.close() + + def test_builder_sign_with_same_ingredient_multiple_times(self): + """Test Builder class operations with the same ingredient added multiple times from different threads.""" + # Test creating builder from JSON + builder = Builder.from_json(self.manifestDefinition) + assert builder._builder is not None + + # Thread synchronization + add_errors = [] + add_lock = threading.Lock() + completed_threads = 0 + completion_lock = threading.Lock() + + def add_ingredient(ingredient_json, thread_id): + nonlocal completed_threads + try: + with open(self.testPath3, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + with add_lock: + add_errors.append(None) # Success case + except Exception as e: + with add_lock: + add_errors.append(f"Thread {thread_id} error: {str(e)}") + finally: + with completion_lock: + completed_threads += 1 + + # Create and start 5 threads for parallel ingredient addition + threads = [] + for i in range(1, 6): + # Create unique manifest JSON for each thread + ingredient_json = json.dumps({ + "title": f"Test Ingredient Thread {i}" + }) + + thread = threading.Thread( + target=add_ingredient, + args=(ingredient_json, i) + ) + threads.append(thread) + thread.start() + + # Wait for all threads to complete + for thread in threads: + thread.join() + + # Check for errors during ingredient addition + if any(error for error in add_errors if error is not None): + self.fail( + "\n".join( + error for error in add_errors if error is not None)) + + # Verify all ingredients were added successfully + self.assertEqual( + completed_threads, + 5, + "All 5 threads should have completed") + self.assertEqual( + len(add_errors), + 5, + "All 5 threads should have completed without errors") + + # Now sign the manifest with the added ingredients + with open(self.testPath2, "rb") as file: + output = io.BytesIO(bytearray()) + builder.sign(self.signer, "image/jpeg", file, output) + output.seek(0) + reader = Reader("image/jpeg", output) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists in active manifest + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertEqual(len(active_manifest["ingredients"]), 5) + + # Verify all ingredients exist in the array with correct thread IDs + # and unique metadata + ingredient_titles = [ing["title"] + for ing in active_manifest["ingredients"]] + + # Check that we have 5 unique titles + self.assertEqual(len(set(ingredient_titles)), 5, + "Should have 5 unique ingredient titles") + + # Verify each thread's ingredient exists with correct metadata + for i in range(1, 6): + # Find ingredients with this thread ID + thread_ingredients = [ing for ing in active_manifest["ingredients"] + if ing["title"] == f"Test Ingredient Thread {i}"] + self.assertEqual( + len(thread_ingredients), + 1, + f"Should find exactly one ingredient for thread {i}") + + builder.close() + + def test_builder_sign_with_multiple_ingredient_random_many_threads(self): + """Test Builder class operations with 10 threads, each adding 3 random ingredients and signing a random file.""" + # Number of threads to use in the test + # We are pushing it here, as we want to test with thread count one to two orders of magnitude + # higher than "usual" max numbers of cores on (server) machines may be. + + TOTAL_THREADS_USED = 12 + + # Get list of files from files-for-reading-tests directory + reading_dir = os.path.join(self.data_dir, "files-for-reading-tests") + + # Filter for JPG and PNG files only + all_files = [ + f for f in os.listdir(reading_dir) + if os.path.isfile(os.path.join(reading_dir, f)) + and os.path.splitext(f)[1].lower() in {'.jpg', '.jpeg', '.png'} + ] + + # Ensure we have enough files + self.assertGreaterEqual( + len(all_files), + 3, + "Need at least 3 JPG/PNG files for testing") + + # Thread synchronization + thread_results = {} + completed_threads = 0 + + def thread_work(thread_id): + nonlocal completed_threads + try: + # Create a new builder for this thread + builder = Builder.from_json(self.manifestDefinition) + + # Select 3 random files for ingredients + # Use thread_id as seed for reproducibility + random.seed(thread_id) + ingredient_files = random.sample(all_files, 3) + + # Add each ingredient + for i, file_name in enumerate(ingredient_files, 1): + file_path = os.path.join(reading_dir, file_name) + ingredient_json = json.dumps({ + "title": f"Thread {thread_id} Ingredient {i} - {file_name}" + }) + + with open(file_path, 'rb') as f: + builder.add_ingredient(ingredient_json, "image/jpeg", f) + + # Select a random file for signing + sign_file = random.choice(all_files) + sign_file_path = os.path.join(reading_dir, sign_file) + + # Sign the file + with open(sign_file_path, "rb") as file: + output = io.BytesIO() + builder.sign(self.signer, "image/jpeg", file, output) + + # Ensure all data is written + output.flush() + + # Get the complete data + output_data = output.getvalue() + + # Create a new BytesIO with the complete data + input_stream = io.BytesIO(output_data) + + # Now read and verify the signed manifest + reader = Reader("image/jpeg", input_stream) + json_data = reader.json() + manifest_data = json.loads(json_data) + + # Store results for verification + thread_results[thread_id] = { + 'manifest': manifest_data, + 'ingredient_files': ingredient_files, + 'sign_file': sign_file + } + + # Clean up streams + output.close() + input_stream.close() + + builder.close() + + except Exception as e: + thread_results[thread_id] = { + 'error': str(e) + } + finally: + completed_threads += 1 + + # Create and start threads + threads = [] + for i in range(1, TOTAL_THREADS_USED + 1): + thread = threading.Thread(target=thread_work, args=(i,)) + threads.append(thread) + thread.start() + + # Wait for all threads to complete + for thread in threads: + thread.join() + + # Verify all threads completed + self.assertEqual(completed_threads, TOTAL_THREADS_USED, f"All { + TOTAL_THREADS_USED} threads should have completed") + self.assertEqual( + len(thread_results), + TOTAL_THREADS_USED, + f"Should have results from all {TOTAL_THREADS_USED} threads") + + # Verify results for each thread + for thread_id in range(1, TOTAL_THREADS_USED + 1): + result = thread_results[thread_id] + + # Check if thread encountered an error + if 'error' in result: + self.fail( + f"Thread {thread_id} failed with error: { + result['error']}") + + manifest_data = result['manifest'] + ingredient_files = result['ingredient_files'] + sign_file = result['sign_file'] + + # Verify active manifest exists + self.assertIn("active_manifest", manifest_data) + active_manifest_id = manifest_data["active_manifest"] + + # Verify active manifest object exists + self.assertIn("manifests", manifest_data) + self.assertIn(active_manifest_id, manifest_data["manifests"]) + active_manifest = manifest_data["manifests"][active_manifest_id] + + # Verify ingredients array exists and has correct length + self.assertIn("ingredients", active_manifest) + self.assertIsInstance(active_manifest["ingredients"], list) + self.assertEqual(len(active_manifest["ingredients"]), 3) + + # Verify all ingredients exist with correct thread ID and file + # names + ingredient_titles = [ing["title"] + for ing in active_manifest["ingredients"]] + for i, file_name in enumerate(ingredient_files, 1): + expected_title = f"Thread { + thread_id} Ingredient {i} - {file_name}" + self.assertIn(expected_title, ingredient_titles, f"Thread { + thread_id} should have ingredient with title {expected_title}") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/training.py b/tests/training.py deleted file mode 100644 index 392009c5..00000000 --- a/tests/training.py +++ /dev/null @@ -1,124 +0,0 @@ -# Copyright 2023 Adobe. All rights reserved. -# This file is licensed to you under the Apache License, -# Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) -# or the MIT license (http://opensource.org/licenses/MIT), -# at your option. -# Unless required by applicable law or agreed to in writing, -# this software is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or -# implied. See the LICENSE-MIT and LICENSE-APACHE files for the -# specific language governing permissions and limitations under -# each license. - -# This example shows how to add a do not train assertion to an asset and then verify it - -import json -import os -import sys - -from c2pa import * - -# set up paths to the files we we are using -PROJECT_PATH = os.getcwd() -testFile = os.path.join(PROJECT_PATH,"tests","fixtures","A.jpg") -pemFile = os.path.join(PROJECT_PATH,"tests","fixtures","es256_certs.pem") -keyFile = os.path.join(PROJECT_PATH,"tests","fixtures","es256_private.key") -testOutputFile = os.path.join(PROJECT_PATH,"target","dnt.jpg") - -# a little helper function to get a value from a nested dictionary -from functools import reduce -import operator -def getitem(d, key): - return reduce(operator.getitem, key, d) - -print("version = " + version()) - -# first create an asset with a do not train assertion - -# define a manifest with the do not train assertion -manifest_json = { - "claim_generator_info": [{ - "name": "python_test", - "version": "0.1" - }], - "title": "Do Not Train Example", - "thumbnail": { - "format": "image/jpeg", - "identifier": "thumbnail" - }, - "assertions": [ - { - "label": "c2pa.training-mining", - "data": { - "entries": { - "c2pa.ai_generative_training": { "use": "notAllowed" }, - "c2pa.ai_inference": { "use": "notAllowed" }, - "c2pa.ai_training": { "use": "notAllowed" }, - "c2pa.data_mining": { "use": "notAllowed" } - } - } - } - ] -} - -ingredient_json = { - "title": "A.jpg", - "relationship": "parentOf", - "thumbnail": { - "identifier": "thumbnail", - "format": "image/jpeg" - } -} - -# V2 signing api -try: - # This could be implemented on a server using an HSM - key = open("tests/fixtures/ps256.pem","rb").read() - def sign(data: bytes) -> bytes: - return sign_ps256(data, key) - - certs = open("tests/fixtures/ps256.pub","rb").read() - - # Create a signer from a certificate pem file - signer = create_signer(sign, SigningAlg.PS256, certs, "http://timestamp.digicert.com") - - builder = Builder(manifest_json) - - builder.add_resource_file("thumbnail", "tests/fixtures/A_thumbnail.jpg") - - builder.add_ingredient_file(ingredient_json, "tests/fixtures/A.jpg") - - if os.path.exists(testOutputFile): - os.remove(testOutputFile) - - result = builder.sign_file(signer, testFile, testOutputFile) - -except Exception as err: - sys.exit(err) - -print("V2: successfully added do not train manifest to file " + testOutputFile) - - -# now verify the asset and check the manifest for a do not train assertion - -allowed = True # opt out model, assume training is ok if the assertion doesn't exist -try: - reader = Reader.from_file(testOutputFile) - manifest_store = json.loads(reader.json()) - - manifest = manifest_store["manifests"][manifest_store["active_manifest"]] - for assertion in manifest["assertions"]: - if assertion["label"] == "c2pa.training-mining": - if getitem(assertion, ("data","entries","c2pa.ai_training","use")) == "notAllowed": - allowed = False - - # get the ingredient thumbnail - uri = getitem(manifest,("ingredients", 0, "thumbnail", "identifier")) - reader.resource_to_file(uri, "target/thumbnail_v2.jpg") -except Exception as err: - sys.exit(err) - -if allowed: - print("Training is allowed") -else: - print("Training is not allowed")